Repository: jkroepke/openvpn-auth-oauth2
Pull request #94 (2023-12-22 18:14:36)

Add non-interactive session refresh via token
#### 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 #

- fixes #
 
#### Special notes for your reviewer

#### Particularly user-facing changes

#### Checklist

Complete these before marking the PR as `ready to review`:

<!-- [Place an '[x]' (no spaces) in all applicable fields.] -->
- [x] [DCO](https://github.com/prometheus-community/helm-charts/blob/main/CONTRIBUTING.md#sign-off-your-work) signed
- [x] The PR title has a summary of the changes
- [x] The PR body has a summary to reflect any significant (and particularly user-facing) changes introduced by this PR


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

| [Files](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/94?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) | Patch % | Lines |
|---|---|---|
| [internal/oauth2/refresh.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/94?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-aW50ZXJuYWwvb2F1dGgyL3JlZnJlc2guZ28=) | 0.00% | [20 Missing :warning: ](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/94?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) |
| [internal/oauth2/handler.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/94?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke#diff-aW50ZXJuYWwvb2F1dGgyL2hhbmRsZXIuZ28=) | 66.66% | [8 Missing and 2 partials :warning: ](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/94?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) |
| [internal/openvpn/client.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/94?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke#diff-aW50ZXJuYWwvb3BlbnZwbi9jbGllbnQuZ28=) | 56.52% | [9 Missing and 1 partial :warning: ](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/94?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) |
| [internal/oauth2/provider.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/94?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) | 87.75% | [5 Missing and 1 partial :warning: ](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/94?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) |
| [internal/config/config.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/94?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==) | 78.94% | [3 Missing and 1 partial :warning: ](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/94?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) |
| [internal/openvpn/callbacks.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/94?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke#diff-aW50ZXJuYWwvb3BlbnZwbi9jYWxsYmFja3MuZ28=) | 50.00% | [2 Missing and 2 partials :warning: ](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/94?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) |
| [internal/oauth2/providers/generic/main.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/94?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=) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/94?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) |

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


```diff
@@            Coverage Diff             @@
##             main      #94      +/-   ##
==========================================
- Coverage   77.07%   76.25%   -0.83%     
==========================================
  Files          25       26       +1     
  Lines        1518     1554      +36     
==========================================
+ Hits         1170     1185      +15     
- Misses        286      308      +22     
+ Partials       62       61       -1     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/94?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/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 2d3e174..2af229a 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -26,7 +26,7 @@ jobs:
           go-version-file: 'go.mod'
 
       - run: go build
-      - run: go test ./... -timeout 20s -race -covermode=atomic -coverprofile=coverage.out
+      - run: go test ./... -timeout 20s -race -covermode=atomic -coverprofile=coverage.out -coverpkg=./...
       - run: go test ./... -bench . -benchmem -count 3
 
       - name: Install plugin dependencies
diff --git a/.gitignore b/.gitignore
index e2d9c37..6722d09 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
 *.iml
+*.out
+
 .idea
 
 tests/*
diff --git a/.golangci.yaml b/.golangci.yaml
index 8be25c0..8b037e5 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -30,6 +30,7 @@ issues:
         - cyclop
         - wrapcheck
         - maintidx
+        - dogsled
 
 linters-settings:
   varnamelen:
diff --git a/README.md b/README.md
index 2a29372..dd4a459 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
 # openvpn-auth-oauth2
 
 openvpn-auth-oauth2 is a management client for OpenVPN that handles the single sign-on (SSO) authentication
-of connecting users against OIDC providers like 
+of connecting users against OIDC providers like
 
 * Microsoft Entra ID (Azure AD)
 * GitHub
@@ -42,12 +42,11 @@ of connecting users against OIDC providers like
 
 - [OpenVPN Community Client for Windows 2.6.0](https://openvpn.net/community-downloads/)
 - [Tunnelblick](https://tunnelblick.net/) [4.0.0beta10](https://github.com/Tunnelblick/Tunnelblick/issues/676)
-- [OpenVPN 3 core library 3.9](https://github.com/OpenVPN/openvpn3)
+- [OpenVPN 3 core library 3.9](https://github.com/OpenVPN/openvpn3)
 
 #### Partial Working
 
-- [OpenVPN Connect v3 for macOS](https://openvpn.net/vpn-server-resources/connecting-to-access-server-with-macos/) ([REAUTH issues](https://github.com/OpenVPN/openvpn3/issues/282))
-- OpenVPN 3 on Linux ([REAUTH issues](https://github.com/OpenVPN/openvpn3/issues/282))
+- [OpenVPN Connect v3 for Windows/macOS/Linux](https://openvpn.net/vpn-server-resources/connecting-to-access-server-with-macos/) ([REAUTH issues](https://github.com/OpenVPN/openvpn3/issues/282); workaround: [`--oauth2.refresh.enabled=true`](https://github.com/jkroepke/openvpn-auth-oauth2/wiki/Configuration#non-interactive-session-refresh))
 
 #### Non-Working
 
@@ -59,7 +58,7 @@ https://github.com/jkroepke/openvpn-auth-oauth2/wiki/Installation
 
 # Configuration
 
-https://github.com/jkroepke/openvpn-auth-oauth2/wiki/Configuration
+https://github.com/jkroepke/openvpn-auth-oauth2/wiki/Configuration#
 
 # Related projects
 
diff --git a/cmd/root.go b/cmd/root.go
index 314b528..5839ace 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -16,7 +16,6 @@ import (
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/storage"
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
 )
 
 //nolint:cyclop
@@ -59,17 +58,17 @@ func Execute(args []string, logWriter io.Writer, version, commit, date string) i
 		return 1
 	}
 
-	storageClient := storage.New(conf.OAuth2.TokenStore.Key.String(), conf.OAuth2.TokenStore.Expires)
-	openvpnClient := openvpn.NewClient(logger, conf, storageClient)
+	storageClient := storage.New(conf.OAuth2.Refresh.Secret.String(), conf.OAuth2.Refresh.Expires)
+	oauth2Client := oauth2.New(logger, conf, storageClient)
+	openvpnClient := openvpn.NewClient(logger, conf, oauth2Client)
 
-	provider, err := oauth2.NewProvider(logger, conf, storageClient, openvpnClient)
-	if err != nil {
+	if err = oauth2Client.Discover(openvpnClient); err != nil {
 		logger.Error(err.Error())
 
 		return 1
 	}
 
-	server := http.NewHTTPServer(logger, conf, provider.Handler())
+	server := http.NewHTTPServer(logger, conf, oauth2Client.Handler())
 
 	done := make(chan int, 1)
 
@@ -102,7 +101,7 @@ func Execute(args []string, logWriter io.Writer, version, commit, date string) i
 	select {
 	case returnCode = <-done:
 	case sig := <-termCh:
-		logger.Info(utils.StringConcat("receiving signal: ", sig.String()))
+		logger.Info(fmt.Sprintf("receiving signal: %s", sig.String()))
 	}
 
 	shutdown(logger, openvpnClient, server)
diff --git a/docs/Configuration.md b/docs/Configuration.md
index b473ad8..7f3803d 100644
--- a/docs/Configuration.md
+++ b/docs/Configuration.md
@@ -6,60 +6,98 @@ Linux package, use the file `/etc/sysconfig/openvpn-auth-oauth2` to configure op
 ## Supported configuration properties
 
 ```
-Usage of openvpn-auth-oauth2:
-      --config string                         path to one .yaml config files (env: CONFIG_CONFIG)
-      --http.baseurl string                   listen addr for client listener (env: CONFIG_HTTP_BASEURL) (default "http://localhost:9000")
-      --http.cert string                      Path to tls server certificate (env: CONFIG_HTTP_CERT)
-      --http.check.ipaddr                     Check if client IP in http and VPN is equal (env: CONFIG_HTTP_CHECK_IPADDR)
-      --http.enable-proxy-headers             Use X-Forward-For http header for client ips (env: CONFIG_HTTP_ENABLE__PROXY__HEADERS)
-      --http.key string                       Path to tls server key (env: CONFIG_HTTP_KEY)
-      --http.listen string                    listen addr for client listener (env: CONFIG_HTTP_LISTEN) (default ":9000")
-      --http.secret string                    Cookie secret (env: CONFIG_HTTP_SECRET)
-      --http.template string                  Path to a HTML file which is displayed at the end of the screen (env: CONFIG_HTTP_TEMPLATE)
-      --http.tls                              enable TLS listener (env: CONFIG_HTTP_TLS)
-      --log.format string                     log format. json or console (env: CONFIG_LOG_FORMAT) (default "console")
-      --log.level string                      log level (env: CONFIG_LOG_LEVEL) (default "info")
-      --oauth2.authorize-params string        additional url query parameter to authorize endpoint (env: CONFIG_OAUTH2_AUTHORIZE__PARAMS)
-      --oauth2.client.id string               oauth2 client id (env: CONFIG_OAUTH2_CLIENT_ID)
-      --oauth2.client.secret string           oauth2 client secret (env: CONFIG_OAUTH2_CLIENT_SECRET)
-      --oauth2.endpoint.auth string           custom oauth2 auth endpoint (env: CONFIG_OAUTH2_ENDPOINT_AUTH)
-      --oauth2.endpoint.discovery string      custom oauth2 discovery url (env: CONFIG_OAUTH2_ENDPOINT_DISCOVERY)
-      --oauth2.endpoint.token string          custom oauth2 token endpoint (env: CONFIG_OAUTH2_ENDPOINT_TOKEN)
-      --oauth2.issuer string                  oauth2 issuer (env: CONFIG_OAUTH2_ISSUER)
-      --oauth2.provider string                oauth2 provider (env: CONFIG_OAUTH2_PROVIDER) (default "generic")
-      --oauth2.scopes strings                 oauth2 token scopes. Defaults depends on oauth2.provider (env: CONFIG_OAUTH2_SCOPES)
-      --oauth2.validate.common-name string    validate common_name from OpenVPN with IDToken claim (env: CONFIG_OAUTH2_VALIDATE_COMMON__NAME)
-      --oauth2.validate.groups strings        oauth2 required user groups (env: CONFIG_OAUTH2_VALIDATE_GROUPS)
-      --oauth2.validate.ipaddr                validate client ipaddr between VPN and oidc token (env: CONFIG_OAUTH2_VALIDATE_IPADDR)
-      --oauth2.validate.issuer                validate issuer from oidc discovery (env: CONFIG_OAUTH2_VALIDATE_ISSUER) (default true)
-      --oauth2.validate.roles strings         oauth2 required user roles (env: CONFIG_OAUTH2_VALIDATE_ROLES)
-      --openvpn.addr string                   openvpn management interface addr. Must start with unix:// or tcp:// (env: CONFIG_OPENVPN_ADDR) (default "unix:/run/openvpn/server.sock")
-      --openvpn.auth-pending-timeout string   How long OpenVPN server wait until user is authenticated (env: CONFIG_OPENVPN_AUTH__PENDING__TIMEOUT) (default "3m0s")
-      --openvpn.auth-token-user               Define auth-token-user for all sessions (env: CONFIG_OPENVPN_AUTH__TOKEN__USER) (default true)
-      --openvpn.bypass.cn strings             bypass oauth authentication for CNs (env: CONFIG_OPENVPN_BYPASS_CN)
-      --openvpn.common-name.mode string       If common names are too long, use md5/sha1 to hash them or omit to skip them. If omit, oauth2.validate.common-name does not work anymore. Values: [plain,omit] (env: CONFIG_OPENVPN_COMMON__NAME_MODE) (default "plain")
-      --openvpn.password string               openvpn management interface password (env: CONFIG_OPENVPN_PASSWORD)
-      --version                               show version
+Usage of ./openvpn-auth-oauth2:
+
+  --config string
+    	path to one .yaml config file (env: CONFIG_CONFIG)
+  --http.baseurl string
+    	listen addr for client listener (env: CONFIG_HTTP_BASEURL) (default "http://localhost:9000")
+  --http.cert string
+    	Path to tls server certificate (env: CONFIG_HTTP_CERT)
+  --http.check.ipaddr
+    	Check if client IP in http and VPN is equal (env: CONFIG_HTTP_CHECK_IPADDR)
+  --http.enable-proxy-headers
+    	Use X-Forward-For http header for client ips (env: CONFIG_HTTP_ENABLE__PROXY__HEADERS)
+  --http.key string
+    	Path to tls server key (env: CONFIG_HTTP_KEY)
+  --http.listen string
+    	listen addr for client listener (env: CONFIG_HTTP_LISTEN) (default ":9000")
+  --http.secret value
+    	Cookie secret (env: CONFIG_HTTP_SECRET)
+  --http.template string
+    	Path to a HTML file which is displayed at the end of the screen (env: CONFIG_HTTP_TEMPLATE)
+  --http.tls
+    	enable TLS listener (env: CONFIG_HTTP_TLS)
+  --log.format string
+    	log format. json or console (env: CONFIG_LOG_FORMAT) (default "console")
+  --log.level value
+    	log level (env: CONFIG_LOG_LEVEL) (default INFO)
+  --oauth2.authorize-params string
+    	additional url query parameter to authorize endpoint (env: CONFIG_OAUTH2_AUTHORIZE__PARAMS)
+  --oauth2.client.id string
+    	oauth2 client id (env: CONFIG_OAUTH2_CLIENT_ID)
+  --oauth2.client.secret value
+    	oauth2 client secret (env: CONFIG_OAUTH2_CLIENT_SECRET)
+  --oauth2.endpoint.auth string
+    	custom oauth2 auth endpoint (env: CONFIG_OAUTH2_ENDPOINT_AUTH)
+  --oauth2.endpoint.discovery string
+    	custom oauth2 discovery url (env: CONFIG_OAUTH2_ENDPOINT_DISCOVERY)
+  --oauth2.endpoint.token string
+    	custom oauth2 token endpoint (env: CONFIG_OAUTH2_ENDPOINT_TOKEN)
+  --oauth2.issuer string
+    	oauth2 issuer (env: CONFIG_OAUTH2_ISSUER)
+  --oauth2.provider string
+    	oauth2 provider (env: CONFIG_OAUTH2_PROVIDER) (default "generic")
+  --oauth2.refresh.enabled
+    	If true, openvpn-auth-oauth2 stores refresh tokens and will use it do an non-interaction reauth. (env: CONFIG_OAUTH2_REFRESH_ENABLED)
+  --oauth2.refresh.expires duration
+    	TTL of stored oauth2 token. (env: CONFIG_OAUTH2_REFRESH_EXPIRES) (default 8h0m0s)
+  --oauth2.scopes value
+    	oauth2 token scopes. Defaults depends on oauth2.provider (env: CONFIG_OAUTH2_SCOPES)
+  --oauth2.validate.common-name string
+    	validate common_name from OpenVPN with IDToken claim (env: CONFIG_OAUTH2_VALIDATE_COMMON__NAME)
+  --oauth2.validate.groups value
+    	oauth2 required user groups (env: CONFIG_OAUTH2_VALIDATE_GROUPS)
+  --oauth2.validate.ipaddr
+    	validate client ipaddr between VPN and oidc token (env: CONFIG_OAUTH2_VALIDATE_IPADDR)
+  --oauth2.validate.issuer
+    	validate issuer from oidc discovery (env: CONFIG_OAUTH2_VALIDATE_ISSUER) (default true)
+  --oauth2.validate.roles value
+    	oauth2 required user roles (env: CONFIG_OAUTH2_VALIDATE_ROLES)
+  --openvpn.addr string
+    	openvpn management interface addr. Must start with unix:// or tcp:// (env: CONFIG_OPENVPN_ADDR) (default "unix:/run/openvpn/server.sock")
+  --openvpn.auth-pending-timeout duration
+    	How long OpenVPN server wait until user is authenticated (env: CONFIG_OPENVPN_AUTH__PENDING__TIMEOUT) (default 3m0s)
+  --openvpn.auth-token-user
+    	Define auth-token-user for all sessions (env: CONFIG_OPENVPN_AUTH__TOKEN__USER) (default true)
+  --openvpn.bypass.cn value
+    	bypass oauth authentication for CNs (env: CONFIG_OPENVPN_BYPASS_CN)
+  --openvpn.common-name.mode string
+    	If common names are too long, use md5/sha1 to hash them or omit to skip them. If omit, oauth2.validate.common-name does not work anymore. Values: [plain,omit] (env: CONFIG_OPENVPN_COMMON__NAME_MODE) (default "plain")
+  --openvpn.password value
+    	openvpn management interface password (env: CONFIG_OPENVPN_PASSWORD)
+  --version
+    	show version
 ```
 
 ## Configuration openvpn-auth-oauth2
-openvpn-auth-oauth2 starts a http listener which needs to be accessible from OpenVPN client before the VPN connection is established.
-By default, the http listener runs on :9000.
+openvpn-auth-oauth2 starts an HTTP listener which needs to be accessible from the OpenVPN client before the VPN connection is established.
+By default, the http listener runs on `:9000`.
 
-It's highly recommend to put openvpn-auth-oauth2 behind a reverse proxy which terminates the TLS connections. It's important to configure
-`CONFIG_HTTP_BASE_URL` because openvpn-auth-oauth2 need to know the redirect url.
+It'd highly recommend putting openvpn-auth-oauth2 behind a reverse proxy which terminates the TLS connections.
+It's important to configure `CONFIG_HTTP_BASE_URL` because openvpn-auth-oauth2 need to know the redirect url.
 
 Example:
-```conf
+```ini
 # openvpn-auth-oauth2 config file
 CONFIG_HTTP_LISTEN=:9000
 CONFIG_HTTP_BASE_URL=https://login.example.com
 ```
 
 ## Setup OpenVPN server
-To connect openvpn-auth-oauth2 with openvpn server add lines below:
+To connect openvpn-auth-oauth2 with openvpn server, add lines below:
 
-```conf
+```ini
 # openvpn server.conf
 ...
 # /etc/openvpn/password.txt is a password file where the password must be on first line
@@ -68,7 +106,7 @@ management-hold
 management-client-auth
 ```
 
-```conf
+```ini
 # openvpn-auth-oauth2 config file
 CONFIG_OPENVPN_ADDR=unix:///run/openvpn/server.sock
 CONFIG_OPENVPN_PASSWORD=<password>
@@ -79,9 +117,9 @@ CONFIG_OPENVPN_PASSWORD=<password>
 See [Providers](Providers) for more information
 
 ## Full configuration example
-Configuration openvpn-auth-oauth2 for zitadel
+Configuration openvpn-auth-oauth2 for [zitadel](https://zitadel.com/)
 
-```conf
+```ini
 # Define the public http endpoint here.
 CONFIG_HTTP_BASEURL=http://<vpn>:9000/
 CONFIG_HTTP_LISTEN=:9000
@@ -94,3 +132,27 @@ CONFIG_OAUTH2_SCOPES=openid profile email offline_access
 CONFIG_OAUTH2_CLIENT_ID=34372461928374612@any
 CONFIG_OAUTH2_CLIENT_SECRET=ASDhjgadjhAUYSDGjkhasgdIATWDGJHASDtiwGDJAHSGDutwqdygASJKD12hfva
 ```
+
+## Non-interactive session refresh
+
+With default settings, openvpn-auth-oauth2 does not store any tokens from the users. This requires an interactive login from user on
+each authentication, included on TLS soft-resets (if `reneg-sec` is triggered).
+
+The interactive login can be avoided by requesting [refresh tokens](https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/)
+(via oauth2 scope `offline_access`; enabled by default) and store the token inside openvpn-auth-oauth2.
+
+If enabled (via `--oauth2.refresh.enabled=true`), `openvpn-auth-oauth2` will store the oauth2 refresh token in an in-memory key-value store.
+`openvpn-auth-oauth2` is using AES to encrypt the tokens.
+The token will be bound to the OpenVPN client ID.
+While on initially connect the interactive login is still mandatory, `openvpn-auth-oauth2` tries to initiate a non-interactive login with the refresh
+token against the OIDC provider and fallbacks to interactive login, if unsuccessful.
+
+References:
+- https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#refresh-the-access-token
+- https://curity.io/resources/learn/oauth-refresh/
+- https://developer.okta.com/docs/guides/refresh-tokens/main/
+
+```ini
+CONFIG_OAUTH2_REFRESH_ENABLED=true
+CONFIG_OAUTH2_REFRESH_EXPIRES=8h
+```
diff --git a/internal/config/config.go b/internal/config/config.go
index ac4e10b..e7a444b 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -158,6 +158,21 @@ func FlagSet(name string) *flag.FlagSet {
 		Defaults.OAuth2.Client.Secret,
 		"oauth2 client secret",
 	)
+	flagSet.Bool(
+		"oauth2.refresh.enabled",
+		Defaults.OAuth2.Refresh.Enabled,
+		"If true, openvpn-auth-oauth2 stores refresh tokens and will use it do an non-interaction reauth.",
+	)
+	flagSet.Duration(
+		"oauth2.refresh.expires",
+		Defaults.OAuth2.Refresh.Expires,
+		"TTL of stored oauth2 token.",
+	)
+	flagSet.TextVar(new(Secret),
+		"oauth2.refresh.secret",
+		Defaults.OAuth2.Refresh.Secret,
+		"Encryption key for stored token in encrypted format.",
+	)
 	flagSet.TextVar(new(StringSlice),
 		"oauth2.validate.groups",
 		Defaults.OAuth2.Validate.Groups,
@@ -188,16 +203,6 @@ func FlagSet(name string) *flag.FlagSet {
 		Defaults.OAuth2.Scopes,
 		"oauth2 token scopes. Defaults depends on oauth2.provider",
 	)
-	flagSet.Duration(
-		"oauth2.tokenstore.expires",
-		Defaults.OAuth2.TokenStore.Expires,
-		"TTL of stored oauth2 token.",
-	)
-	flagSet.TextVar(new(Secret),
-		"oauth2.tokenstore.key",
-		Defaults.OAuth2.TokenStore.Key,
-		"encryption key to encrypt oauth2 token in store",
-	)
 	flagSet.Bool(
 		"version",
 		false,
@@ -266,6 +271,12 @@ func Validate(mode int, conf Config) error { //nolint:cyclop
 		}
 	}
 
+	if conf.OAuth2.Refresh.Enabled {
+		if !slices.Contains([]int{16, 24, 32}, len(conf.OAuth2.Refresh.Secret)) {
+			return errors.New("oauth2.refresh.secret requires a length of 16, 24 or 32")
+		}
+	}
+
 	if mode == ManagementClient {
 		for key, value := range map[string]*url.URL{
 			"openvpn.addr": conf.OpenVpn.Addr,
diff --git a/internal/config/defaults.go b/internal/config/defaults.go
index 6e3ba74..9bb0661 100644
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -57,9 +57,9 @@ var Defaults = Config{
 			IPAddr: false,
 			Issuer: true,
 		},
-		Scopes: []string{},
-		TokenStore: OAuth2TokenStore{
+		Refresh: OAuth2Refresh{
 			Expires: time.Hour * 8,
 		},
+		Scopes: []string{},
 	},
 }
diff --git a/internal/config/types.go b/internal/config/types.go
index b82a813..25cb357 100644
--- a/internal/config/types.go
+++ b/internal/config/types.go
@@ -56,15 +56,15 @@ type OpenVPNCommonName struct {
 }
 
 type OAuth2 struct {
-	Issuer          *url.URL         `koanf:"issuer"`
-	Provider        string           `koanf:"provider"`
-	AuthorizeParams string           `koanf:"authorize-params"`
-	Endpoints       OAuth2Endpoints  `koanf:"endpoint"`
-	Client          OAuth2Client     `koanf:"client"`
-	Scopes          StringSlice      `koanf:"scopes"`
-	Pkce            bool             `koanf:"pkce"`
-	Validate        OAuth2Validate   `koanf:"validate"`
-	TokenStore      OAuth2TokenStore `koanf:"tokenstore"`
+	Issuer          *url.URL        `koanf:"issuer"`
+	Provider        string          `koanf:"provider"`
+	AuthorizeParams string          `koanf:"authorize-params"`
+	Endpoints       OAuth2Endpoints `koanf:"endpoint"`
+	Client          OAuth2Client    `koanf:"client"`
+	Scopes          StringSlice     `koanf:"scopes"`
+	Pkce            bool            `koanf:"pkce"`
+	Validate        OAuth2Validate  `koanf:"validate"`
+	Refresh         OAuth2Refresh   `koanf:"refresh"`
 }
 
 type OAuth2Client struct {
@@ -86,9 +86,10 @@ type OAuth2Validate struct {
 	CommonName string      `koanf:"common_name"`
 }
 
-type OAuth2TokenStore struct {
+type OAuth2Refresh struct {
+	Enabled bool          `koanf:"enabled"`
 	Expires time.Duration `koanf:"expires"`
-	Key     Secret        `koanf:"salt"`
+	Secret  Secret        `koanf:"secret"`
 }
 
 type OpenVPNCommonNameMode int
diff --git a/internal/oauth2/handler.go b/internal/oauth2/handler.go
index cea9885..1cdd4c2 100644
--- a/internal/oauth2/handler.go
+++ b/internal/oauth2/handler.go
@@ -2,7 +2,6 @@ package oauth2
 
 import (
 	"context"
-	"encoding/base64"
 	"fmt"
 	"io/fs"
 	"log/slog"
@@ -13,8 +12,8 @@ import (
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/idtoken"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/types"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/types"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/ui"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
 	"github.com/zitadel/oidc/v3/pkg/client/rp"
@@ -22,29 +21,28 @@ import (
 )
 
 type OpenVPN interface {
-	AcceptClient(logger *slog.Logger, client state.ClientIdentifier)
-	AcceptClientWithToken(logger *slog.Logger, client state.ClientIdentifier, username string)
+	AcceptClient(logger *slog.Logger, client state.ClientIdentifier, username string)
 	DenyClient(logger *slog.Logger, client state.ClientIdentifier, reason string)
 }
 
-func (provider Provider) Handler() *http.ServeMux {
+func (p *Provider) Handler() *http.ServeMux {
 	staticFs, err := fs.Sub(ui.Static, "static")
 	if err != nil {
 		panic(err)
 	}
 
-	basePath := strings.TrimSuffix(provider.conf.HTTP.BaseURL.Path, "/")
+	basePath := strings.TrimSuffix(p.conf.HTTP.BaseURL.Path, "/")
 
 	mux := http.NewServeMux()
 	mux.Handle("/", http.NotFoundHandler())
 	mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFs))))
-	mux.Handle(utils.StringConcat(basePath, "/oauth2/start"), provider.oauth2Start())
-	mux.Handle(utils.StringConcat(basePath, "/oauth2/callback"), provider.oauth2Callback())
+	mux.Handle(utils.StringConcat(basePath, "/oauth2/start"), p.oauth2Start())
+	mux.Handle(utils.StringConcat(basePath, "/oauth2/callback"), p.oauth2Callback())
 
 	return mux
 }
 
-func (provider Provider) oauth2Start() http.Handler {
+func (p *Provider) oauth2Start() http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		sendCacheHeaders(w)
 
@@ -56,24 +54,24 @@ func (provider Provider) oauth2Start() http.Handler {
 		}
 
 		session := state.NewEncoded(sessionState)
-		if err := session.Decode(provider.conf.HTTP.Secret.String()); err != nil {
-			provider.logger.Warn(utils.StringConcat("invalid state: ", err.Error()))
-			provider.logger.Debug(sessionState)
+		if err := session.Decode(p.conf.HTTP.Secret.String()); err != nil {
+			p.logger.Warn(utils.StringConcat("invalid state: ", err.Error()))
+			p.logger.Debug(sessionState)
 			w.WriteHeader(http.StatusBadRequest)
 
 			return
 		}
 
-		logger := provider.logger.With(
+		logger := p.logger.With(
 			slog.String("common_name", session.CommonName),
 			slog.Uint64("cid", session.Client.Cid),
 			slog.Uint64("kid", session.Client.Kid),
 		)
 
-		if provider.conf.HTTP.Check.IPAddr {
-			ok, httpStatusCode, denyReason := checkClientIPAddr(r, logger, session, provider.conf)
+		if p.conf.HTTP.Check.IPAddr {
+			ok, httpStatusCode, denyReason := checkClientIPAddr(r, logger, session, p.conf)
 			if !ok {
-				provider.openvpn.DenyClient(logger, session.Client, denyReason)
+				p.openvpn.DenyClient(logger, session.Client, denyReason)
 				w.WriteHeader(httpStatusCode)
 
 				return
@@ -84,7 +82,7 @@ func (provider Provider) oauth2Start() http.Handler {
 
 		rp.AuthURLHandler(func() string {
 			return sessionState
-		}, provider.RelyingParty, provider.authorizeParams...).ServeHTTP(w, r)
+		}, p.RelyingParty, p.authorizeParams...).ServeHTTP(w, r)
 	})
 }
 
@@ -122,7 +120,7 @@ func checkClientIPAddr(r *http.Request, logger *slog.Logger, session state.State
 	return true, 0, ""
 }
 
-func (provider Provider) oauth2Callback() http.Handler {
+func (p *Provider) oauth2Callback() http.Handler {
 	return rp.CodeExchangeHandler(func(
 		w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*idtoken.Claims], encryptedSession string,
 		rp rp.RelyingParty,
@@ -134,7 +132,7 @@ func (provider Provider) oauth2Callback() http.Handler {
 		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 		defer cancel()
 
-		logger := provider.logger
+		logger := p.logger
 		if tokens.IDTokenClaims != nil {
 			logger = logger.With(
 				slog.String("idtoken.subject", tokens.IDTokenClaims.Subject),
@@ -143,10 +141,10 @@ func (provider Provider) oauth2Callback() http.Handler {
 		}
 
 		session := state.NewEncoded(encryptedSession)
-		if err := session.Decode(provider.conf.HTTP.Secret.String()); err != nil {
+		if err := session.Decode(p.conf.HTTP.Secret.String()); err != nil {
 			logger.Warn(err.Error())
 			logger.Debug(encryptedSession)
-			writeError(w, logger, provider.conf, http.StatusInternalServerError, "invalidSession", err.Error())
+			writeError(w, logger, p.conf, http.StatusInternalServerError, "invalidSession", err.Error())
 
 			return
 		}
@@ -157,11 +155,11 @@ func (provider Provider) oauth2Callback() http.Handler {
 			slog.Uint64("kid", session.Client.Kid),
 		)
 
-		user, err := provider.OIDC.GetUser(ctx, tokens)
+		user, err := p.OIDC.GetUser(ctx, tokens)
 		if err != nil {
 			logger.Error(err.Error())
-			provider.openvpn.DenyClient(logger, session.Client, "unable to fetch user data")
-			writeError(w, logger, provider.conf, http.StatusInternalServerError, "fetchUser", err.Error())
+			p.openvpn.DenyClient(logger, session.Client, "unable to fetch user data")
+			writeError(w, logger, p.conf, http.StatusInternalServerError, "fetchUser", err.Error())
 
 			return
 		}
@@ -171,34 +169,31 @@ func (provider Provider) oauth2Callback() http.Handler {
 			slog.String("user.preferred_username", user.PreferredUsername),
 		)
 
-		err = provider.OIDC.CheckUser(ctx, session, user, tokens)
+		err = p.OIDC.CheckUser(ctx, session, user, tokens)
 		if err != nil {
 			reason := err.Error()
 			logger.Warn(reason)
-			provider.openvpn.DenyClient(logger, session.Client, "client rejected")
+			p.openvpn.DenyClient(logger, session.Client, "client rejected")
 
-			writeError(w, logger, provider.conf, http.StatusInternalServerError, "tokenValidation", reason)
+			writeError(w, logger, p.conf, http.StatusInternalServerError, "tokenValidation", reason)
 
 			return
 		}
 
 		logger.Info("successful authorization via oauth2")
 
-		if provider.conf.OpenVpn.AuthTokenUser {
-			username := getAuthTokenUsername(session, user)
-			provider.openvpn.AcceptClientWithToken(logger, session.Client, username)
-		} else {
-			provider.openvpn.AcceptClient(logger, session.Client)
-		}
+		p.openvpn.AcceptClient(logger, session.Client, getAuthTokenUsername(session, user))
 
-		if tokens.RefreshToken != "" {
-			if err = provider.storage.Set(session.Client.Cid, tokens.RefreshToken); err != nil {
+		if p.conf.OAuth2.Refresh.Enabled {
+			if tokens.RefreshToken == "" {
+				p.logger.Warn("oauth2.refresh is enabled, but provider does not return refresh token")
+			} else if err = p.storage.Set(session.Client.Cid, tokens.RefreshToken); err != nil {
 				logger.Warn(err.Error())
 			}
 		}
 
-		writeSuccess(w, provider.conf, logger)
-	}, provider.RelyingParty)
+		writeSuccess(w, p.conf, logger)
+	}, p.RelyingParty)
 }
 
 func getAuthTokenUsername(session state.State, user types.UserData) string {
@@ -209,7 +204,7 @@ func getAuthTokenUsername(session state.State, user types.UserData) string {
 		username = user.Subject
 	}
 
-	return base64.StdEncoding.EncodeToString([]byte(username))
+	return username
 }
 
 func writeError(w http.ResponseWriter, logger *slog.Logger, conf config.Config, httpCode int, errorType, errorDesc string) {
diff --git a/internal/oauth2/provider.go b/internal/oauth2/provider.go
index 7d46ab2..81ad6bd 100644
--- a/internal/oauth2/provider.go
+++ b/internal/oauth2/provider.go
@@ -21,34 +21,52 @@ import (
 	"golang.org/x/oauth2"
 )
 
-// NewProvider returns a [Provider] instance.
-func NewProvider(logger *slog.Logger, conf config.Config, storageClient *storage.Storage, openvpnClient OpenVPN) (Provider, error) {
-	provider, err := newOidcProvider(conf)
+// New returns a [Provider] instance.
+func New(logger *slog.Logger, conf config.Config, storageClient *storage.Storage) *Provider {
+	return &Provider{
+		storage: storageClient,
+		conf:    conf,
+		logger:  logger,
+	}
+}
+
+// Discover initiate the discovery of OIDC provider.
+func (p *Provider) Discover(openvpn OpenVPN) error {
+	var err error
+
+	p.openvpn = openvpn
+
+	p.OIDC, err = newOidcProvider(p.conf)
+	if err != nil {
+		return err
+	}
+
+	endpoints, err := p.OIDC.GetEndpoints(p.conf)
 	if err != nil {
-		return Provider{}, err
+		return fmt.Errorf("error getting endpoints: %w", err)
 	}
 
-	authorizeParams, err := GetAuthorizeParams(conf.OAuth2.AuthorizeParams)
+	p.authorizeParams, err = GetAuthorizeParams(p.conf.OAuth2.AuthorizeParams)
 	if err != nil {
-		return Provider{}, err
+		return err
 	}
 
-	basePath := conf.HTTP.BaseURL.JoinPath("/oauth2")
+	basePath := p.conf.HTTP.BaseURL.JoinPath("/oauth2")
 	redirectURI := basePath.JoinPath("/callback").String()
 
-	cookieKey := []byte(conf.HTTP.Secret)
+	cookieKey := []byte(p.conf.HTTP.Secret)
 	cookieOpt := []httphelper.CookieHandlerOpt{
-		httphelper.WithMaxAge(int(conf.OpenVpn.AuthPendingTimeout.Seconds()) + 5),
+		httphelper.WithMaxAge(int(p.conf.OpenVpn.AuthPendingTimeout.Seconds()) + 5),
 		httphelper.WithPath(basePath.Path),
 		httphelper.WithDomain(basePath.Hostname()),
 	}
 
-	if conf.HTTP.BaseURL.Scheme == "http" {
+	if p.conf.HTTP.BaseURL.Scheme == "http" {
 		cookieOpt = append(cookieOpt, httphelper.WithUnsecure())
 	}
 
 	cookieHandler := httphelper.NewCookieHandler(cookieKey, cookieKey, cookieOpt...)
-	providerLogger := log.NewZitadelLogger(logger)
+	providerLogger := log.NewZitadelLogger(p.logger)
 
 	options := []rp.Option{
 		rp.WithLogger(providerLogger),
@@ -56,83 +74,68 @@ func NewProvider(logger *slog.Logger, conf config.Config, storageClient *storage
 		rp.WithVerifierOpts(rp.WithIssuedAtOffset(5 * time.Second)),
 		rp.WithHTTPClient(&http.Client{Timeout: time.Second * 30, Transport: utils.NewUserAgentTransport(nil)}),
 		rp.WithErrorHandler(func(w http.ResponseWriter, r *http.Request, errorType string, errorDesc string, encryptedSession string) {
-			errorHandler(w, conf, logger, openvpnClient, http.StatusInternalServerError, errorType, errorDesc, encryptedSession)
+			errorHandler(w, p.conf, p.logger, p.openvpn, http.StatusInternalServerError, errorType, errorDesc, encryptedSession)
 		}),
 		rp.WithUnauthorizedHandler(func(w http.ResponseWriter, r *http.Request, desc string, encryptedSession string) {
-			errorHandler(w, conf, logger, openvpnClient, http.StatusUnauthorized, "Unauthorized", desc, encryptedSession)
+			errorHandler(w, p.conf, p.logger, p.openvpn, http.StatusUnauthorized, "Unauthorized", desc, encryptedSession)
 		}),
 	}
 
-	if conf.OAuth2.Pkce {
+	if p.conf.OAuth2.Pkce {
 		options = append(options, rp.WithPKCE(cookieHandler))
 	}
 
-	endpoints, err := provider.GetEndpoints(conf)
-	if err != nil {
-		return Provider{}, fmt.Errorf("error getting endpoints: %w", err)
-	}
-
-	scopes := conf.OAuth2.Scopes
+	scopes := p.conf.OAuth2.Scopes
 	if len(scopes) == 0 {
-		scopes = provider.GetDefaultScopes()
+		scopes = p.OIDC.GetDefaultScopes()
 	}
 
-	var relyingParty rp.RelyingParty
-
 	if endpoints == (oauth2.Endpoint{}) {
-		if !config.IsURLEmpty(conf.OAuth2.Endpoints.Discovery) {
-			logger.Info(fmt.Sprintf(
-				"discover oidc auto configuration with provider %s for issuer %s with custom discovery url %s",
-				provider.GetName(), conf.OAuth2.Issuer.String(), conf.OAuth2.Endpoints.Discovery.String(),
+		if !config.IsURLEmpty(p.conf.OAuth2.Endpoints.Discovery) {
+			p.logger.Info(fmt.Sprintf(
+				"discover oidc auto configuration with p %s for issuer %s with custom discovery url %s",
+				p.OIDC.GetName(), p.conf.OAuth2.Issuer.String(), p.conf.OAuth2.Endpoints.Discovery.String(),
 			))
 
-			options = append(options, rp.WithCustomDiscoveryUrl(conf.OAuth2.Endpoints.Discovery.String()))
+			options = append(options, rp.WithCustomDiscoveryUrl(p.conf.OAuth2.Endpoints.Discovery.String()))
 		} else {
-			logger.Info(fmt.Sprintf(
-				"discover oidc auto configuration with provider %s for issuer %s",
-				provider.GetName(), conf.OAuth2.Issuer.String(),
+			p.logger.Info(fmt.Sprintf(
+				"discover oidc auto configuration with p %s for issuer %s",
+				p.OIDC.GetName(), p.conf.OAuth2.Issuer.String(),
 			))
 		}
 
-		relyingParty, err = rp.NewRelyingPartyOIDC(
+		p.RelyingParty, err = rp.NewRelyingPartyOIDC(
 			logging.ToContext(context.Background(), providerLogger),
-			conf.OAuth2.Issuer.String(),
-			conf.OAuth2.Client.ID,
-			conf.OAuth2.Client.Secret.String(),
+			p.conf.OAuth2.Issuer.String(),
+			p.conf.OAuth2.Client.ID,
+			p.conf.OAuth2.Client.Secret.String(),
 			redirectURI,
 			scopes,
 			options...,
 		)
 	} else {
-		logger.Info(utils.StringConcat(
-			"manually configure oauth2 provider with provider ",
-			provider.GetName(), " and endpoints ", endpoints.AuthURL, " and ", endpoints.TokenURL,
+		p.logger.Info(fmt.Sprintf(
+			"manually configure oauth2 p with p %s and endpoints %s and %s",
+			p.OIDC.GetName(), endpoints.AuthURL, endpoints.TokenURL,
 		))
 
 		rpConfig := &oauth2.Config{
-			ClientID:     conf.OAuth2.Client.ID,
-			ClientSecret: conf.OAuth2.Client.Secret.String(),
+			ClientID:     p.conf.OAuth2.Client.ID,
+			ClientSecret: p.conf.OAuth2.Client.Secret.String(),
 			RedirectURL:  redirectURI,
 			Scopes:       scopes,
 			Endpoint:     endpoints,
 		}
 
-		relyingParty, err = rp.NewRelyingPartyOAuth(rpConfig, options...)
+		p.RelyingParty, err = rp.NewRelyingPartyOAuth(rpConfig, options...)
 	}
 
 	if err != nil {
-		return Provider{}, fmt.Errorf("error oauth2 provider: %w", err)
+		return fmt.Errorf("error oauth2 provider: %w", err)
 	}
 
-	return Provider{
-		RelyingParty:    relyingParty,
-		OIDC:            provider,
-		openvpn:         openvpnClient,
-		storage:         storageClient,
-		conf:            conf,
-		logger:          logger,
-		authorizeParams: authorizeParams,
-	}, nil
+	return nil
 }
 
 func GetAuthorizeParams(authorizeParams string) ([]rp.URLParamOpt, error) {
diff --git a/internal/oauth2/providers/generic/check.go b/internal/oauth2/providers/generic/check.go
index 71967a0..435c16c 100644
--- a/internal/oauth2/providers/generic/check.go
+++ b/internal/oauth2/providers/generic/check.go
@@ -7,8 +7,8 @@ import (
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/idtoken"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/types"
 	"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/v3/pkg/oidc"
 )
diff --git a/internal/oauth2/providers/generic/main.go b/internal/oauth2/providers/generic/main.go
index 5cb7d87..0b730b8 100644
--- a/internal/oauth2/providers/generic/main.go
+++ b/internal/oauth2/providers/generic/main.go
@@ -21,5 +21,5 @@ func (p *Provider) GetName() string {
 }
 
 func (p *Provider) GetDefaultScopes() []string {
-	return []string{"openid", "profile"}
+	return []string{"openid", "profile", "offline_access"}
 }
diff --git a/internal/oauth2/providers/generic/user.go b/internal/oauth2/providers/generic/user.go
index a7aa5e9..5777513 100644
--- a/internal/oauth2/providers/generic/user.go
+++ b/internal/oauth2/providers/generic/user.go
@@ -4,7 +4,7 @@ import (
 	"context"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/idtoken"
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/types"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/types"
 	"github.com/zitadel/oidc/v3/pkg/oidc"
 )
 
diff --git a/internal/oauth2/providers/github/check.go b/internal/oauth2/providers/github/check.go
index 8c6b040..6d71886 100644
--- a/internal/oauth2/providers/github/check.go
+++ b/internal/oauth2/providers/github/check.go
@@ -5,8 +5,8 @@ import (
 	"fmt"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/idtoken"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/types"
 	"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/v3/pkg/oidc"
 )
diff --git a/internal/oauth2/providers/github/user.go b/internal/oauth2/providers/github/user.go
index f066558..b2ea1f7 100644
--- a/internal/oauth2/providers/github/user.go
+++ b/internal/oauth2/providers/github/user.go
@@ -5,7 +5,7 @@ import (
 	"strconv"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/idtoken"
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/types"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/types"
 	"github.com/zitadel/oidc/v3/pkg/oidc"
 )
 
diff --git a/internal/oauth2/refresh.go b/internal/oauth2/refresh.go
new file mode 100644
index 0000000..1a6da7d
--- /dev/null
+++ b/internal/oauth2/refresh.go
@@ -0,0 +1,47 @@
+package oauth2
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log/slog"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/log"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/storage"
+	"github.com/zitadel/logging"
+	"github.com/zitadel/oidc/v3/pkg/client/rp"
+	"github.com/zitadel/oidc/v3/pkg/oidc"
+)
+
+// RefreshClientAuth initiate a non-interactive authentication against the sso provider.
+func (p *Provider) RefreshClientAuth(clientID uint64, logger *slog.Logger) (bool, error) {
+	refreshToken, err := p.storage.Get(clientID)
+	if err != nil {
+		if errors.Is(err, storage.ErrNotExists) {
+			return false, nil
+		}
+
+		return false, fmt.Errorf("error from token store: %w", err)
+	}
+
+	logger.Info("initiate non-interactive authentication via refresh token")
+	ctx := logging.ToContext(context.Background(), log.NewZitadelLogger(logger))
+
+	newTokens, err := rp.RefreshTokens[*oidc.IDTokenClaims](ctx, p.RelyingParty, refreshToken, "", "")
+	if err != nil {
+		return false, fmt.Errorf("failed authentication via refresh token %w", err)
+	}
+
+	logger.Info("successful authenticate via refresh token")
+
+	if err = p.storage.Set(clientID, newTokens.RefreshToken); err != nil {
+		return true, fmt.Errorf("error from token store: %w", err)
+	}
+
+	return true, nil
+}
+
+// ClientDisconnect purges the refresh token from store.
+func (p *Provider) ClientDisconnect(clientID uint64) {
+	p.storage.Delete(clientID)
+}
diff --git a/internal/oauth2/types.go b/internal/oauth2/types.go
index bd23866..9f00683 100644
--- a/internal/oauth2/types.go
+++ b/internal/oauth2/types.go
@@ -6,9 +6,9 @@ import (
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/idtoken"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/types"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/storage"
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/types"
 	"github.com/zitadel/oidc/v3/pkg/client/rp"
 	"github.com/zitadel/oidc/v3/pkg/oidc"
 	"golang.org/x/oauth2"
@@ -20,6 +20,7 @@ type Provider struct {
 	storage *storage.Storage
 
 	rp.RelyingParty
+
 	OIDC            oidcProvider
 	openvpn         OpenVPN
 	authorizeParams []rp.URLParamOpt
diff --git a/internal/openvpn/callbacks.go b/internal/openvpn/callbacks.go
index c4d1299..fc5af41 100644
--- a/internal/openvpn/callbacks.go
+++ b/internal/openvpn/callbacks.go
@@ -1,25 +1,25 @@
 package openvpn
 
 import (
+	"encoding/base64"
 	"fmt"
 	"log/slog"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
 )
 
-func (c *Client) AcceptClient(logger *slog.Logger, client state.ClientIdentifier) {
+func (c *Client) AcceptClient(logger *slog.Logger, client state.ClientIdentifier, username string) {
 	logger.Info(fmt.Sprintf("accept OpenVPN client cid %d, kid %d", client.Cid, client.Kid))
 
-	_, err := c.SendCommandf(`client-auth-nt %d %d`, client.Cid, client.Kid)
-	if err != nil {
-		logger.Warn(err.Error())
-	}
-}
+	var err error
 
-func (c *Client) AcceptClientWithToken(logger *slog.Logger, client state.ClientIdentifier, username string) {
-	logger.Info(fmt.Sprintf("accept OpenVPN client cid %d, kid %d", client.Cid, client.Kid))
+	if c.conf.OpenVpn.AuthTokenUser {
+		tokenUsername := base64.StdEncoding.EncodeToString([]byte(username))
+		_, err = c.SendCommandf("client-auth %d %d\npush \"auth-token-user %s\"\nEND", client.Cid, client.Kid, tokenUsername)
+	} else {
+		_, err = c.SendCommandf(`client-auth-nt %d %d`, client.Cid, client.Kid)
+	}
 
-	_, err := c.SendCommandf("client-auth %d %d\npush \"auth-token-user %s\"\nEND", client.Cid, client.Kid, username)
 	if err != nil {
 		logger.Warn(err.Error())
 	}
diff --git a/internal/openvpn/client.go b/internal/openvpn/client.go
index 3e9d385..fe9166e 100644
--- a/internal/openvpn/client.go
+++ b/internal/openvpn/client.go
@@ -1,12 +1,10 @@
 package openvpn
 
 import (
-	"encoding/base64"
 	"fmt"
 	"log/slog"
 	"net/url"
 	"slices"
-	"strings"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn/connection"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
@@ -47,6 +45,10 @@ func (c *Client) clientConnect(client connection.Client) error {
 		return nil
 	}
 
+	if client.Reason == "REAUTH" && c.checkReAuth(logger, client) {
+		return nil
+	}
+
 	ClientIdentifier := state.ClientIdentifier{
 		Cid: client.Cid,
 		Kid: client.Kid,
@@ -59,21 +61,21 @@ func (c *Client) clientConnect(client connection.Client) error {
 		return fmt.Errorf("error encoding state: %w", err)
 	}
 
-	startURL := utils.StringConcat(
-		strings.TrimSuffix(c.conf.HTTP.BaseURL.String(), "/"),
-		"/oauth2/start?state=", url.QueryEscape(session.Encoded()),
-	)
+	startURL := c.conf.HTTP.BaseURL.JoinPath("/oauth2/start")
+	startURL.RawQuery = fmt.Sprintf("state=%s", url.QueryEscape(session.Encoded()))
+
+	startURLString := startURL.String()
 
-	if len(startURL) >= 245 {
+	if len(startURLString) >= 245 {
 		c.DenyClient(logger, ClientIdentifier, "internal error")
 
 		return fmt.Errorf("url %s (%d chars) too long! OpenVPN support up to 245 chars. Try --openvpn.common-name.mode to avoid this error",
-			startURL, len(startURL))
+			startURL, len(startURLString))
 	}
 
 	logger.Info("start pending auth")
 
-	_, err = c.SendCommandf(`client-pending-auth %d %d "WEB_AUTH::%s" %.0f`, client.Cid, client.Kid, startURL, c.conf.OpenVpn.AuthPendingTimeout.Seconds())
+	_, err = c.SendCommandf(`client-pending-auth %d %d "WEB_AUTH::%s" %.0f`, client.Cid, client.Kid, startURLString, c.conf.OpenVpn.AuthPendingTimeout.Seconds())
 	if err != nil {
 		logger.Warn(err.Error())
 	}
@@ -87,15 +89,26 @@ func (c *Client) checkAuthBypass(logger *slog.Logger, client connection.Client)
 	}
 
 	logger.Info("client bypass authentication")
+	c.AcceptClient(logger, state.ClientIdentifier{Cid: client.Cid, Kid: client.Kid}, client.CommonName)
 
-	if c.conf.OpenVpn.AuthTokenUser {
-		tokenUsername := base64.StdEncoding.EncodeToString([]byte(client.CommonName))
-		c.AcceptClientWithToken(logger, state.ClientIdentifier{Cid: client.Cid, Kid: client.Kid}, tokenUsername)
-	} else {
-		c.AcceptClient(logger, state.ClientIdentifier{Cid: client.Cid, Kid: client.Kid})
+	return true
+}
+
+func (c *Client) checkReAuth(logger *slog.Logger, client connection.Client) bool {
+	if !c.conf.OAuth2.Refresh.Enabled {
+		return false
 	}
 
-	return true
+	ok, err := c.oauth2.RefreshClientAuth(client.Cid, logger)
+	if err != nil {
+		logger.Warn(err.Error())
+	}
+
+	if ok {
+		c.AcceptClient(logger, state.ClientIdentifier{Cid: client.Cid, Kid: client.Kid}, client.CommonName)
+	}
+
+	return ok
 }
 
 func (c *Client) clientDisconnect(client connection.Client) {
@@ -105,6 +118,8 @@ func (c *Client) clientDisconnect(client connection.Client) {
 		slog.String("common_name", client.CommonName),
 		slog.String("username", client.Username),
 	)
+
+	c.oauth2.ClientDisconnect(client.Cid)
 }
 
 func (c *Client) clientEstablished(client connection.Client) {
diff --git a/internal/openvpn/main.go b/internal/openvpn/main.go
index 4b6dc72..b7ff567 100644
--- a/internal/openvpn/main.go
+++ b/internal/openvpn/main.go
@@ -14,17 +14,17 @@ import (
 	"time"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn/connection"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/storage"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
 )
 
-func NewClient(logger *slog.Logger, conf config.Config, stor *storage.Storage) *Client {
+func NewClient(logger *slog.Logger, conf config.Config, oauth2Client *oauth2.Provider) *Client {
 	return &Client{
-		conf:    conf,
-		storage: stor,
-		logger:  logger,
+		conf:   conf,
+		logger: logger,
+		oauth2: oauth2Client,
 
 		closed: false,
 		mu:     sync.Mutex{},
diff --git a/internal/openvpn/types.go b/internal/openvpn/types.go
index 681f27c..5063071 100644
--- a/internal/openvpn/types.go
+++ b/internal/openvpn/types.go
@@ -7,8 +7,8 @@ import (
 	"sync"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn/connection"
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/storage"
 )
 
 type Client struct {
@@ -16,7 +16,7 @@ type Client struct {
 	conn    net.Conn
 	scanner *bufio.Scanner
 	logger  *slog.Logger
-	storage *storage.Storage
+	oauth2  *oauth2.Provider
 
 	mu     sync.Mutex
 	closed bool
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
index bf39956..af01802 100644
--- a/internal/storage/storage.go
+++ b/internal/storage/storage.go
@@ -16,7 +16,7 @@ type Storage struct {
 }
 
 type item struct {
-	token   string
+	token   []byte
 	expires time.Time
 }
 
@@ -31,50 +31,50 @@ func New(encryptionKey string, expires time.Duration) *Storage {
 	return storage
 }
 
-func (storage *Storage) collect() {
+func (s *Storage) collect() {
 	for {
-		storage.data.Range(func(client, data any) bool {
+		time.Sleep(time.Minute * 5)
+
+		s.data.Range(func(client, data any) bool {
 			entry, ok := data.(item)
 			if !ok {
 				panic(data)
 			}
 
 			if entry.expires.Compare(time.Now()) == -1 {
-				storage.data.Delete(client)
+				s.data.Delete(client)
 			}
 
 			return true
 		})
-
-		time.Sleep(time.Minute * 5)
 	}
 }
 
-func (storage *Storage) Set(client uint64, token string) error {
-	encryptedToken, err := crypto.EncryptAES(token, storage.encryptionKey)
+func (s *Storage) Set(client uint64, token string) error {
+	encryptedBytes, err := crypto.EncryptBytesAES([]byte(token), s.encryptionKey)
 	if err != nil {
-		return fmt.Errorf("encrypt error: %w", err)
+		return fmt.Errorf("decrypt error: %w", err)
 	}
 
-	storage.data.Store(client, item{encryptedToken, time.Now().Add(storage.expires)})
+	s.data.Store(client, item{encryptedBytes, time.Now().Add(s.expires)})
 
 	return nil
 }
 
-func (storage *Storage) Get(client uint64) (string, error) {
-	data, ok := storage.data.Load(client)
+func (s *Storage) Get(client uint64) (string, error) {
+	data, ok := s.data.Load(client)
 	if !ok {
 		return "", ErrNotExists
 	}
 
-	token, err := crypto.DecryptAES(data.(item).token, storage.encryptionKey)
+	token, err := crypto.DecryptBytesAES(data.(item).token, s.encryptionKey)
 	if err != nil {
 		return "", fmt.Errorf("decrypt error: %w", err)
 	}
 
-	return token, nil
+	return string(token), nil
 }
 
-func (storage *Storage) Delete(client uint64) {
-	storage.data.Delete(client)
+func (s *Storage) Delete(client uint64) {
+	s.data.Delete(client)
 }
diff --git a/pkg/plugin/callbacks.go b/pkg/plugin/callbacks.go
index 29f2b1e..2f7bcac 100644
--- a/pkg/plugin/callbacks.go
+++ b/pkg/plugin/callbacks.go
@@ -12,11 +12,7 @@ import (
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
 )
 
-func (p *PluginHandle) AcceptClient(logger *slog.Logger, client state.ClientIdentifier) {
-	p.writeToAuthFile(logger, client, "1")
-}
-
-func (p *PluginHandle) AcceptClientWithToken(logger *slog.Logger, client state.ClientIdentifier, _ string) {
+func (p *PluginHandle) AcceptClient(logger *slog.Logger, client state.ClientIdentifier, _ string) {
 	p.writeToAuthFile(logger, client, "1")
 }
 
diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go
index 8e02c79..a4b9950 100644
--- a/pkg/plugin/plugin.go
+++ b/pkg/plugin/plugin.go
@@ -75,9 +75,10 @@ func openvpn_plugin_open_v3_go(v3structver C.int, args *C.struct_openvpn_plugin_
 		conf:   conf,
 	}
 
-	storageClient := storage.New(conf.OAuth2.TokenStore.Key.String(), conf.OAuth2.TokenStore.Expires)
-	provider, err := oauth2.NewProvider(logger, conf, storageClient, handle)
-	if err != nil {
+	storageClient := storage.New(conf.OAuth2.Refresh.Secret.String(), conf.OAuth2.Refresh.Expires)
+	provider := oauth2.New(logger, conf, storageClient)
+
+	if err = provider.Discover(handle); err != nil {
 		logger.Error(err.Error())
 
 		return C.OPENVPN_PLUGIN_FUNC_ERROR


Test patch:
diff --git a/cmd/root_test.go b/cmd/root_test.go
index 45a5b45..509a4d5 100644
--- a/cmd/root_test.go
+++ b/cmd/root_test.go
@@ -4,7 +4,6 @@ import (
 	"bufio"
 	"bytes"
 	"io"
-	"net"
 	"os"
 	"syscall"
 	"testing"
@@ -92,21 +91,17 @@ func TestExecuteConfigInvalid(t *testing.T) {
 }
 
 func TestExecuteConfigFileFound(t *testing.T) { //nolint: paralleltest
-	clientListener, err := net.Listen("tcp", "127.0.0.1:0")
-	require.NoError(t, err)
-
+	clientListener := testutils.TCPTestListener(t)
 	defer clientListener.Close()
 
-	svr, client, err := testutils.SetupResourceServer(clientListener)
-	require.NoError(t, err)
-
-	l, err := net.Listen("tcp", "127.0.0.1:0")
+	resourceServer, _, clientCredentials, err := testutils.SetupResourceServer(clientListener)
 	require.NoError(t, err)
 
-	defer l.Close()
+	managementInterface := testutils.TCPTestListener(t)
+	defer managementInterface.Close()
 
 	go func() {
-		conn, err := l.Accept()
+		conn, err := managementInterface.Accept()
 		require.NoError(t, err)
 
 		defer conn.Close()
@@ -129,7 +124,7 @@ func TestExecuteConfigFileFound(t *testing.T) { //nolint: paralleltest
 		_ = p.Signal(syscall.SIGINT)
 	}()
 
-	t.Setenv("CONFIG_OPENVPN_ADDR", utils.StringConcat(l.Addr().Network(), "://", l.Addr().String()))
+	t.Setenv("CONFIG_OPENVPN_ADDR", utils.StringConcat(managementInterface.Addr().Network(), "://", managementInterface.Addr().String()))
 	t.Setenv("CONFIG_LOG_FORMAT", "console")
 	t.Setenv("CONFIG_LOG_LEVEL", "warn")
 
@@ -138,8 +133,8 @@ func TestExecuteConfigFileFound(t *testing.T) { //nolint: paralleltest
 		"--config=../config.example.yaml",
 		"--http.secret=0123456789101112",
 		"--http.listen=127.0.0.1:0",
-		"--oauth2.issuer", svr.URL,
-		"--oauth2.client.id", client.ID,
+		"--oauth2.issuer", resourceServer.URL,
+		"--oauth2.client.id", clientCredentials.ID,
 	}
 
 	var buf bytes.Buffer
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 6728b30..33f5ed9 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -101,11 +101,11 @@ func TestValidate(t *testing.T) {
 		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "invalid"}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "-"},
 		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "invalid", Check: config.HTTPCheck{}}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{ID: "client"}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "http.secret requires a length of 16, 24 or 32"},
 		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "invalid", Check: config.HTTPCheck{}}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{ID: "client"}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "http.secret requires a length of 16, 24 or 32"},
-		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}, Secret: testutils.HTTPSecret, Check: config.HTTPCheck{}}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{ID: "client"}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Addr: &url.URL{}, Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "openvpn.addr is required"},
-		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}, Secret: testutils.HTTPSecret, Check: config.HTTPCheck{}}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{ID: "client"}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Addr: &url.URL{Scheme: "tcps", Host: "127.0.0.1:9000"}, Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "openvpn.addr: invalid URL. only tcp://addr or unix://addr scheme supported"},
-		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "httpss", Host: "invalid"}, Secret: testutils.HTTPSecret, Check: config.HTTPCheck{}}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{ID: "client"}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Addr: &url.URL{Scheme: "tcp", Host: "127.0.0.1:9000"}, Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "-"},
-		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}, Secret: testutils.HTTPSecret, Check: config.HTTPCheck{}}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{ID: "client"}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Addr: &url.URL{Scheme: "tcp", Host: "127.0.0.1:9000"}, Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, ""},
-		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}, Secret: testutils.HTTPSecret, Check: config.HTTPCheck{}}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{ID: "client"}, Endpoints: config.OAuth2Endpoints{Token: &url.URL{Scheme: "http", Host: "invalid"}, Auth: &url.URL{Scheme: "http", Host: "invalid"}}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Addr: &url.URL{Scheme: "tcp", Host: "127.0.0.1:9000"}, Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, ""},
+		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}, Secret: testutils.Secret, Check: config.HTTPCheck{}}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{ID: "client"}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Addr: &url.URL{}, Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "openvpn.addr is required"},
+		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}, Secret: testutils.Secret, Check: config.HTTPCheck{}}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{ID: "client"}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Addr: &url.URL{Scheme: "tcps", Host: "127.0.0.1:9000"}, Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "openvpn.addr: invalid URL. only tcp://addr or unix://addr scheme supported"},
+		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "httpss", Host: "invalid"}, Secret: testutils.Secret, Check: config.HTTPCheck{}}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{ID: "client"}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Addr: &url.URL{Scheme: "tcp", Host: "127.0.0.1:9000"}, Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "-"},
+		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}, Secret: testutils.Secret, Check: config.HTTPCheck{}}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{ID: "client"}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Addr: &url.URL{Scheme: "tcp", Host: "127.0.0.1:9000"}, Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, ""},
+		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}, Secret: testutils.Secret, Check: config.HTTPCheck{}}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{ID: "client"}, Endpoints: config.OAuth2Endpoints{Token: &url.URL{Scheme: "http", Host: "invalid"}, Auth: &url.URL{Scheme: "http", Host: "invalid"}}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Addr: &url.URL{Scheme: "tcp", Host: "127.0.0.1:9000"}, Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, ""},
 	} {
 		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
diff --git a/internal/internal_test.go b/internal/internal_test.go
new file mode 100644
index 0000000..235bfe7
--- /dev/null
+++ b/internal/internal_test.go
@@ -0,0 +1,90 @@
+package internal_test
+
+import (
+	"bufio"
+	"context"
+	"io"
+	"net/http"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/pkg/testutils"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func BenchmarkFull(b *testing.B) {
+	b.StopTimer()
+
+	_, client, managementInterface, _, _, httpClient, shutdownFn := testutils.SetupMockEnvironment(b, config.Config{})
+	defer shutdownFn()
+
+	wg := sync.WaitGroup{}
+	wg.Add(1)
+
+	go func() {
+		defer wg.Done()
+
+		err := client.Connect()
+		if err != nil && !strings.HasSuffix(err.Error(), "EOF") {
+			require.NoError(b, err)
+		}
+	}()
+
+	managementInterfaceConn, err := managementInterface.Accept()
+	require.NoError(b, err)
+
+	defer managementInterfaceConn.Close()
+	defer client.Shutdown()
+
+	reader := bufio.NewReader(managementInterfaceConn)
+
+	testutils.SendLine(b, managementInterfaceConn, ">INFO:OpenVPN Management Interface Version 5 -- type 'help' for more info\r\n")
+	assert.Equal(b, "hold release", testutils.ReadLine(b, reader))
+	testutils.SendLine(b, managementInterfaceConn, "SUCCESS: hold release succeeded\r\n")
+	assert.Equal(b, "version", testutils.ReadLine(b, reader))
+	testutils.SendLine(b, managementInterfaceConn, "OpenVPN Version: OpenVPN Mock\r\nManagement Interface Version: 5\r\nEND\r\n")
+
+	time.Sleep(time.Millisecond * 100)
+
+	var (
+		request *http.Request
+		resp    *http.Response
+		authURL string
+	)
+
+	b.ResetTimer()
+	b.StartTimer()
+
+	for i := 0; i < b.N; i++ {
+		testutils.SendLine(b, managementInterfaceConn, ">CLIENT:CONNECT,0,1\n>CLIENT:ENV,n_clients=0\n>CLIENT:ENV,password=\n>CLIENT:ENV,untrusted_port=17016\n>CLIENT:ENV,untrusted_ip=192.168.65.1\n>CLIENT:ENV,common_name=user@example.com\n>CLIENT:ENV,username=\n>CLIENT:ENV,IV_BS64DL=1\n>CLIENT:ENV,IV_SSO=webauth,openurl,crtext\n>CLIENT:ENV,IV_GUI_VER=OCmacOS_3.4.4-4629\n>CLIENT:ENV,IV_AUTO_SESS=1\n>CLIENT:ENV,IV_CIPHERS=AES-128-GCM:AES-192-GCM:AES-256-GCM:CHACHA20-POLY1305\n>CLIENT:ENV,IV_MTU=1600\n>CLIENT:ENV,IV_PROTO=990\n>CLIENT:ENV,IV_TCPNL=1\n>CLIENT:ENV,IV_NCP=2\n>CLIENT:ENV,IV_PLAT=mac\n>CLIENT:ENV,IV_VER=3.8.1\n>CLIENT:ENV,tls_serial_hex_0=51:b3:55:90:65:af:71:5c:d5:52:2b:0b:00:14:8d:ee\n>CLIENT:ENV,tls_serial_0=108598624241397715647038806614705737198\n>CLIENT:ENV,tls_digest_sha256_0=d3:6d:1d:96:f8:bd:7e:e8:db:c4:0f:53:a1:76:f0:ca:9e:78:63:bf:c6:4a:ac:b9:e6:ed:84:62:f5:ac:5d:b8\n>CLIENT:ENV,tls_digest_0=b7:73:bd:6c:31:31:49:63:0d:0c:11:6d:0c:13:d0:b4:8f:97:33:7d\n>CLIENT:ENV,tls_id_0=CN=user@example.com\n>CLIENT:ENV,X509_0_CN=user@example.com\n>CLIENT:ENV,tls_serial_hex_1=01:b3:95:f8:1a:9f:9f:fe:7c:27:ad:29:c1:93:23:ae:08:7f:ab:36\n>CLIENT:ENV,tls_serial_1=9713888317380397892476539918183380788698917686\n>CLIENT:ENV,tls_digest_sha256_1=75:1a:a1:63:bb:e9:c7:f3:e3:bf:e1:08:f1:36:b7:36:90:04:da:dd:b8:78:b1:cf:d5:ac:09:b6:36:31:a7:db\n>CLIENT:ENV,tls_digest_1=d4:bc:00:89:e5:01:0c:27:3d:ea:4a:b5:42:8b:f7:3d:19:7a:a2:25\n>CLIENT:ENV,tls_id_1=CN=Easy-RSA CA\n>CLIENT:ENV,X509_1_CN=Easy-RSA CA\n>CLIENT:ENV,remote_port_1=1194\n>CLIENT:ENV,local_port_1=1194\n>CLIENT:ENV,proto_1=udp\n>CLIENT:ENV,daemon_pid=7\n>CLIENT:ENV,daemon_start_time=1703401559\n>CLIENT:ENV,daemon_log_redirect=0\n>CLIENT:ENV,daemon=0\n>CLIENT:ENV,verb=3\n>CLIENT:ENV,config=/etc/openvpn/openvpn.conf\n>CLIENT:ENV,ifconfig_local=100.64.0.1\n>CLIENT:ENV,ifconfig_netmask=255.255.255.0\n>CLIENT:ENV,script_context=init\n>CLIENT:ENV,tun_mtu=1500\n>CLIENT:ENV,dev=tun0\n>CLIENT:ENV,dev_type=tun\n>CLIENT:ENV,redirect_gateway=0\n>CLIENT:ENV,END\n")
+
+		authURL = testutils.ReadLine(b, reader)
+		testutils.SendLine(b, managementInterfaceConn, "SUCCESS: client-pending-auth command succeeded\r\n")
+
+		_, authURL, _ = strings.Cut(authURL, `"`)
+		authURL, _, _ = strings.Cut(authURL, `"`)
+		authURL = strings.TrimPrefix(authURL, "WEB_AUTH::")
+
+		request, _ = http.NewRequestWithContext(context.Background(), http.MethodGet, authURL, nil)
+
+		go func() {
+			testutils.ReadLine(b, reader)
+			testutils.SendLine(b, managementInterfaceConn, "SUCCESS: client-auth command succeeded\r\n")
+		}()
+
+		resp, _ = httpClient.Do(request)
+		_, _ = io.Copy(io.Discard, resp.Body)
+		_ = resp.Body.Close()
+	}
+
+	b.StopTimer()
+
+	client.Shutdown()
+	wg.Wait()
+
+	b.ReportAllocs()
+}
diff --git a/internal/oauth2/handler_test.go b/internal/oauth2/handler_test.go
index 325cde3..5bc9b64 100644
--- a/internal/oauth2/handler_test.go
+++ b/internal/oauth2/handler_test.go
@@ -5,11 +5,7 @@ import (
 	"context"
 	"fmt"
 	"io"
-	"net"
 	"net/http"
-	"net/http/cookiejar"
-	"net/http/httptest"
-	"net/url"
 	"strings"
 	"sync"
 	"testing"
@@ -17,10 +13,7 @@ import (
 	"time"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2"
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/storage"
 	"github.com/jkroepke/openvpn-auth-oauth2/pkg/testutils"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -29,8 +22,6 @@ import (
 func TestHandler(t *testing.T) {
 	t.Parallel()
 
-	logger := testutils.NewTestLogger()
-
 	tests := []struct {
 		name          string
 		conf          config.Config
@@ -43,7 +34,7 @@ func TestHandler(t *testing.T) {
 			"default",
 			config.Config{
 				HTTP: config.HTTP{
-					Secret: testutils.HTTPSecret,
+					Secret: testutils.Secret,
 					Check: config.HTTPCheck{
 						IPAddr: false,
 					},
@@ -73,7 +64,7 @@ func TestHandler(t *testing.T) {
 			"with template",
 			config.Config{
 				HTTP: config.HTTP{
-					Secret: testutils.HTTPSecret,
+					Secret: testutils.Secret,
 					Check: config.HTTPCheck{
 						IPAddr: false,
 					},
@@ -104,7 +95,7 @@ func TestHandler(t *testing.T) {
 			"with ipaddr",
 			config.Config{
 				HTTP: config.HTTP{
-					Secret: testutils.HTTPSecret,
+					Secret: testutils.Secret,
 					Check: config.HTTPCheck{
 						IPAddr: true,
 					},
@@ -134,7 +125,7 @@ func TestHandler(t *testing.T) {
 			"with ipaddr + forwarded-for",
 			config.Config{
 				HTTP: config.HTTP{
-					Secret: testutils.HTTPSecret,
+					Secret: testutils.Secret,
 					Check: config.HTTPCheck{
 						IPAddr: true,
 					},
@@ -165,7 +156,7 @@ func TestHandler(t *testing.T) {
 			"with ipaddr + disabled forwarded-for",
 			config.Config{
 				HTTP: config.HTTP{
-					Secret: testutils.HTTPSecret,
+					Secret: testutils.Secret,
 					Check: config.HTTPCheck{
 						IPAddr: true,
 					},
@@ -196,7 +187,7 @@ func TestHandler(t *testing.T) {
 			"with ipaddr + multiple forwarded-for",
 			config.Config{
 				HTTP: config.HTTP{
-					Secret: testutils.HTTPSecret,
+					Secret: testutils.Secret,
 					Check: config.HTTPCheck{
 						IPAddr: true,
 					},
@@ -227,7 +218,7 @@ func TestHandler(t *testing.T) {
 			"with empty state",
 			config.Config{
 				HTTP: config.HTTP{
-					Secret: testutils.HTTPSecret,
+					Secret: testutils.Secret,
 					Check: config.HTTPCheck{
 						IPAddr: true,
 					},
@@ -258,7 +249,7 @@ func TestHandler(t *testing.T) {
 			"with invalid state",
 			config.Config{
 				HTTP: config.HTTP{
-					Secret: testutils.HTTPSecret,
+					Secret: testutils.Secret,
 					Check: config.HTTPCheck{
 						IPAddr: true,
 					},
@@ -293,44 +284,8 @@ func TestHandler(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			t.Parallel()
 
-			managementInterface, err := net.Listen("tcp", "127.0.0.1:0")
-			require.NoError(t, err)
-			defer managementInterface.Close()
-
-			clientListener, err := net.Listen("tcp", "127.0.0.1:0")
-			require.NoError(t, err)
-			defer clientListener.Close()
-
-			resourceServer, clientCredentials, err := testutils.SetupResourceServer(clientListener)
-			require.NoError(t, err)
-			defer resourceServer.Close()
-
-			resourceServerURL, err := url.Parse(resourceServer.URL)
-			require.NoError(t, err)
-
-			tt.conf.OAuth2.Client = clientCredentials
-			tt.conf.OAuth2.Issuer = resourceServerURL
-			tt.conf.HTTP.BaseURL = &url.URL{Scheme: "http", Host: clientListener.Addr().String()}
-			tt.conf.OpenVpn.Addr = &url.URL{Scheme: managementInterface.Addr().Network(), Host: managementInterface.Addr().String()}
-			if tt.conf.HTTP.CallbackTemplate == nil {
-				tt.conf.HTTP.CallbackTemplate = config.Defaults.HTTP.CallbackTemplate
-			}
-
-			storageClient := storage.New("0123456789101112", time.Hour)
-
-			client := openvpn.NewClient(logger, tt.conf, storageClient)
-			defer client.Shutdown()
-
-			provider, err := oauth2.NewProvider(logger, tt.conf, storageClient, client)
-			require.NoError(t, err)
-
-			httpClientListener := httptest.NewUnstartedServer(provider.Handler())
-			httpClientListener.Listener.Close()
-			httpClientListener.Listener = clientListener
-			httpClientListener.Start()
-			defer httpClientListener.Close()
-
-			httpClient := httpClientListener.Client()
+			conf, client, managementInterface, _, httpClientListener, httpClient, shutdownFn := testutils.SetupMockEnvironment(t, tt.conf)
+			defer shutdownFn()
 
 			wg := sync.WaitGroup{}
 			wg.Add(2)
@@ -373,18 +328,12 @@ func TestHandler(t *testing.T) {
 				}
 			}()
 
-			jar, err := cookiejar.New(nil)
-			require.NoError(t, err)
-
-			httpClient.Jar = jar
-
 			time.Sleep(time.Millisecond * 100)
 
 			session := tt.state
 			if tt.state == "-" {
 				sessionState := state.New(state.ClientIdentifier{Cid: 0, Kid: 1}, tt.ipaddr, "name")
-				err = sessionState.Encode(tt.conf.HTTP.Secret.String())
-				require.NoError(t, err)
+				require.NoError(t, sessionState.Encode(tt.conf.HTTP.Secret.String()))
 
 				session = sessionState.Encoded()
 			}
@@ -422,7 +371,7 @@ func TestHandler(t *testing.T) {
 				return
 			}
 
-			if tt.conf.HTTP.CallbackTemplate != config.Defaults.HTTP.CallbackTemplate {
+			if conf.HTTP.CallbackTemplate != config.Defaults.HTTP.CallbackTemplate {
 				if !assert.Contains(t, string(body), "openvpn-auth-oauth2 is a management client for OpenVPN that handles the single sign-on") {
 					return
 				}
diff --git a/internal/oauth2/provider_test.go b/internal/oauth2/provider_test.go
index 4ba1c3d..a78888f 100644
--- a/internal/oauth2/provider_test.go
+++ b/internal/oauth2/provider_test.go
@@ -1,9 +1,7 @@
 package oauth2_test
 
 import (
-	"crypto/sha256"
 	"net"
-	"net/http/httptest"
 	"net/url"
 	"strings"
 	"testing"
@@ -17,35 +15,17 @@ import (
 	"github.com/jkroepke/openvpn-auth-oauth2/pkg/testutils"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
-	serverStorage "github.com/zitadel/oidc/v3/example/server/storage"
-	"github.com/zitadel/oidc/v3/pkg/op"
-	"golang.org/x/text/language"
 )
 
 func TestNewProvider(t *testing.T) {
 	t.Parallel()
 
-	opStorage := serverStorage.NewStorageWithClients(serverStorage.NewUserStore("http://localhost"), map[string]*serverStorage.Client{})
-	opConfig := &op.Config{
-		CryptoKey:                sha256.Sum256([]byte("test")),
-		DefaultLogoutRedirectURI: "/",
-		CodeMethodS256:           true,
-		AuthMethodPost:           true,
-		AuthMethodPrivateKeyJWT:  true,
-		GrantTypeRefreshToken:    true,
-		RequestObjectSupported:   true,
-		SupportedUILocales:       []language.Tag{language.English},
-	}
-
-	handler, err := op.NewProvider(opConfig, opStorage, op.IssuerFromHost(""), op.WithAllowInsecure())
-
+	clientListener := testutils.TCPTestListener(t)
+	_, resourceServerURL, clientCredentials, err := testutils.SetupResourceServer(clientListener)
 	require.NoError(t, err)
 
-	svr := httptest.NewServer(handler)
 	logger := testutils.NewTestLogger()
 
-	svrURL, _ := url.Parse(svr.URL)
-
 	tests := []struct {
 		name string
 		conf config.Config
@@ -56,9 +36,9 @@ func TestNewProvider(t *testing.T) {
 			config.Config{
 				HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "localhost"}},
 				OAuth2: config.OAuth2{
-					Issuer:    svrURL,
+					Issuer:    resourceServerURL,
 					Provider:  generic.Name,
-					Client:    config.OAuth2Client{ID: "ID", Secret: "ID"},
+					Client:    clientCredentials,
 					Endpoints: config.OAuth2Endpoints{},
 				},
 			},
@@ -69,11 +49,11 @@ func TestNewProvider(t *testing.T) {
 			config.Config{
 				HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "localhost"}},
 				OAuth2: config.OAuth2{
-					Issuer:   svrURL,
+					Issuer:   resourceServerURL,
 					Provider: generic.Name,
-					Client:   config.OAuth2Client{ID: "ID", Secret: "ID"},
+					Client:   clientCredentials,
 					Endpoints: config.OAuth2Endpoints{
-						Discovery: &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/.well-known/openid-configuration"},
+						Discovery: &url.URL{Scheme: resourceServerURL.Scheme, Host: resourceServerURL.Host, Path: "/.well-known/openid-configuration"},
 					},
 				},
 			},
@@ -84,11 +64,11 @@ func TestNewProvider(t *testing.T) {
 			config.Config{
 				HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "localhost"}},
 				OAuth2: config.OAuth2{
-					Issuer:   svrURL,
+					Issuer:   resourceServerURL,
 					Provider: generic.Name,
-					Client:   config.OAuth2Client{ID: "ID", Secret: "ID"},
+					Client:   clientCredentials,
 					Endpoints: config.OAuth2Endpoints{
-						Discovery: &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/.well-known/openid-config"},
+						Discovery: &url.URL{Scheme: resourceServerURL.Scheme, Host: resourceServerURL.Host, Path: "/.well-known/openid-config"},
 					},
 				},
 			},
@@ -99,13 +79,13 @@ func TestNewProvider(t *testing.T) {
 			config.Config{
 				HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "localhost"}},
 				OAuth2: config.OAuth2{
-					Issuer:   svrURL,
+					Issuer:   resourceServerURL,
 					Provider: generic.Name,
-					Client:   config.OAuth2Client{ID: "ID", Secret: "ID"},
+					Client:   clientCredentials,
 					Endpoints: config.OAuth2Endpoints{
-						Discovery: &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/.well-known/openid-configuration"},
-						Auth:      &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/authorize"},
-						Token:     &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/token"},
+						Discovery: &url.URL{Scheme: resourceServerURL.Scheme, Host: resourceServerURL.Host, Path: "/.well-known/openid-configuration"},
+						Auth:      &url.URL{Scheme: resourceServerURL.Scheme, Host: resourceServerURL.Host, Path: "/authorize"},
+						Token:     &url.URL{Scheme: resourceServerURL.Scheme, Host: resourceServerURL.Host, Path: "/token"},
 					},
 				},
 			},
@@ -116,12 +96,12 @@ func TestNewProvider(t *testing.T) {
 			config.Config{
 				HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "localhost"}},
 				OAuth2: config.OAuth2{
-					Issuer:   svrURL,
+					Issuer:   resourceServerURL,
 					Provider: generic.Name,
-					Client:   config.OAuth2Client{ID: "ID", Secret: "ID"},
+					Client:   clientCredentials,
 					Endpoints: config.OAuth2Endpoints{
-						Discovery: &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/.well-known/openid-configuration"},
-						Auth:      &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/authorize"},
+						Discovery: &url.URL{Scheme: resourceServerURL.Scheme, Host: resourceServerURL.Host, Path: "/.well-known/openid-configuration"},
+						Auth:      &url.URL{Scheme: resourceServerURL.Scheme, Host: resourceServerURL.Host, Path: "/authorize"},
 					},
 				},
 			},
@@ -132,14 +112,14 @@ func TestNewProvider(t *testing.T) {
 			config.Config{
 				HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "localhost"}},
 				OAuth2: config.OAuth2{
-					Issuer:   svrURL,
+					Issuer:   resourceServerURL,
 					Provider: generic.Name,
 					Pkce:     true,
-					Client:   config.OAuth2Client{ID: "ID", Secret: "ID"},
+					Client:   clientCredentials,
 					Endpoints: config.OAuth2Endpoints{
-						Discovery: &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/.well-known/openid-configuration"},
-						Auth:      &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/authorize"},
-						Token:     &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/token"},
+						Discovery: &url.URL{Scheme: resourceServerURL.Scheme, Host: resourceServerURL.Host, Path: "/.well-known/openid-configuration"},
+						Auth:      &url.URL{Scheme: resourceServerURL.Scheme, Host: resourceServerURL.Host, Path: "/authorize"},
+						Token:     &url.URL{Scheme: resourceServerURL.Scheme, Host: resourceServerURL.Host, Path: "/token"},
 					},
 				},
 			},
@@ -157,15 +137,17 @@ func TestNewProvider(t *testing.T) {
 			defer managementInterface.Close()
 			tt.conf.OpenVpn.Addr = &url.URL{Scheme: managementInterface.Addr().Network(), Host: managementInterface.Addr().String()}
 
-			storageClient := storage.New("0123456789101112", time.Hour)
+			storageClient := storage.New(testutils.Secret, time.Hour)
+
+			provider := oauth2.New(logger, tt.conf, storageClient)
 
-			client := openvpn.NewClient(logger, tt.conf, storageClient)
+			client := openvpn.NewClient(logger, tt.conf, provider)
 			defer client.Shutdown()
 
-			provider, err := oauth2.NewProvider(logger, tt.conf, storageClient, client)
+			err = provider.Discover(client)
 			if tt.err != "" {
 				require.Error(t, err)
-				assert.Equal(t, strings.TrimSpace(err.Error()), tt.err)
+				assert.Equal(t, tt.err, strings.TrimSpace(err.Error()))
 
 				return
 			}
diff --git a/internal/oauth2/refresh_test.go b/internal/oauth2/refresh_test.go
new file mode 100644
index 0000000..a8329da
--- /dev/null
+++ b/internal/oauth2/refresh_test.go
@@ -0,0 +1,103 @@
+package oauth2_test
+
+import (
+	"bufio"
+	"context"
+	"io"
+	"net/http"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/pkg/testutils"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestRefreshReAuth(t *testing.T) {
+	t.Parallel()
+
+	_, client, managementInterface, _, _, httpClient, shutdownFn := testutils.SetupMockEnvironment(t, config.Config{
+		OAuth2: config.OAuth2{
+			Refresh: config.OAuth2Refresh{Enabled: true},
+		},
+	})
+	defer shutdownFn()
+
+	wg := sync.WaitGroup{}
+	wg.Add(1)
+
+	go func() {
+		defer wg.Done()
+
+		err := client.Connect()
+		if err != nil && !strings.HasSuffix(err.Error(), "EOF") {
+			require.NoError(t, err)
+		}
+	}()
+
+	managementInterfaceConn, err := managementInterface.Accept()
+	require.NoError(t, err)
+
+	defer managementInterfaceConn.Close()
+	defer client.Shutdown()
+
+	reader := bufio.NewReader(managementInterfaceConn)
+	testutils.SendLine(t, managementInterfaceConn, ">INFO:OpenVPN Management Interface Version 5 -- type 'help' for more info\r\n")
+	assert.Equal(t, "hold release", testutils.ReadLine(t, reader))
+	testutils.SendLine(t, managementInterfaceConn, "SUCCESS: hold release succeeded\r\n")
+	assert.Equal(t, "version", testutils.ReadLine(t, reader))
+
+	testutils.SendLine(t, managementInterfaceConn, "OpenVPN Version: OpenVPN Mock\r\nManagement Interface Version: 5\r\nEND\r\n")
+
+	time.Sleep(time.Millisecond * 100)
+
+	testutils.SendLine(t, managementInterfaceConn, ">CLIENT:CONNECT,1,2\r\n>CLIENT:ENV,untrusted_ip=127.0.0.1\r\n>CLIENT:ENV,common_name=test\r\n>CLIENT:ENV,IV_SSO=webauth\r\n>CLIENT:ENV,END\r\n")
+
+	auth := testutils.ReadLine(t, reader)
+	assert.Contains(t, auth, "client-pending-auth 1 2 \"WEB_AUTH::")
+	testutils.SendLine(t, managementInterfaceConn, "SUCCESS: %s command succeeded\r\n", strings.SplitN(auth, " ", 2)[0])
+
+	authURL := strings.TrimPrefix(strings.Split(auth, `"`)[1], "WEB_AUTH::")
+
+	request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, authURL, nil)
+	require.NoError(t, err)
+
+	wg.Add(1)
+
+	go func() {
+		defer wg.Done()
+		assert.Equal(t, "client-auth-nt 1 2", testutils.ReadLine(t, reader))
+		testutils.SendLine(t, managementInterfaceConn, "SUCCESS: client-auth command succeeded\r\n")
+	}()
+
+	resp, err := httpClient.Do(request)
+	require.NoError(t, err)
+	require.Equal(t, http.StatusOK, resp.StatusCode)
+
+	_, err = io.Copy(io.Discard, resp.Body)
+	require.NoError(t, err)
+
+	_ = resp.Body.Close()
+
+	// Testing ReAuth
+	testutils.SendLine(t, managementInterfaceConn, ">CLIENT:REAUTH,1,3\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")
+	assert.Equal(t, "client-auth-nt 1 3", testutils.ReadLine(t, reader))
+	testutils.SendLine(t, managementInterfaceConn, "SUCCESS: client-auth command succeeded\r\n")
+
+	// Test Disconnect
+	testutils.SendLine(t, managementInterfaceConn, ">CLIENT:DISCONNECT,1\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")
+
+	// Test ReAuth after DC
+	testutils.SendLine(t, managementInterfaceConn, ">CLIENT:REAUTH,1,3\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")
+
+	assert.Contains(t, auth, "client-pending-auth 1 2 \"WEB_AUTH::")
+	testutils.SendLine(t, managementInterfaceConn, "SUCCESS: %s command succeeded\r\n", strings.SplitN(auth, " ", 2)[0])
+
+	time.Sleep(time.Millisecond * 50)
+
+	client.Shutdown()
+	wg.Wait()
+}
diff --git a/internal/types/userdata.go b/internal/oauth2/types/userdata.go
similarity index 100%
rename from internal/types/userdata.go
rename to internal/oauth2/types/userdata.go
diff --git a/internal/openvpn/bench_test.go b/internal/openvpn/bench_test.go
index 96c7b00..6734379 100644
--- a/internal/openvpn/bench_test.go
+++ b/internal/openvpn/bench_test.go
@@ -11,6 +11,7 @@ import (
 	"time"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/storage"
 	"github.com/jkroepke/openvpn-auth-oauth2/pkg/testutils"
@@ -30,7 +31,7 @@ func BenchmarkOpenVPNHandler(b *testing.B) {
 	conf := config.Config{
 		HTTP: config.HTTP{
 			BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
-			Secret:  testutils.HTTPSecret,
+			Secret:  testutils.Secret,
 		},
 		OpenVpn: config.OpenVpn{
 			Addr:   &url.URL{Scheme: managementInterface.Addr().Network(), Host: managementInterface.Addr().String()},
@@ -38,8 +39,8 @@ func BenchmarkOpenVPNHandler(b *testing.B) {
 		},
 	}
 
-	storageClient := storage.New("0123456789101112", time.Hour)
-	client := openvpn.NewClient(logger, conf, storageClient)
+	storageClient := storage.New(testutils.Secret, time.Hour)
+	client := openvpn.NewClient(logger, conf, oauth2.New(logger, conf, storageClient))
 
 	wg := sync.WaitGroup{}
 	wg.Add(1)
@@ -53,19 +54,19 @@ func BenchmarkOpenVPNHandler(b *testing.B) {
 		}
 	}()
 
-	conn, err := managementInterface.Accept()
+	managementInterfaceConn, err := managementInterface.Accept()
 	require.NoError(b, err)
 
-	defer conn.Close()
-	reader := bufio.NewReader(conn)
+	defer managementInterfaceConn.Close()
+	reader := bufio.NewReader(managementInterfaceConn)
 
 	require.NoError(b, err)
-	testutils.SendLine(b, conn, ">INFO:OpenVPN Management Interface Version 5 -- type 'help' for more info\r\n")
+	testutils.SendLine(b, managementInterfaceConn, ">INFO:OpenVPN Management Interface Version 5 -- type 'help' for more info\r\n")
 	assert.Equal(b, "hold release", testutils.ReadLine(b, reader))
-	testutils.SendLine(b, conn, "SUCCESS: hold release succeeded\r\n")
+	testutils.SendLine(b, managementInterfaceConn, "SUCCESS: hold release succeeded\r\n")
 	assert.Equal(b, "version", testutils.ReadLine(b, reader))
 
-	testutils.SendLine(b, conn, "OpenVPN Version: OpenVPN Mock\r\nManagement Interface Version: 5\r\nEND\r\n")
+	testutils.SendLine(b, managementInterfaceConn, "OpenVPN Version: OpenVPN Mock\r\nManagement Interface Version: 5\r\nEND\r\n")
 
 	tests := []struct {
 		name   string
@@ -89,9 +90,9 @@ func BenchmarkOpenVPNHandler(b *testing.B) {
 
 		b.Run(fmt.Sprintf(tt.name), func(b *testing.B) {
 			for i := 0; i < b.N; i++ {
-				testutils.SendLine(b, conn, tt.client)
+				testutils.SendLine(b, managementInterfaceConn, tt.client)
 				assert.Contains(b, testutils.ReadLine(b, reader), "client-pending-auth 0 1 \"WEB_AUTH::")
-				testutils.SendLine(b, conn, "SUCCESS: client-pending-auth command succeeded\r\n")
+				testutils.SendLine(b, managementInterfaceConn, "SUCCESS: client-pending-auth command succeeded\r\n")
 			}
 
 			b.ReportAllocs()
diff --git a/internal/openvpn/main_test.go b/internal/openvpn/main_test.go
index d05614e..604c1df 100644
--- a/internal/openvpn/main_test.go
+++ b/internal/openvpn/main_test.go
@@ -4,7 +4,6 @@ import (
 	"bufio"
 	"errors"
 	"io"
-	"net"
 	"net/url"
 	"regexp"
 	"strings"
@@ -13,6 +12,7 @@ import (
 	"time"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/storage"
@@ -28,15 +28,16 @@ func TestClientInvalidServer(t *testing.T) {
 	conf := config.Config{
 		HTTP: config.HTTP{
 			BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
-			Secret:  testutils.HTTPSecret,
+			Secret:  testutils.Secret,
 		},
 		OpenVpn: config.OpenVpn{
 			Addr:   &url.URL{Scheme: "tcp", Host: "0.0.0.0:1"},
 			Bypass: config.OpenVpnBypass{CommonNames: make([]string, 0)},
 		},
 	}
-	storageClient := storage.New("0123456789101112", time.Hour)
-	client := openvpn.NewClient(logger, conf, storageClient)
+	storageClient := storage.New(testutils.Secret, time.Hour)
+	provider := oauth2.New(logger, conf, storageClient)
+	client := openvpn.NewClient(logger, conf, provider)
 	err := client.Connect()
 	require.Error(t, err)
 	assert.Equal(t, "unable to connect to openvpn management interface tcp://0.0.0.0:1: dial tcp 0.0.0.0:1: connect: connection refused", err.Error())
@@ -59,7 +60,7 @@ func TestClientFull(t *testing.T) {
 			conf: config.Config{
 				HTTP: config.HTTP{
 					BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
-					Secret:  testutils.HTTPSecret,
+					Secret:  testutils.Secret,
 				},
 				OpenVpn: config.OpenVpn{
 					Bypass: config.OpenVpnBypass{CommonNames: make([]string, 0)},
@@ -73,7 +74,7 @@ func TestClientFull(t *testing.T) {
 			config.Config{
 				HTTP: config.HTTP{
 					BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
-					Secret:  testutils.HTTPSecret,
+					Secret:  testutils.Secret,
 				},
 				OpenVpn: config.OpenVpn{
 					Bypass:   config.OpenVpnBypass{CommonNames: make([]string, 0)},
@@ -105,7 +106,7 @@ func TestClientFull(t *testing.T) {
 			config.Config{
 				HTTP: config.HTTP{
 					BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
-					Secret:  testutils.HTTPSecret,
+					Secret:  testutils.Secret,
 				},
 				OpenVpn: config.OpenVpn{
 					Bypass:   config.OpenVpnBypass{CommonNames: make([]string, 0)},
@@ -121,7 +122,7 @@ func TestClientFull(t *testing.T) {
 			config.Config{
 				HTTP: config.HTTP{
 					BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
-					Secret:  testutils.HTTPSecret,
+					Secret:  testutils.Secret,
 				},
 				OpenVpn: config.OpenVpn{
 					Bypass:   config.OpenVpnBypass{CommonNames: []string{"bypass"}},
@@ -137,7 +138,7 @@ func TestClientFull(t *testing.T) {
 			config.Config{
 				HTTP: config.HTTP{
 					BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
-					Secret:  testutils.HTTPSecret,
+					Secret:  testutils.Secret,
 				},
 				OpenVpn: config.OpenVpn{
 					Bypass:   config.OpenVpnBypass{CommonNames: []string{"bypass"}},
@@ -153,7 +154,7 @@ func TestClientFull(t *testing.T) {
 			config.Config{
 				HTTP: config.HTTP{
 					BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
-					Secret:  testutils.HTTPSecret,
+					Secret:  testutils.Secret,
 				},
 				OpenVpn: config.OpenVpn{
 					Bypass:   config.OpenVpnBypass{CommonNames: []string{"bypass"}},
@@ -169,7 +170,7 @@ func TestClientFull(t *testing.T) {
 			config.Config{
 				HTTP: config.HTTP{
 					BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
-					Secret:  testutils.HTTPSecret,
+					Secret:  testutils.Secret,
 				},
 				OpenVpn: config.OpenVpn{
 					Bypass:   config.OpenVpnBypass{CommonNames: []string{"bypass"}},
@@ -188,21 +189,21 @@ func TestClientFull(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			t.Parallel()
 
-			l, err := net.Listen("tcp", "127.0.0.1:0")
-			require.NoError(t, err)
-			defer l.Close()
+			managementInterface := testutils.TCPTestListener(t)
+			defer managementInterface.Close()
 
-			tt.conf.OpenVpn.Addr = &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()}
+			tt.conf.OpenVpn.Addr = &url.URL{Scheme: managementInterface.Addr().Network(), Host: managementInterface.Addr().String()}
 
-			storageClient := storage.New("0123456789101112", time.Hour)
-			client := openvpn.NewClient(logger, tt.conf, storageClient)
+			storageClient := storage.New(testutils.Secret, time.Hour)
+			provider := oauth2.New(logger, tt.conf, storageClient)
+			client := openvpn.NewClient(logger, tt.conf, provider)
 
 			wg := sync.WaitGroup{}
 			wg.Add(1)
 
 			go func() {
 				defer wg.Done()
-				conn, err := l.Accept()
+				conn, err := managementInterface.Accept()
 				require.NoError(t, err)
 
 				defer conn.Close()
@@ -258,7 +259,7 @@ func TestClientFull(t *testing.T) {
 				}
 			}()
 
-			err = client.Connect()
+			err := client.Connect()
 			if tt.err != nil {
 				require.Error(t, err)
 				assert.Equal(t, tt.err.Error(), err.Error())
@@ -278,28 +279,28 @@ func TestClientInvalidPassword(t *testing.T) {
 	t.Parallel()
 
 	logger := testutils.NewTestLogger()
-	l, err := net.Listen("tcp", "127.0.0.1:0")
-	require.NoError(t, err)
 
-	defer l.Close()
+	managementInterface := testutils.TCPTestListener(t)
+	defer managementInterface.Close()
 
 	conf := config.Config{
 		HTTP: config.HTTP{
 			BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
-			Secret:  testutils.HTTPSecret,
+			Secret:  testutils.Secret,
 		},
 		OpenVpn: config.OpenVpn{
-			Addr:     &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()},
+			Addr:     &url.URL{Scheme: managementInterface.Addr().Network(), Host: managementInterface.Addr().String()},
 			Bypass:   config.OpenVpnBypass{CommonNames: make([]string, 0)},
 			Password: "invalid",
 		},
 	}
 
-	storageClient := storage.New("0123456789101112", time.Hour)
-	client := openvpn.NewClient(logger, conf, storageClient)
+	storageClient := storage.New(testutils.Secret, time.Hour)
+	provider := oauth2.New(logger, conf, storageClient)
+	client := openvpn.NewClient(logger, conf, provider)
 
 	go func() {
-		conn, err := l.Accept()
+		conn, err := managementInterface.Accept()
 		require.NoError(t, err)
 
 		defer conn.Close()
@@ -310,7 +311,7 @@ func TestClientInvalidPassword(t *testing.T) {
 		testutils.SendLine(t, conn, "ERROR: bad password\r\n")
 	}()
 
-	err = client.Connect()
+	err := client.Connect()
 
 	require.Error(t, err)
 	assert.Equal(t, "unable to connect to openvpn management interface: invalid password", err.Error())
@@ -326,7 +327,7 @@ func TestClientInvalidVersion(t *testing.T) {
 	conf := config.Config{
 		HTTP: config.HTTP{
 			BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
-			Secret:  testutils.HTTPSecret,
+			Secret:  testutils.Secret,
 		},
 		OpenVpn: config.OpenVpn{
 			Bypass: config.OpenVpnBypass{CommonNames: make([]string, 0)},
@@ -361,17 +362,17 @@ func TestClientInvalidVersion(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			t.Parallel()
 
-			l, err := net.Listen("tcp", "127.0.0.1:0")
-			require.NoError(t, err)
-			defer l.Close()
+			managementInterface := testutils.TCPTestListener(t)
+			defer managementInterface.Close()
 
-			conf.OpenVpn.Addr = &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()}
+			conf.OpenVpn.Addr = &url.URL{Scheme: managementInterface.Addr().Network(), Host: managementInterface.Addr().String()}
 
-			storageClient := storage.New("0123456789101112", time.Hour)
-			client := openvpn.NewClient(logger, conf, storageClient)
+			storageClient := storage.New(testutils.Secret, time.Hour)
+			provider := oauth2.New(logger, conf, storageClient)
+			client := openvpn.NewClient(logger, conf, provider)
 
 			go func() {
-				conn, err := l.Accept()
+				conn, err := managementInterface.Accept()
 				require.NoError(t, err)
 
 				defer conn.Close()
@@ -386,7 +387,7 @@ func TestClientInvalidVersion(t *testing.T) {
 				testutils.SendLine(t, conn, tt.version)
 			}()
 
-			err = client.Connect()
+			err := client.Connect()
 			require.Error(t, err)
 			assert.Equal(t, tt.err, err.Error())
 
diff --git a/internal/state/state_test.go b/internal/state/state_test.go
index 0585f68..7a1977b 100644
--- a/internal/state/state_test.go
+++ b/internal/state/state_test.go
@@ -13,7 +13,7 @@ import (
 func TestState(t *testing.T) {
 	t.Parallel()
 
-	encryptionKey := testutils.HTTPSecret
+	encryptionKey := testutils.Secret
 
 	for i := 1; i < 50; i++ {
 		token := state.New(state.ClientIdentifier{Cid: 1, Kid: 2}, "127.0.0.1", "test")
@@ -41,7 +41,7 @@ func TestStateInvalid_Key(t *testing.T) {
 func TestStateInvalid_Future(t *testing.T) {
 	t.Parallel()
 
-	encryptionKey := testutils.HTTPSecret
+	encryptionKey := testutils.Secret
 
 	token := state.New(state.ClientIdentifier{Cid: 1, Kid: 2}, "127.0.0.1", "test")
 	token.Issued = time.Now().Add(time.Hour).Unix()
@@ -53,7 +53,7 @@ func TestStateInvalid_Future(t *testing.T) {
 func TestStateInvalid_TooOld(t *testing.T) {
 	t.Parallel()
 
-	encryptionKey := testutils.HTTPSecret
+	encryptionKey := testutils.Secret
 
 	token := state.New(state.ClientIdentifier{Cid: 1, Kid: 2}, "127.0.0.1", "test")
 	token.Issued = time.Now().Add(-1 * time.Hour).Unix()
@@ -65,7 +65,7 @@ func TestStateInvalid_TooOld(t *testing.T) {
 func TestStateInvalid_Encoded(t *testing.T) {
 	t.Parallel()
 
-	encryptionKey := testutils.HTTPSecret
+	encryptionKey := testutils.Secret
 
 	token := state.NewEncoded("test")
 	require.Error(t, token.Decode(encryptionKey))
diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go
new file mode 100644
index 0000000..62b6405
--- /dev/null
+++ b/internal/storage/storage_test.go
@@ -0,0 +1,38 @@
+package storage_test
+
+import (
+	"testing"
+	"time"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/storage"
+	"github.com/jkroepke/openvpn-auth-oauth2/pkg/testutils"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestStorage(t *testing.T) {
+	t.Parallel()
+
+	storageClient := storage.New(testutils.Secret, time.Millisecond*400)
+	require.NoError(t, storageClient.Set(uint64(0), "TEST0"))
+	require.NoError(t, storageClient.Set(uint64(1), "TEST1"))
+
+	token, err := storageClient.Get(uint64(0))
+
+	require.NoError(t, err)
+	assert.Equal(t, "TEST0", token)
+
+	token, err = storageClient.Get(uint64(1))
+
+	require.NoError(t, err)
+	assert.Equal(t, "TEST1", token)
+
+	_, err = storageClient.Get(uint64(2))
+	require.ErrorIs(t, err, storage.ErrNotExists)
+
+	storageClient.Delete(uint64(1))
+
+	_, err = storageClient.Get(uint64(1))
+	require.Error(t, err)
+	require.ErrorIs(t, err, storage.ErrNotExists)
+}
diff --git a/pkg/testutils/listener.go b/pkg/testutils/listener.go
new file mode 100644
index 0000000..6f2de50
--- /dev/null
+++ b/pkg/testutils/listener.go
@@ -0,0 +1,17 @@
+package testutils
+
+import (
+	"net"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TCPTestListener(tb testing.TB) net.Listener {
+	tb.Helper()
+
+	listener, err := net.Listen("tcp", "127.0.0.1:0")
+	require.NoError(tb, err)
+
+	return listener
+}
diff --git a/pkg/testutils/main.go b/pkg/testutils/main.go
index f2dd6c3..78eb1a1 100644
--- a/pkg/testutils/main.go
+++ b/pkg/testutils/main.go
@@ -8,18 +8,25 @@ import (
 	"io"
 	"net"
 	"net/http"
+	"net/http/cookiejar"
 	"net/http/httptest"
+	"net/url"
 	"strings"
 	"testing"
+	"time"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/providers/generic"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/storage"
 	"github.com/stretchr/testify/require"
-	"github.com/zitadel/oidc/v3/example/server/storage"
+	oidcStorage "github.com/zitadel/oidc/v3/example/server/storage"
 	"github.com/zitadel/oidc/v3/pkg/op"
 	"golang.org/x/text/language"
 )
 
-const HTTPSecret = "0123456789101112"
+const Secret = "0123456789101112"
 
 func SendLine(tb testing.TB, conn net.Conn, msg string, a ...any) {
 	tb.Helper()
@@ -40,18 +47,18 @@ func ReadLine(tb testing.TB, reader *bufio.Reader) string {
 	return strings.TrimSpace(line)
 }
 
-func SetupResourceServer(clientListener net.Listener) (*httptest.Server, config.OAuth2Client, error) {
-	client := storage.WebClient(
+func SetupResourceServer(clientListener net.Listener) (*httptest.Server, *url.URL, config.OAuth2Client, error) {
+	client := oidcStorage.WebClient(
 		clientListener.Addr().String(),
 		"SECRET",
 		fmt.Sprintf("http://%s/oauth2/callback", clientListener.Addr().String()),
 	)
 
-	clients := map[string]*storage.Client{
+	clients := map[string]*oidcStorage.Client{
 		clientListener.Addr().String(): client,
 	}
 
-	opStorage := storage.NewStorageWithClients(storage.NewUserStore("http://localhost"), clients)
+	opStorage := oidcStorage.NewStorageWithClients(oidcStorage.NewUserStore("http://localhost"), clients)
 	opConfig := &op.Config{
 		CryptoKey:                sha256.Sum256([]byte("test")),
 		DefaultLogoutRedirectURI: "/",
@@ -65,7 +72,7 @@ func SetupResourceServer(clientListener net.Listener) (*httptest.Server, config.
 
 	opProvider, err := op.NewProvider(opConfig, opStorage, op.IssuerFromHost(""), op.WithAllowInsecure())
 	if err != nil {
-		return nil, config.OAuth2Client{}, err //nolint:wrapcheck
+		return nil, nil, config.OAuth2Client{}, err //nolint:wrapcheck
 	}
 
 	mux := http.NewServeMux()
@@ -75,5 +82,93 @@ func SetupResourceServer(clientListener net.Listener) (*httptest.Server, config.
 		http.Redirect(w, r, op.AuthCallbackURL(opProvider)(r.Context(), r.FormValue("authRequestID")), http.StatusFound)
 	}))
 
-	return httptest.NewServer(mux), config.OAuth2Client{ID: clientListener.Addr().String(), Secret: "SECRET"}, err
+	resourceServer := httptest.NewServer(mux)
+
+	resourceServerURL, err := url.Parse(resourceServer.URL)
+	if err != nil {
+		return nil, nil, config.OAuth2Client{}, err //nolint:wrapcheck
+	}
+
+	return resourceServer, resourceServerURL, config.OAuth2Client{ID: clientListener.Addr().String(), Secret: "SECRET"}, nil
+}
+
+//nolint:cyclop
+func SetupMockEnvironment(tb testing.TB, conf config.Config) (
+	config.Config, *openvpn.Client, net.Listener,
+	*oauth2.Provider, *httptest.Server, *http.Client, func(),
+) {
+	tb.Helper()
+
+	logger := NewTestLogger()
+
+	managementInterface := TCPTestListener(tb)
+	clientListener := TCPTestListener(tb)
+
+	resourceServer, resourceServerURL, clientCredentials, err := SetupResourceServer(clientListener)
+	require.NoError(tb, err)
+
+	if conf.HTTP.BaseURL == nil {
+		conf.HTTP.BaseURL = &url.URL{Scheme: "http", Host: clientListener.Addr().String()}
+	}
+
+	if conf.HTTP.Secret == "" {
+		conf.HTTP.Secret = Secret
+	}
+
+	if conf.HTTP.CallbackTemplate == nil {
+		conf.HTTP.CallbackTemplate = config.Defaults.HTTP.CallbackTemplate
+	}
+
+	if conf.OpenVpn.Addr == nil {
+		conf.OpenVpn.Addr = &url.URL{Scheme: managementInterface.Addr().Network(), Host: managementInterface.Addr().String()}
+	}
+
+	if conf.OpenVpn.Bypass.CommonNames == nil {
+		conf.OpenVpn.Bypass.CommonNames = make([]string, 0)
+	}
+
+	if conf.OAuth2.Issuer == nil {
+		conf.OAuth2.Issuer = resourceServerURL
+	}
+
+	if conf.OAuth2.Provider == "" {
+		conf.OAuth2.Provider = generic.Name
+	}
+
+	if conf.OAuth2.Client.ID == "" {
+		conf.OAuth2.Client.ID = clientCredentials.ID
+	}
+
+	if conf.OAuth2.Client.Secret.String() == "" {
+		conf.OAuth2.Client.Secret = clientCredentials.Secret
+	}
+
+	if conf.OAuth2.Refresh.Expires.String() == "" {
+		conf.OAuth2.Refresh.Expires = time.Hour
+	}
+
+	storageClient := storage.New(Secret, conf.OAuth2.Refresh.Expires)
+	provider := oauth2.New(logger, conf, storageClient)
+	openvpnClient := openvpn.NewClient(logger, conf, provider)
+
+	require.NoError(tb, provider.Discover(openvpnClient))
+
+	httpClientListener := httptest.NewUnstartedServer(provider.Handler())
+	httpClientListener.Listener.Close()
+	httpClientListener.Listener = clientListener
+	httpClientListener.Start()
+
+	httpClient := httpClientListener.Client()
+
+	jar, err := cookiejar.New(nil)
+	require.NoError(tb, err)
+
+	httpClient.Jar = jar
+
+	return conf, openvpnClient, managementInterface, provider, httpClientListener, httpClient, func() {
+		defer managementInterface.Close()
+		defer clientListener.Close()
+		defer resourceServer.Close()
+		defer httpClientListener.Close()
+	}
 }
diff --git a/tests/docker-compose.yaml b/tests/docker-compose.yaml
index d37e074..069e4ef 100644
--- a/tests/docker-compose.yaml
+++ b/tests/docker-compose.yaml
@@ -80,7 +80,7 @@ services:
 
         #plugin /plugin/openvpn-auth-oauth2.so /plugin/config.yaml
 
-        reneg-sec 120
+        reneg-sec 30
         auth-user-pass-optional
         EOF