Repository: jkroepke/openvpn-auth-oauth2
Pull request #44 (2023-10-06 09:32:19)

tests: more coverage
Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>**What this PR does / why we need it**:

**Which issue this PR fixes** *(optional, in `fixes #<issue number>(, fixes #<issue_number>, ...)` format, will close that issue when PR gets merged)*: fixes #

**Special notes for your reviewer**:

**Particularly user-facing changes**:

**PR Readiness Checklist**:

Complete these before marking the PR as `ready to review`:

- [ ] The PR title has a summary of the changes
- [ ] The PR body has a summary to reflect any significant (and particularly user-facing) changes introduced by this PR


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

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


```diff
@@            Coverage Diff             @@
##             main      #44      +/-   ##
==========================================
+ Coverage   72.99%   74.13%   +1.14%     
==========================================
  Files          19       19              
  Lines        1248     1249       +1     
==========================================
+ Hits          911      926      +15     
+ Misses        294      282      -12     
+ Partials       43       41       -2     
```


| [Files](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/44?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) | Coverage Δ | |
|---|---|---|
| [cmd/root.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/44?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke#diff-Y21kL3Jvb3QuZ28=) | `80.76% <100.00%> (+5.04%)` | :arrow_up: |
| [internal/config/config.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/44?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke#diff-aW50ZXJuYWwvY29uZmlnL2NvbmZpZy5nbw==) | `96.09% <66.66%> (ø)` | |

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


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/44?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 4c830a3..a56e033 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -34,7 +34,7 @@ jobs:
           sudo sed -i'' -E 's/^(deb|deb-src) /\1 [arch=amd64,i386] /' /etc/apt/sources.list
 
           echo "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ $(lsb_release -cs) main restricted universe" | sudo tee /etc/apt/sources.list.d/arm64.list
-          echo "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ $(lsb_release -cs)-updates main restricted universe" | sudo tee -a /etc/apt/sources.list.d/arm64.list
+          #echo "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ $(lsb_release -cs)-updates main restricted universe" | sudo tee -a /etc/apt/sources.list.d/arm64.list
           echo "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ $(lsb_release -cs)-security main restricted universe" | sudo tee -a /etc/apt/sources.list.d/arm64.list
 
           sudo apt-get update -q
@@ -76,9 +76,18 @@ jobs:
     permissions:
       contents: write
     steps:
-      - run: |
-          sudo dpkg --add-architecture arm64 && sudo apt-get update
+      - name: Install plugin dependencies
+        run: |
+          sudo dpkg --add-architecture arm64
+          sudo sed -i'' -E 's/^(deb|deb-src) /\1 [arch=amd64,i386] /' /etc/apt/sources.list
+
+          echo "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ $(lsb_release -cs) main restricted universe" | sudo tee /etc/apt/sources.list.d/arm64.list
+          #echo "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ $(lsb_release -cs)-updates main restricted universe" | sudo tee -a /etc/apt/sources.list.d/arm64.list
+          echo "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ $(lsb_release -cs)-security main restricted universe" | sudo tee -a /etc/apt/sources.list.d/arm64.list
+
+          sudo apt-get update -q
           sudo apt-get install --no-install-recommends -qy $PACKAGES
+
       - uses: actions/checkout@v4
         with:
           fetch-depth: 0
diff --git a/.golangci.yaml b/.golangci.yaml
index 6cc9c9d..3477b2b 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -19,6 +19,7 @@ linters:
     - funlen
     - ireturn
     - goerr113
+    - gomoddirectives
 
 issues:
   exclude-rules:
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 041c0e3..d658853 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -11,7 +11,7 @@ builds:
     mod_timestamp: '{{ .CommitTimestamp }}'
   - id: "openvpn-auth-oauth2.so"
     binary: openvpn-auth-oauth2
-    main: ./plugin/
+    main: ./pkg/plugin/
     buildmode: c-shared
     goos:
       - linux
diff --git a/cmd/root.go b/cmd/root.go
index 874d883..e2feea0 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -65,7 +65,8 @@ func Execute(args []string, logWriter io.Writer, version, commit, date string) i
 	openvpnClient := openvpn.NewClient(logger, conf)
 	done := make(chan int, 1)
 
-	server := http.NewHTTPServer(logger, conf, provider, openvpnClient)
+	serverHandler := oauth2.Handler(logger, conf, provider, openvpnClient)
+	server := http.NewHTTPServer(logger, conf, serverHandler)
 
 	go func() {
 		if err := server.Listen(); err != nil {
diff --git a/go.mod b/go.mod
index 0e2c45c..c4b0319 100644
--- a/go.mod
+++ b/go.mod
@@ -1,19 +1,21 @@
 module github.com/jkroepke/openvpn-auth-oauth2
 
-go 1.21
+go 1.21.1
 
 require (
+	github.com/jkroepke/openvpn-auth-oauth2/pkg/testutils v0.0.0-00010101000000-000000000000
 	github.com/knadh/koanf/parsers/yaml v0.1.0
 	github.com/knadh/koanf/providers/env v0.1.0
 	github.com/knadh/koanf/providers/file v0.1.0
 	github.com/knadh/koanf/providers/posflag v0.1.0
 	github.com/knadh/koanf/providers/structs v0.1.0
 	github.com/knadh/koanf/v2 v2.0.1
+	github.com/madflojo/testcerts v1.1.0
 	github.com/mitchellh/mapstructure v1.5.0
 	github.com/spf13/pflag v1.0.5
 	github.com/stretchr/testify v1.8.4
 	github.com/zitadel/oidc/v2 v2.11.0
-	golang.org/x/oauth2 v0.12.0
+	golang.org/x/oauth2 v0.13.0
 	golang.org/x/text v0.13.0
 )
 
@@ -29,7 +31,6 @@ require (
 	github.com/gorilla/schema v1.2.0 // indirect
 	github.com/gorilla/securecookie v1.1.1 // indirect
 	github.com/knadh/koanf/maps v0.1.1 // indirect
-	github.com/kr/text v0.2.0 // indirect
 	github.com/mitchellh/copystructure v1.2.0 // indirect
 	github.com/mitchellh/reflectwalk v1.0.2 // indirect
 	github.com/muhlemmer/gu v0.3.1 // indirect
@@ -39,10 +40,12 @@ require (
 	go.opentelemetry.io/otel v1.19.0 // indirect
 	go.opentelemetry.io/otel/metric v1.19.0 // indirect
 	go.opentelemetry.io/otel/trace v1.19.0 // indirect
-	golang.org/x/crypto v0.13.0 // indirect
-	golang.org/x/sys v0.12.0 // indirect
+	golang.org/x/crypto v0.14.0 // indirect
+	golang.org/x/sys v0.13.0 // indirect
 	google.golang.org/appengine v1.6.8 // indirect
 	google.golang.org/protobuf v1.31.0 // indirect
 	gopkg.in/square/go-jose.v2 v2.6.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
+
+replace github.com/jkroepke/openvpn-auth-oauth2/pkg/testutils => ./pkg/testutils
diff --git a/go.sum b/go.sum
index 03f2ef5..2d7fc78 100644
--- a/go.sum
+++ b/go.sum
@@ -1,4 +1,3 @@
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
@@ -47,6 +46,8 @@ github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
 github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/madflojo/testcerts v1.1.0 h1:kopRnZB2jH1yKhC2d27+GVQLDy/fL9/65UrkpJkF4GA=
+github.com/madflojo/testcerts v1.1.0/go.mod h1:G+ucVds7Pj79qA9/ue9FygnXiBCm622IdzKWna621io=
 github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
@@ -78,16 +79,16 @@ go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1
 go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
-golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
+golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
-golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
-golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4=
-golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4=
+golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos=
+golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
+golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY=
+golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -96,8 +97,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
-golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
+golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/go.work b/go.work
index cfb4297..7209106 100644
--- a/go.work
+++ b/go.work
@@ -2,5 +2,6 @@ go 1.21.1
 
 use (
 	.
-	plugin
+	pkg/plugin
+	pkg/testutils
 )
diff --git a/go.work.sum b/go.work.sum
index b220654..5088ab6 100644
--- a/go.work.sum
+++ b/go.work.sum
@@ -1,23 +1,56 @@
+cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA=
+cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw=
 cloud.google.com/go/compute v1.20.1 h1:6aKEtlUiwEpJzM001l0yFkpXmUVXaN8W+fbkb2AZNbg=
 cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
 cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
 cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
 github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/google/go-github/v31 v31.0.0 h1:JJUxlP9lFK+ziXKimTCprajMApV1ecWD4NB6CCb0plo=
 github.com/google/go-github/v31 v31.0.0/go.mod h1:NQPZol8/1sMoWYGN2yaALIBytu17gAWfhbweiEed3pM=
 github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
 github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
+github.com/google/s2a-go v0.1.4 h1:1kZ/sQM3srePvKs3tXAvQzo66XfcReoqFpIpIccE7Oc=
+github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A=
+github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k=
+github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
+github.com/googleapis/gax-go/v2 v2.11.0 h1:9V9PWXEsWnPpQhu/PeQIkS4eGzMlTLGgt80cUUI8Ki4=
+github.com/googleapis/gax-go/v2 v2.11.0/go.mod h1:DxmR61SGKkGLa2xigwuZIQpkCI2S5iydzRfb3peWZJI=
 github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
+go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
+go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
 golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
+golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU=
 golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
+golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
+google.golang.org/api v0.126.0 h1:q4GJq+cAdMAC7XP7njvQ4tvohGLiSlytuL4BQxbIZ+o=
+google.golang.org/api v0.126.0/go.mod h1:mBwVAtz+87bEN6CbA1GtZPDOqY2R5ONPqJeIlvyo4Aw=
+google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc h1:8DyZCyvI8mE1IdLy/60bS+52xfymkE72wv1asokgtao=
+google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:xZnkP7mREFX5MORlOPEzLMr+90PPZQ2QWzrVTWfAq64=
+google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc h1:kVKPf/IiYSBWEWtkIn6wZXwWGCnLKcC8oWfZvXjsGnM=
+google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc h1:XSJ8Vk1SWuNr8S18z1NZSziL0CPIXLCCMDOEFtHBOFc=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
+google.golang.org/grpc v1.55.0 h1:3Oj82/tFSCeUrRTg/5E/7d/W5A1tj6Ky1ABAuZuv5ag=
+google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8=
diff --git a/internal/config/config.go b/internal/config/config.go
index 4b22b45..ccd3fe2 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -192,7 +192,7 @@ func Validate(mode int, conf Config) error { //nolint:cyclop
 		"oauth2.client.id": conf.OAuth2.Client.ID,
 	} {
 		if value == "" {
-			return fmt.Errorf("%s is required", key)
+			return fmt.Errorf("%s is %w", key, ErrRequired)
 		}
 	}
 
@@ -201,7 +201,7 @@ func Validate(mode int, conf Config) error { //nolint:cyclop
 		"oauth2.issuer": conf.OAuth2.Issuer,
 	} {
 		if utils.IsURLEmpty(value) {
-			return errors.New(utils.StringConcat(key, " is required"))
+			return fmt.Errorf("%s is %w", key, ErrRequired)
 		}
 	}
 
@@ -230,7 +230,7 @@ func Validate(mode int, conf Config) error { //nolint:cyclop
 			"openvpn.addr": conf.OpenVpn.Addr,
 		} {
 			if utils.IsURLEmpty(value) {
-				return errors.New(utils.StringConcat(key, " is required"))
+				return fmt.Errorf("%s is %w", key, ErrRequired)
 			}
 		}
 
diff --git a/internal/http/main.go b/internal/http/main.go
index 423d689..b3c2c77 100644
--- a/internal/http/main.go
+++ b/internal/http/main.go
@@ -9,7 +9,6 @@ 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/utils"
 )
 
@@ -19,15 +18,17 @@ type Server struct {
 	server *http.Server
 }
 
-func NewHTTPServer(logger *slog.Logger, conf config.Config, provider oauth2.Provider, openvpn oauth2.OpenVPN) Server {
+func NewHTTPServer(logger *slog.Logger, conf config.Config, fnHandler *http.ServeMux) Server {
 	return Server{
 		conf:   conf,
 		logger: logger,
 		server: &http.Server{
 			Addr:              conf.HTTP.Listen,
 			ReadHeaderTimeout: 3 * time.Second,
+			ReadTimeout:       3 * time.Second,
+			WriteTimeout:      3 * time.Second,
 			ErrorLog:          slog.NewLogLogger(logger.Handler(), slog.LevelError),
-			Handler:           oauth2.Handler(logger, conf, provider, openvpn),
+			Handler:           fnHandler,
 		},
 	}
 }
diff --git a/plugin/.gitignore b/pkg/plugin/.gitignore
similarity index 100%
rename from plugin/.gitignore
rename to pkg/plugin/.gitignore
diff --git a/plugin/Dockerfile b/pkg/plugin/Dockerfile
similarity index 100%
rename from plugin/Dockerfile
rename to pkg/plugin/Dockerfile
diff --git a/plugin/Makefile b/pkg/plugin/Makefile
similarity index 100%
rename from plugin/Makefile
rename to pkg/plugin/Makefile
diff --git a/plugin/callbacks.go b/pkg/plugin/callbacks.go
similarity index 93%
rename from plugin/callbacks.go
rename to pkg/plugin/callbacks.go
index 2aa2408..873632c 100644
--- a/plugin/callbacks.go
+++ b/pkg/plugin/callbacks.go
@@ -1,3 +1,5 @@
+//go:build linux
+
 package main
 
 import "C"
@@ -13,7 +15,7 @@ func (p *PluginHandle) AcceptClient(logger *slog.Logger, client state.ClientIden
 	p.writeToAuthFile(logger, client, "1")
 }
 
-func (p *PluginHandle) AcceptClientWithToken(logger *slog.Logger, client state.ClientIdentifier, username string) {
+func (p *PluginHandle) AcceptClientWithToken(logger *slog.Logger, client state.ClientIdentifier, _ string) {
 	p.writeToAuthFile(logger, client, "1")
 }
 
diff --git a/plugin/client.go b/pkg/plugin/client.go
similarity index 97%
rename from plugin/client.go
rename to pkg/plugin/client.go
index bb4c316..fb2c925 100644
--- a/plugin/client.go
+++ b/pkg/plugin/client.go
@@ -1,3 +1,5 @@
+//go:build linux
+
 package main
 
 import "C"
diff --git a/plugin/go.mod b/pkg/plugin/go.mod
similarity index 83%
rename from plugin/go.mod
rename to pkg/plugin/go.mod
index fae3b68..d87dd83 100644
--- a/plugin/go.mod
+++ b/pkg/plugin/go.mod
@@ -1,8 +1,8 @@
-module github.com/jkroepke/openvpn-auth-oauth2/plugin
+module github.com/jkroepke/openvpn-auth-oauth2/pkg/plugin
 
 go 1.21.1
 
-replace github.com/jkroepke/openvpn-auth-oauth2 => ../
+replace github.com/jkroepke/openvpn-auth-oauth2 => ../../
 
 require github.com/jkroepke/openvpn-auth-oauth2 v0.0.0-00010101000000-000000000000
 
@@ -20,15 +20,16 @@ require (
 	github.com/knadh/koanf/providers/posflag v0.1.0 // indirect
 	github.com/knadh/koanf/providers/structs v0.1.0 // indirect
 	github.com/knadh/koanf/v2 v2.0.1 // indirect
+	github.com/kr/text v0.2.0 // indirect
 	github.com/mitchellh/copystructure v1.2.0 // indirect
 	github.com/mitchellh/mapstructure v1.5.0 // indirect
 	github.com/mitchellh/reflectwalk v1.0.2 // indirect
 	github.com/muhlemmer/gu v0.3.1 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/zitadel/oidc/v2 v2.11.0 // indirect
-	golang.org/x/crypto v0.13.0 // indirect
-	golang.org/x/oauth2 v0.12.0 // indirect
-	golang.org/x/sys v0.12.0 // indirect
+	golang.org/x/crypto v0.14.0 // indirect
+	golang.org/x/oauth2 v0.13.0 // indirect
+	golang.org/x/sys v0.13.0 // indirect
 	golang.org/x/text v0.13.0 // indirect
 	google.golang.org/appengine v1.6.8 // indirect
 	google.golang.org/protobuf v1.31.0 // indirect
diff --git a/plugin/go.sum b/pkg/plugin/go.sum
similarity index 94%
rename from plugin/go.sum
rename to pkg/plugin/go.sum
index 6bf1a79..ecebe04 100644
--- a/plugin/go.sum
+++ b/pkg/plugin/go.sum
@@ -41,6 +41,7 @@ github.com/knadh/koanf/v2 v2.0.1 h1:1dYGITt1I23x8cfx8ZnldtezdyaZtfAuRtIFOiRzK7g=
 github.com/knadh/koanf/v2 v2.0.1/go.mod h1:ZeiIlIDXTE7w1lMT6UVcNiRAS2/rCeLn/GdLNvY1Dus=
 github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/madflojo/testcerts v1.1.0 h1:kopRnZB2jH1yKhC2d27+GVQLDy/fL9/65UrkpJkF4GA=
 github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
@@ -72,16 +73,13 @@ go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1
 go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
-golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
-golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
-golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4=
-golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4=
+golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos=
+golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -90,8 +88,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
-golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/plugin/logger.go b/pkg/plugin/logger.go
similarity index 97%
rename from plugin/logger.go
rename to pkg/plugin/logger.go
index 37e3c25..df47537 100644
--- a/plugin/logger.go
+++ b/pkg/plugin/logger.go
@@ -1,3 +1,5 @@
+//go:build linux
+
 package main
 
 /*
@@ -42,7 +44,7 @@ func (h *PluginHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
 	return &h2
 }
 
-func (h *PluginHandler) WithGroup(name string) slog.Handler {
+func (h *PluginHandler) WithGroup(_ string) slog.Handler {
 	// TODO implement me
 	panic("implement me")
 }
diff --git a/plugin/main.go b/pkg/plugin/main.go
similarity index 96%
rename from plugin/main.go
rename to pkg/plugin/main.go
index b619f21..a646740 100644
--- a/plugin/main.go
+++ b/pkg/plugin/main.go
@@ -1,3 +1,5 @@
+//go:build linux
+
 package main
 
 import "C"
diff --git a/plugin/plugin.go b/pkg/plugin/plugin.go
similarity index 86%
rename from plugin/plugin.go
rename to pkg/plugin/plugin.go
index 813a3de..092c300 100644
--- a/plugin/plugin.go
+++ b/pkg/plugin/plugin.go
@@ -1,3 +1,5 @@
+//go:build linux
+
 package main
 
 /*
@@ -20,25 +22,29 @@ import (
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
 )
 
+//goland:noinspection GoSnakeCaseUsage
 const OPENVPN_PLUGIN_STRUCTVER_MIN = 5
 
 //export openvpn_plugin_select_initialization_point_v1
+//goland:noinspection GoSnakeCaseUsage
 func openvpn_plugin_select_initialization_point_v1() C.int {
 	return 2
 }
 
 //export openvpn_plugin_min_version_required_v1
+//goland:noinspection GoSnakeCaseUsage
 func openvpn_plugin_min_version_required_v1() C.int {
 	return 3
 }
 
 //export openvpn_plugin_open_v3_go
-func openvpn_plugin_open_v3_go(v3structver C.int, args *C.struct_openvpn_plugin_args_open_in, retptr *C.struct_openvpn_plugin_args_open_return) C.int {
+//goland:noinspection GoSnakeCaseUsage
+func openvpn_plugin_open_v3_go(v3structver C.int, args *C.struct_openvpn_plugin_args_open_in, ret *C.struct_openvpn_plugin_args_open_return) C.int {
 	if v3structver < OPENVPN_PLUGIN_STRUCTVER_MIN {
 		return C.OPENVPN_PLUGIN_FUNC_ERROR
 	}
 
-	retptr.type_mask = 1 << C.OPENVPN_PLUGIN_AUTH_USER_PASS_VERIFY
+	ret.type_mask = 1 << C.OPENVPN_PLUGIN_AUTH_USER_PASS_VERIFY
 
 	pluginArgs := unsafe.Slice(args.argv, 2)
 
@@ -74,7 +80,8 @@ func openvpn_plugin_open_v3_go(v3structver C.int, args *C.struct_openvpn_plugin_
 		return C.OPENVPN_PLUGIN_FUNC_ERROR
 	}
 
-	handle.server = http.NewHTTPServer(logger, conf, provider, handle)
+	serverHandler := oauth2.Handler(logger, conf, provider, handle)
+	handle.server = http.NewHTTPServer(logger, conf, serverHandler)
 	go func() {
 		if err := handle.server.Listen(); err != nil {
 			logger.Error(fmt.Errorf("error http listener: %w", err).Error())
@@ -82,7 +89,7 @@ func openvpn_plugin_open_v3_go(v3structver C.int, args *C.struct_openvpn_plugin_
 		}
 	}()
 
-	retptr.handle = (C.openvpn_plugin_handle_t)(unsafe.Pointer(handle))
+	ret.handle = (C.openvpn_plugin_handle_t)(unsafe.Pointer(handle))
 
 	logger.Info(fmt.Sprintf("plugin initialization done. version: %s", version))
 	logger.Warn("THIS PLUGIN IS STILL IN EXPERIMENTAL STATE")
@@ -91,6 +98,7 @@ func openvpn_plugin_open_v3_go(v3structver C.int, args *C.struct_openvpn_plugin_
 }
 
 //export openvpn_plugin_func_v3_go
+//goland:noinspection GoSnakeCaseUsage
 func openvpn_plugin_func_v3_go(v3structver C.int, args *C.struct_openvpn_plugin_args_func_in, _ *C.struct_openvpn_plugin_args_func_return) C.int {
 	if v3structver < OPENVPN_PLUGIN_STRUCTVER_MIN {
 		return C.OPENVPN_PLUGIN_FUNC_ERROR
@@ -136,6 +144,7 @@ func openvpn_plugin_func_v3_go(v3structver C.int, args *C.struct_openvpn_plugin_
 }
 
 //export openvpn_plugin_close_v1
+//goland:noinspection GoSnakeCaseUsage
 func openvpn_plugin_close_v1(pluginHandle C.openvpn_plugin_handle_t) {
 	handle := (*PluginHandle)(unsafe.Pointer(pluginHandle))
 	if handle == nil {
@@ -148,6 +157,7 @@ func openvpn_plugin_close_v1(pluginHandle C.openvpn_plugin_handle_t) {
 }
 
 //export openvpn_plugin_abort_v1
+//goland:noinspection GoSnakeCaseUsage
 func openvpn_plugin_abort_v1(pluginHandle C.openvpn_plugin_handle_t) {
 	handle := (*PluginHandle)(unsafe.Pointer(pluginHandle))
 	if handle == nil {
diff --git a/plugin/plugin_wrapper.go b/pkg/plugin/plugin_wrapper.go
similarity index 97%
rename from plugin/plugin_wrapper.go
rename to pkg/plugin/plugin_wrapper.go
index 51cb923..8d9997e 100644
--- a/plugin/plugin_wrapper.go
+++ b/pkg/plugin/plugin_wrapper.go
@@ -1,3 +1,5 @@
+//go:build linux
+
 package main
 
 /*


Test patch:
diff --git a/cmd/root_test.go b/cmd/root_test.go
index 2c93aef..ed1734e 100644
--- a/cmd/root_test.go
+++ b/cmd/root_test.go
@@ -3,23 +3,17 @@ package cmd_test
 import (
 	"bufio"
 	"bytes"
-	"crypto/sha256"
-	"fmt"
 	"io"
 	"net"
-	"net/http/httptest"
 	"os"
-	"strings"
 	"syscall"
 	"testing"
 	"time"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/cmd"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
+	"github.com/jkroepke/openvpn-auth-oauth2/pkg/testutils"
 	"github.com/stretchr/testify/assert"
-	"github.com/zitadel/oidc/v2/example/server/storage"
-	"github.com/zitadel/oidc/v2/pkg/op"
-	"golang.org/x/text/language"
 )
 
 func TestExecuteVersion(t *testing.T) {
@@ -60,6 +54,11 @@ func TestExecuteConfigInvalid(t *testing.T) {
 			[]string{"", "--config=../config.example.yaml", "--log.format=console", "--log.level=invalid", "--http.secret=1234567891111213"},
 			`error configure logging: unable to parse log level: slog: level string \"invalid\": unknown name`,
 		},
+		{
+			"error oidc provider",
+			[]string{"", "--config=../config.example.yaml", "--log.format=console", "--log.level=info", "--http.secret=1234567891111213"},
+			`newProviderWithDiscovery`,
+		},
 	}
 
 	for _, tt := range tests {
@@ -78,26 +77,14 @@ func TestExecuteConfigInvalid(t *testing.T) {
 }
 
 func TestExecuteConfigFileFound(t *testing.T) { //nolint: paralleltest
-	opStorage := storage.NewStorage(storage.NewUserStore("http://localhost/"))
-	opConfig := &op.Config{
-		CryptoKey:                sha256.Sum256([]byte("test")),
-		DefaultLogoutRedirectURI: "/",
-		CodeMethodS256:           true,
-		AuthMethodPost:           true,
-		AuthMethodPrivateKeyJWT:  true,
-		GrantTypeRefreshToken:    true,
-		RequestObjectSupported:   true,
-		SupportedUILocales:       []language.Tag{language.English},
-	}
+	clientListener, err := net.Listen("tcp", "127.0.0.1:0")
+	assert.NoError(t, err)
 
-	handler, err := op.NewDynamicOpenIDProvider("", opConfig, opStorage,
-		op.WithAllowInsecure(),
-	)
+	defer clientListener.Close()
 
+	svr, client, err := testutils.SetupResourceServer(clientListener)
 	assert.NoError(t, err)
 
-	svr := httptest.NewServer(handler.HttpHandler())
-
 	l, err := net.Listen("tcp", "127.0.0.1:0")
 	assert.NoError(t, err)
 
@@ -110,12 +97,12 @@ func TestExecuteConfigFileFound(t *testing.T) { //nolint: paralleltest
 		defer conn.Close()
 		reader := bufio.NewReader(conn)
 
-		sendLine(t, conn, ">INFO:OpenVPN Management Interface Version 5 -- type 'help' for more info\r\n")
-		assert.Equal(t, "hold release", readLine(t, reader))
-		sendLine(t, conn, "SUCCESS: hold release succeeded\r\n")
-		assert.Equal(t, "version", readLine(t, reader))
+		testutils.SendLine(t, conn, ">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, conn, "SUCCESS: hold release succeeded\r\n")
+		assert.Equal(t, "version", testutils.ReadLine(t, reader))
 
-		sendLine(t, conn, "OpenVPN Version: OpenVPN Mock\r\nManagement Interface Version: 5\r\nEND\r\n")
+		testutils.SendLine(t, conn, "OpenVPN Version: OpenVPN Mock\r\nManagement Interface Version: 5\r\nEND\r\n")
 
 		time.Sleep(100 * time.Millisecond)
 
@@ -137,6 +124,7 @@ func TestExecuteConfigFileFound(t *testing.T) { //nolint: paralleltest
 		"--http.secret=0123456789101112",
 		"--http.listen=127.0.0.1:0",
 		"--oauth2.issuer", svr.URL,
+		"--oauth2.client.id", client.ID,
 	}
 
 	var buf bytes.Buffer
@@ -145,19 +133,3 @@ func TestExecuteConfigFileFound(t *testing.T) { //nolint: paralleltest
 	returnCode := cmd.Execute(args, &buf, "version", "commit", "date")
 	assert.Equal(t, 0, returnCode, buf.String())
 }
-
-func sendLine(tb testing.TB, conn net.Conn, msg string, a ...any) {
-	tb.Helper()
-
-	_, err := fmt.Fprintf(conn, msg, a...)
-	assert.NoError(tb, err)
-}
-
-func readLine(tb testing.TB, reader *bufio.Reader) string {
-	tb.Helper()
-
-	line, err := reader.ReadString('\n')
-	assert.NoError(tb, err)
-
-	return strings.TrimSpace(line)
-}
diff --git a/internal/http/main_test.go b/internal/http/main_test.go
new file mode 100644
index 0000000..4bd394d
--- /dev/null
+++ b/internal/http/main_test.go
@@ -0,0 +1,105 @@
+package http_test
+
+import (
+	"errors"
+	gohttp "net/http"
+	"net/url"
+	"os"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/http"
+	"github.com/jkroepke/openvpn-auth-oauth2/pkg/testutils"
+	"github.com/madflojo/testcerts"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNewHTTPServer(t *testing.T) {
+	t.Parallel()
+
+	logger := testutils.NewTestLogger()
+
+	cert, key, err := testcerts.GenerateCertsToTempFile("/tmp/")
+	if !assert.NoError(t, err) {
+		return
+	}
+
+	t.Cleanup(func() {
+		assert.NoError(t, os.Remove(key))
+		assert.NoError(t, os.Remove(cert))
+	})
+
+	confs := []struct {
+		name string
+		conf config.Config
+		err  error
+	}{
+		{
+			"http listener",
+			config.Config{
+				HTTP: config.HTTP{
+					BaseURL: &url.URL{Scheme: "http", Host: "127.0.0.1"},
+					Listen:  "127.0.0.1:0",
+				},
+			},
+			nil,
+		},
+		{
+			"https listener invalid",
+			config.Config{
+				HTTP: config.HTTP{
+					BaseURL: &url.URL{Scheme: "http", Host: "127.0.0.1"},
+					Listen:  "127.0.0.1:0",
+					TLS:     true,
+				},
+			},
+			errors.New("ListenAndServeTLS: open : no such file or directory"),
+		},
+		{
+			"https listener",
+			config.Config{
+				HTTP: config.HTTP{
+					BaseURL:  &url.URL{Scheme: "http", Host: "127.0.0.1"},
+					Listen:   "127.0.0.1:0",
+					TLS:      true,
+					KeyFile:  key,
+					CertFile: cert,
+				},
+			},
+			nil,
+		},
+	}
+
+	for _, tt := range confs {
+		tt := tt
+
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			mux := gohttp.NewServeMux()
+			mux.Handle("/", gohttp.NotFoundHandler())
+
+			svr := http.NewHTTPServer(logger, tt.conf, mux)
+
+			wg := sync.WaitGroup{}
+			wg.Add(1)
+
+			go func() {
+				defer wg.Done()
+				err := svr.Listen()
+				if tt.err == nil {
+					assert.NoError(t, err)
+				} else if assert.Error(t, err) {
+					assert.Equal(t, tt.err.Error(), err.Error())
+				}
+			}()
+
+			time.Sleep(50 * time.Millisecond)
+
+			assert.NoError(t, svr.Shutdown())
+			wg.Wait()
+		})
+	}
+}
diff --git a/internal/oauth2/handler_test.go b/internal/oauth2/handler_test.go
index 3ec11b9..bf95fdb 100644
--- a/internal/oauth2/handler_test.go
+++ b/internal/oauth2/handler_test.go
@@ -3,10 +3,8 @@ package oauth2_test
 import (
 	"bufio"
 	"context"
-	"crypto/sha256"
 	"fmt"
 	"io"
-	"log/slog"
 	"net"
 	"net/http"
 	"net/http/cookiejar"
@@ -21,18 +19,14 @@ import (
 	"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/pkg/testutils"
 	"github.com/stretchr/testify/assert"
-	"github.com/zitadel/oidc/v2/example/server/storage"
-	"github.com/zitadel/oidc/v2/pkg/op"
-	"golang.org/x/text/language"
 )
 
-var mu = sync.Mutex{} //nolint:gochecknoglobals
-
 func TestHandler(t *testing.T) {
 	t.Parallel()
 
-	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+	logger := testutils.NewTestLogger()
 
 	tests := []struct {
 		name          string
@@ -40,6 +34,7 @@ func TestHandler(t *testing.T) {
 		ipaddr        string
 		xForwardedFor string
 		allow         bool
+		state         string
 	}{
 		{
 			"default",
@@ -69,6 +64,7 @@ func TestHandler(t *testing.T) {
 			"127.0.0.1",
 			"",
 			true,
+			"-",
 		},
 		{
 			"with ipaddr",
@@ -98,6 +94,7 @@ func TestHandler(t *testing.T) {
 			"127.0.0.1",
 			"",
 			true,
+			"-",
 		},
 		{
 			"with ipaddr + forwarded-for",
@@ -128,6 +125,7 @@ func TestHandler(t *testing.T) {
 			"127.0.0.2",
 			"127.0.0.2",
 			true,
+			"-",
 		},
 		{
 			"with ipaddr + disabled forwarded-for",
@@ -158,6 +156,7 @@ func TestHandler(t *testing.T) {
 			"127.0.0.2",
 			"127.0.0.2",
 			false,
+			"-",
 		},
 		{
 			"with ipaddr + multiple forwarded-for",
@@ -188,6 +187,69 @@ func TestHandler(t *testing.T) {
 			"127.0.0.2",
 			"127.0.0.2, 8.8.8.8",
 			true,
+			"-",
+		},
+		{
+			"with empty state",
+			config.Config{
+				HTTP: config.HTTP{
+					Secret: "0123456789101112",
+					Check: config.HTTPCheck{
+						IPAddr: true,
+					},
+					EnableProxyHeaders: true,
+				},
+				OAuth2: config.OAuth2{
+					Provider:  "generic",
+					Endpoints: config.OAuth2Endpoints{},
+					Scopes:    []string{"openid", "profile"},
+					Validate: config.OAuth2Validate{
+						Groups: make([]string, 0),
+						Roles:  make([]string, 0),
+						Issuer: true,
+						IPAddr: false,
+					},
+				},
+				OpenVpn: config.OpenVpn{
+					Bypass:        config.OpenVpnBypass{CommonNames: []string{}},
+					AuthTokenUser: true,
+				},
+			},
+			"127.0.0.1",
+			"127.0.0.1",
+			true,
+			"",
+		},
+		{
+			"with invalid state",
+			config.Config{
+				HTTP: config.HTTP{
+					Secret: "0123456789101112",
+					Check: config.HTTPCheck{
+						IPAddr: true,
+					},
+					EnableProxyHeaders: true,
+				},
+				OAuth2: config.OAuth2{
+					Provider:  "generic",
+					Endpoints: config.OAuth2Endpoints{},
+					Scopes:    []string{"openid", "profile"},
+					Validate: config.OAuth2Validate{
+						Groups: make([]string, 0),
+						Roles:  make([]string, 0),
+						Issuer: true,
+						IPAddr: false,
+					},
+				},
+				OpenVpn: config.OpenVpn{
+					Bypass:        config.OpenVpnBypass{CommonNames: []string{}},
+					AuthTokenUser: true,
+				},
+			},
+			"127.0.0.1",
+			"127.0.0.1",
+			true,
+			"test",
 		},
 	}
 
@@ -205,7 +267,7 @@ func TestHandler(t *testing.T) {
 			assert.NoError(t, err)
 			defer clientListener.Close()
 
-			resourceServer, clientCredentials, err := setupResourceServer(clientListener)
+			resourceServer, clientCredentials, err := testutils.SetupResourceServer(clientListener)
 			assert.NoError(t, err)
 			defer resourceServer.Close()
 
@@ -238,7 +300,6 @@ func TestHandler(t *testing.T) {
 			go func() {
 				defer wg.Done()
 				defer client.Shutdown()
-				defer httpClientListener.Close()
 
 				conn, err := managementInterface.Accept()
 				assert.NoError(t, err)
@@ -246,22 +307,25 @@ func TestHandler(t *testing.T) {
 				defer conn.Close()
 				reader := bufio.NewReader(conn)
 
-				sendLine(t, conn, ">INFO:OpenVPN Management Interface Version 5 -- type 'help' for more info\r\n")
-				assert.Equal(t, "hold release", readLine(t, reader))
-				sendLine(t, conn, "SUCCESS: hold release succeeded\r\n")
-				assert.Equal(t, "version", readLine(t, reader))
+				testutils.SendLine(t, conn, ">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, conn, "SUCCESS: hold release succeeded\r\n")
+				assert.Equal(t, "version", testutils.ReadLine(t, reader))
 
-				sendLine(t, conn, "OpenVPN Version: OpenVPN Mock\r\nManagement Interface Version: 5\r\nEND\r\n")
+				testutils.SendLine(t, conn, "OpenVPN Version: OpenVPN Mock\r\nManagement Interface Version: 5\r\nEND\r\n")
+				if tt.state != "-" {
+					return
+				}
 
 				if tt.allow {
-					assert.Equal(t, "client-auth 0 1", readLine(t, reader))
-					assert.Equal(t, "push \"auth-token-user aWQx\"", readLine(t, reader))
-					assert.Equal(t, "END", readLine(t, reader))
+					assert.Equal(t, "client-auth 0 1", testutils.ReadLine(t, reader))
+					assert.Equal(t, "push \"auth-token-user aWQx\"", testutils.ReadLine(t, reader))
+					assert.Equal(t, "END", testutils.ReadLine(t, reader))
 				} else {
-					assert.Equal(t, `client-deny 0 1 "http client ip 127.0.0.1 and vpn ip 127.0.0.2 is different."`, readLine(t, reader))
+					assert.Equal(t, `client-deny 0 1 "http client ip 127.0.0.1 and vpn ip 127.0.0.2 is different."`, testutils.ReadLine(t, reader))
 				}
 
-				sendLine(t, conn, "SUCCESS: client-auth command succeeded\r\n")
+				testutils.SendLine(t, conn, "SUCCESS: client-auth command succeeded\r\n")
 			}()
 
 			go func() {
@@ -279,16 +343,20 @@ func TestHandler(t *testing.T) {
 
 			httpClient.Jar = jar
 
-			sessionState := state.New(state.ClientIdentifier{Cid: 0, Kid: 1}, tt.ipaddr, "name")
-			err = sessionState.Encode(tt.conf.HTTP.Secret)
-			if !assert.NoError(t, err) {
-				return
-			}
-
 			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)
+				if !assert.NoError(t, err) {
+					return
+				}
+				session = sessionState.Encoded()
+			}
+
 			request, err := http.NewRequestWithContext(context.Background(), http.MethodGet,
-				fmt.Sprintf("%s/oauth2/start?state=%s", httpClientListener.URL, sessionState.Encoded()),
+				fmt.Sprintf("%s/oauth2/start?state=%s", httpClientListener.URL, session),
 				nil,
 			)
 
@@ -305,6 +373,12 @@ func TestHandler(t *testing.T) {
 				return
 			}
 
+			if tt.state != "-" {
+				assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
+
+				return
+			}
+
 			body, err := io.ReadAll(resp.Body)
 			if !assert.NoError(t, err) {
 				return
@@ -325,53 +399,3 @@ func TestHandler(t *testing.T) {
 		})
 	}
 }
-
-func setupResourceServer(clientListener net.Listener) (*httptest.Server, config.OAuth2Client, error) {
-	mu.Lock()
-	storage.RegisterClients(storage.WebClient(clientListener.Addr().String(), "SECRET", fmt.Sprintf("http://%s/oauth2/callback", clientListener.Addr().String())))
-	mu.Unlock()
-
-	opStorage := storage.NewStorage(storage.NewUserStore("http://localhost"))
-	opConfig := &op.Config{
-		CryptoKey:                sha256.Sum256([]byte("test")),
-		DefaultLogoutRedirectURI: "/",
-		CodeMethodS256:           true,
-		AuthMethodPost:           true,
-		AuthMethodPrivateKeyJWT:  true,
-		GrantTypeRefreshToken:    true,
-		RequestObjectSupported:   true,
-		SupportedUILocales:       []language.Tag{language.English},
-	}
-
-	opProvider, err := op.NewDynamicOpenIDProvider("", opConfig, opStorage,
-		op.WithAllowInsecure(),
-	)
-	if err != nil {
-		return nil, config.OAuth2Client{}, err
-	}
-
-	mux := http.NewServeMux()
-	mux.Handle("/", opProvider.HttpHandler())
-	mux.Handle("/login/username", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		_ = opStorage.CheckUsernamePassword("test-user@localhost", "verysecure", r.FormValue("authRequestID"))
-		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
-}
-
-func sendLine(t *testing.T, conn net.Conn, msg string) {
-	t.Helper()
-
-	_, err := fmt.Fprint(conn, msg)
-	assert.NoError(t, err)
-}
-
-func readLine(t *testing.T, reader *bufio.Reader) string {
-	t.Helper()
-
-	line, err := reader.ReadString('\n')
-	assert.NoError(t, err)
-
-	return strings.TrimSpace(line)
-}
diff --git a/internal/oauth2/provider_test.go b/internal/oauth2/provider_test.go
index 05cba17..ad31c0d 100644
--- a/internal/oauth2/provider_test.go
+++ b/internal/oauth2/provider_test.go
@@ -2,8 +2,6 @@ package oauth2_test
 
 import (
 	"crypto/sha256"
-	"io"
-	"log/slog"
 	"net/http/httptest"
 	"net/url"
 	"strings"
@@ -12,6 +10,7 @@ import (
 	"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/pkg/testutils"
 	"github.com/stretchr/testify/assert"
 	"github.com/zitadel/oidc/v2/example/server/storage"
 	"github.com/zitadel/oidc/v2/pkg/op"
@@ -40,7 +39,7 @@ func TestNewProvider(t *testing.T) {
 	assert.NoError(t, err)
 
 	svr := httptest.NewServer(handler.HttpHandler())
-	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+	logger := testutils.NewTestLogger()
 
 	svrURL, _ := url.Parse(svr.URL)
 
diff --git a/internal/openvpn/main_test.go b/internal/openvpn/main_test.go
index 2ca2af2..ee33d51 100644
--- a/internal/openvpn/main_test.go
+++ b/internal/openvpn/main_test.go
@@ -3,9 +3,6 @@ package openvpn_test
 import (
 	"bufio"
 	"errors"
-	"fmt"
-	"io"
-	"log/slog"
 	"net"
 	"net/url"
 	"regexp"
@@ -16,13 +13,14 @@ import (
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
+	"github.com/jkroepke/openvpn-auth-oauth2/pkg/testutils"
 	"github.com/stretchr/testify/assert"
 )
 
 func TestClientInvalidServer(t *testing.T) {
 	t.Parallel()
 
-	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+	logger := testutils.NewTestLogger()
 	conf := config.Config{
 		HTTP: config.HTTP{
 			BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
@@ -42,7 +40,7 @@ func TestClientInvalidServer(t *testing.T) {
 func TestClientFull(t *testing.T) {
 	t.Parallel()
 
-	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+	logger := testutils.NewTestLogger()
 
 	confs := []struct {
 		name   string
@@ -207,18 +205,18 @@ func TestClientFull(t *testing.T) {
 				reader := bufio.NewReader(conn)
 
 				if tt.conf.OpenVpn.Password != "" {
-					sendLine(t, conn, "ENTER PASSWORD:")
-					assert.Equal(t, tt.conf.OpenVpn.Password, readLine(t, reader))
-					sendLine(t, conn, "SUCCESS: password is correct\r\n")
+					testutils.SendLine(t, conn, "ENTER PASSWORD:")
+					assert.Equal(t, tt.conf.OpenVpn.Password, testutils.ReadLine(t, reader))
+					testutils.SendLine(t, conn, "SUCCESS: password is correct\r\n")
 				}
 
-				sendLine(t, conn, ">INFO:OpenVPN Management Interface Version 5 -- type 'help' for more info\r\n")
-				assert.Equal(t, "hold release", readLine(t, reader))
-				sendLine(t, conn, "SUCCESS: hold release succeeded\r\n")
-				assert.Equal(t, "version", readLine(t, reader))
+				testutils.SendLine(t, conn, ">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, conn, "SUCCESS: hold release succeeded\r\n")
+				assert.Equal(t, "version", testutils.ReadLine(t, reader))
 
-				sendLine(t, conn, "OpenVPN Version: OpenVPN Mock\r\nManagement Interface Version: 5\r\nEND\r\n")
-				sendLine(t, conn, tt.client)
+				testutils.SendLine(t, conn, "OpenVPN Version: OpenVPN Mock\r\nManagement Interface Version: 5\r\nEND\r\n")
+				testutils.SendLine(t, conn, tt.client)
 				if tt.err != nil {
 					_, _ = reader.ReadString('\n')
 
@@ -227,7 +225,7 @@ func TestClientFull(t *testing.T) {
 					return
 				}
 
-				auth := readLine(t, reader)
+				auth := testutils.ReadLine(t, reader)
 
 				if strings.Contains(tt.expect, "WEB_AUTH") {
 					assert.Contains(t, auth, tt.expect)
@@ -235,10 +233,10 @@ func TestClientFull(t *testing.T) {
 					assert.Equal(t, tt.expect, auth)
 				}
 
-				sendLine(t, conn, "SUCCESS: %s command succeeded\r\n", strings.SplitN(auth, " ", 2)[0])
+				testutils.SendLine(t, conn, "SUCCESS: %s command succeeded\r\n", strings.SplitN(auth, " ", 2)[0])
 
 				if strings.Contains(auth, "client-deny") {
-					sendLine(t, conn, ">CLIENT:DISCONNECT,0\r\n>CLIENT:ENV,END\r\n")
+					testutils.SendLine(t, conn, ">CLIENT:DISCONNECT,0\r\n>CLIENT:ENV,END\r\n")
 				} else if strings.Contains(auth, "WEB_AUTH::") {
 					matches := regexp.MustCompile(`state=(.+)"`).FindStringSubmatch(auth)
 					assert.Len(t, matches, 2)
@@ -273,7 +271,7 @@ func TestClientFull(t *testing.T) {
 func TestClientInvalidPassword(t *testing.T) {
 	t.Parallel()
 
-	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+	logger := testutils.NewTestLogger()
 	l, err := net.Listen("tcp", "127.0.0.1:0")
 	assert.NoError(t, err)
 
@@ -300,9 +298,9 @@ func TestClientInvalidPassword(t *testing.T) {
 		defer conn.Close()
 		reader := bufio.NewReader(conn)
 
-		sendLine(t, conn, "ENTER PASSWORD:")
-		assert.Equal(t, conf.OpenVpn.Password, readLine(t, reader))
-		sendLine(t, conn, "ERROR: bad password\r\n")
+		testutils.SendLine(t, conn, "ENTER PASSWORD:")
+		assert.Equal(t, conf.OpenVpn.Password, testutils.ReadLine(t, reader))
+		testutils.SendLine(t, conn, "ERROR: bad password\r\n")
 
 		_, _ = reader.ReadString('\n')
 	}()
@@ -319,7 +317,7 @@ func TestClientInvalidPassword(t *testing.T) {
 func TestClientInvalidVersion(t *testing.T) {
 	t.Parallel()
 
-	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+	logger := testutils.NewTestLogger()
 
 	conf := config.Config{
 		HTTP: config.HTTP{
@@ -374,13 +372,13 @@ func TestClientInvalidVersion(t *testing.T) {
 				defer conn.Close()
 				reader := bufio.NewReader(conn)
 
-				sendLine(t, conn, ">INFO:OpenVPN Management Interface Version 5 -- type 'help' for more info\r\n")
+				testutils.SendLine(t, conn, ">INFO:OpenVPN Management Interface Version 5 -- type 'help' for more info\r\n")
 
-				assert.Equal(t, "hold release", readLine(t, reader))
-				sendLine(t, conn, "SUCCESS: hold release succeeded\r\n")
-				assert.Equal(t, "version", readLine(t, reader))
+				assert.Equal(t, "hold release", testutils.ReadLine(t, reader))
+				testutils.SendLine(t, conn, "SUCCESS: hold release succeeded\r\n")
+				assert.Equal(t, "version", testutils.ReadLine(t, reader))
 
-				sendLine(t, conn, tt.version)
+				testutils.SendLine(t, conn, tt.version)
 			}()
 
 			err = client.Connect()
@@ -391,19 +389,3 @@ func TestClientInvalidVersion(t *testing.T) {
 		})
 	}
 }
-
-func sendLine(tb testing.TB, conn net.Conn, msg string, a ...any) {
-	tb.Helper()
-
-	_, err := fmt.Fprintf(conn, msg, a...)
-	assert.NoError(tb, err)
-}
-
-func readLine(tb testing.TB, reader *bufio.Reader) string {
-	tb.Helper()
-
-	line, err := reader.ReadString('\n')
-	assert.NoError(tb, err)
-
-	return strings.TrimSpace(line)
-}
diff --git a/pkg/testutils/go.mod b/pkg/testutils/go.mod
new file mode 100644
index 0000000..26c5ee7
--- /dev/null
+++ b/pkg/testutils/go.mod
@@ -0,0 +1,34 @@
+module github.com/jkroepke/openvpn-auth-oauth2/pkg/testutils
+
+go 1.21.1
+
+require (
+	github.com/stretchr/testify v1.8.4
+	github.com/zitadel/oidc/v2 v2.11.0
+	golang.org/x/text v0.13.0
+)
+
+require (
+	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/go-logr/logr v1.2.4 // indirect
+	github.com/go-logr/stdr v1.2.2 // indirect
+	github.com/golang/protobuf v1.5.3 // indirect
+	github.com/google/uuid v1.3.1 // indirect
+	github.com/gorilla/mux v1.8.0 // indirect
+	github.com/gorilla/schema v1.2.0 // indirect
+	github.com/gorilla/securecookie v1.1.1 // indirect
+	github.com/kr/text v0.2.0 // indirect
+	github.com/muhlemmer/gu v0.3.1 // indirect
+	github.com/muhlemmer/httpforwarded v0.1.0 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/rs/cors v1.10.1 // indirect
+	go.opentelemetry.io/otel v1.19.0 // indirect
+	go.opentelemetry.io/otel/metric v1.19.0 // indirect
+	go.opentelemetry.io/otel/trace v1.19.0 // indirect
+	golang.org/x/crypto v0.14.0 // indirect
+	golang.org/x/oauth2 v0.13.0 // indirect
+	google.golang.org/appengine v1.6.8 // indirect
+	google.golang.org/protobuf v1.31.0 // indirect
+	gopkg.in/square/go-jose.v2 v2.6.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/pkg/testutils/go.sum b/pkg/testutils/go.sum
new file mode 100644
index 0000000..7388338
--- /dev/null
+++ b/pkg/testutils/go.sum
@@ -0,0 +1,29 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
+github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
+github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
+github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
+github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
+github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
+github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/zitadel/oidc/v2 v2.11.0 h1:Am4/yQr4iiM5bznRgF3FOp+wLdKx2gzSU73uyI9vvBE=
+go.opentelemetry.io/otel v1.19.0 h1:MuS/TNf4/j4IXsZuJegVzI1cwut7Qc00344rgH7p8bs=
+go.opentelemetry.io/otel/metric v1.19.0 h1:aTzpGtV0ar9wlV4Sna9sdJyII5jTVJEvKETPiOKwvpE=
+go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg=
+golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
+golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY=
+golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
+google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
+google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/pkg/testutils/logger.go b/pkg/testutils/logger.go
new file mode 100644
index 0000000..5693a69
--- /dev/null
+++ b/pkg/testutils/logger.go
@@ -0,0 +1,28 @@
+package testutils
+
+import (
+	"context"
+	"log/slog"
+)
+
+type Logger struct{}
+
+func (l Logger) Enabled(_ context.Context, _ slog.Level) bool {
+	return false
+}
+
+func (l Logger) Handle(_ context.Context, _ slog.Record) error {
+	return nil
+}
+
+func (l Logger) WithAttrs(_ []slog.Attr) slog.Handler {
+	return l
+}
+
+func (l Logger) WithGroup(_ string) slog.Handler {
+	return l
+}
+
+func NewTestLogger() *slog.Logger {
+	return slog.New(Logger{})
+}
diff --git a/pkg/testutils/main.go b/pkg/testutils/main.go
new file mode 100644
index 0000000..1d2ebe5
--- /dev/null
+++ b/pkg/testutils/main.go
@@ -0,0 +1,71 @@
+package testutils
+
+import (
+	"bufio"
+	"crypto/sha256"
+	"fmt"
+	"net"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/stretchr/testify/assert"
+	"github.com/zitadel/oidc/v2/example/server/storage"
+	"github.com/zitadel/oidc/v2/pkg/op"
+	"golang.org/x/text/language"
+)
+
+var mu = sync.Mutex{} //nolint:gochecknoglobals
+
+func SendLine(tb testing.TB, conn net.Conn, msg string, a ...any) {
+	tb.Helper()
+
+	_, err := fmt.Fprintf(conn, msg, a...)
+	assert.NoError(tb, err)
+}
+
+func ReadLine(t *testing.T, reader *bufio.Reader) string {
+	t.Helper()
+
+	line, err := reader.ReadString('\n')
+	assert.NoError(t, err)
+
+	return strings.TrimSpace(line)
+}
+
+func SetupResourceServer(clientListener net.Listener) (*httptest.Server, config.OAuth2Client, error) {
+	mu.Lock()
+	storage.RegisterClients(storage.WebClient(clientListener.Addr().String(), "SECRET", fmt.Sprintf("http://%s/oauth2/callback", clientListener.Addr().String())))
+	mu.Unlock()
+
+	opStorage := storage.NewStorage(storage.NewUserStore("http://localhost"))
+	opConfig := &op.Config{
+		CryptoKey:                sha256.Sum256([]byte("test")),
+		DefaultLogoutRedirectURI: "/",
+		CodeMethodS256:           true,
+		AuthMethodPost:           true,
+		AuthMethodPrivateKeyJWT:  true,
+		GrantTypeRefreshToken:    true,
+		RequestObjectSupported:   true,
+		SupportedUILocales:       []language.Tag{language.English},
+	}
+
+	opProvider, err := op.NewDynamicOpenIDProvider("", opConfig, opStorage,
+		op.WithAllowInsecure(),
+	)
+	if err != nil {
+		return nil, config.OAuth2Client{}, err //nolint:wrapcheck
+	}
+
+	mux := http.NewServeMux()
+	mux.Handle("/", opProvider.HttpHandler())
+	mux.Handle("/login/username", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		_ = opStorage.CheckUsernamePassword("test-user@localhost", "verysecure", r.FormValue("authRequestID"))
+		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
+}
diff --git a/tests/docker-compose.yaml b/tests/docker-compose.yaml
index 5fc4305..85f1eca 100644
--- a/tests/docker-compose.yaml
+++ b/tests/docker-compose.yaml
@@ -1,6 +1,7 @@
 version: '3'
 services:
   openvpn:
+    # noinspection ComposeUnknownKeys
     init: true
     image: local/openvpn
     build: