Repository: oauth2-proxy/oauth2-proxy
Pull request #1828 (2022-10-13 11:11:59)

set providerDefaults for oidc consistently
<!--- Provide a general summary of your changes in the Title above -->

## Description
This change calls `p.setProviderDefaults` also in the oidc provider so that any further changes to how a provider should behave is also respected in the oidc provider.

## Motivation and Context

* I was trying to use Microsoft Azure AD with the `oidc` provider since the `azure` provider is using the v1 auth endpoint and I wanted to be a bit future proof and use the v2 auth endpoints with the v2 access token format.
* My usecase also includes CLIs and non human users to authenticate by passing an accessToken as `Authorization: Bearer $TOKEN` and configuring `oauth2-proxy` with `--skip-jwt-bearer-tokens`.
* If the accessToken is from a Service Account (so an Azure AD App) it does not contain the email claim... as it does not have one
* I want to use `--pass-user-headers` / `--set-xauthrequest` so that the application I want to protect can create the users and also set a mail if available
  * currently `Forwarded-User` / `X-Auth-Request-User` is never set since UserClaim is never set
  * trying to use the deprecated option `--user-id-claim` (or doing equivalent changes in `alphaConfig`) changes `X-Auth-Request-Email` due to https://github.com/oauth2-proxy/oauth2-proxy/blob/ce750e9b301ac66b3b8cbdbd0bc8ac7415bfed79/providers/providers.go#L148..L153

## How Has This Been Tested?

I have basically applied the changes and checked for the wanted result for my usecase only. Sadly X-Auth-Request-Email will always be set even without email but that is better than not having any headers for Service Accounts.

## Checklist:

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My change requires a change to the documentation or CHANGELOG.
- [x] I have updated the documentation/CHANGELOG accordingly.
- [x] I have created a feature (non-master) branch for my PR.

## See also:
* https://github.com/oauth2-proxy/oauth2-proxy/pull/1579



Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index cb1f8f4e..973d1abf 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -20,15 +20,15 @@ jobs:
     - name: Check out code
       uses: actions/checkout@v2
 
-    - name: Set up Go 1.17
+    - name: Set up Go 1.19
       uses: actions/setup-go@v2
       with:
-        go-version: 1.17.x
+        go-version: 1.19.x
       id: go
 
     - name: Get dependencies
       run: |
-        curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.36.0
+        curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.50.0
         curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
         chmod +x ./cc-test-reporter
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ed00ca21..63c0de79 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,12 +8,18 @@
   - Having a unique CSRF cookie per request can lead to quite a number of cookies, in case an application performs a high number of parallel authentication requests. Each call will redirect to /oauth2/start, if the user is not authenticated, and a new cookie will be set. The successfully authenticated requests will have its CSRF cookies immediatly expired, however the failed ones will mantain its CSRF cookies until they expire (by default in 15 minutes). 
   - The user may redefine the CSRF cookie expiration time using flag "--cookie-csrf-expire" (e.g. --cookie-csrf-expire=5m). By default, it is 15 minutes, but you can fine tune to your environment.
 
+- [#1574](https://github.com/oauth2-proxy/oauth2-proxy/pull/1574) Add Azure groups support and Azure OAuth v2.0 (@adriananeci)
+  - group membership check is now validated while using the the azure provider.
+  - Azure OAuth v2.0 (https://login.microsoftonline.com/{tenant_id}/v2.0) is now available along with Azure OAuth v1.0. See https://github.com/oauth2-proxy/oauth2-proxy/blob/master/docs/docs/configuration/auth.md#azure-auth-provider for more details
+  - When using v2.0 Azure Auth endpoint (`https://login.microsoftonline.com/{tenant-id}/v2.0`) as `--oidc_issuer_url`, in conjunction with `--resource` flag, be sure to append `/.default` at the end of the resource name. See https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#the-default-scope for more details.
+
 ## Breaking Changes
 
 N/A
 
 ## Changes since v7.3.0
-
+- [#1828](https://github.com/oauth2-proxy/oauth2-proxy/pull/1828) call providerData.setProviderDefaults for oidc provider to achieve consistent behaviour (@centzilius)
+  - UserClaim will be set to sub instead of beeing empty from now on.
 - [#1691](https://github.com/oauth2-proxy/oauth2-proxy/pull/1691) Fix Redis IdleTimeout when Redis timeout option is set to non-zero (@dimss)
 - [#1669](https://github.com/oauth2-proxy/oauth2-proxy/pull/1699) Fix method deprecated error in lint (@t-katsumura)
 - [#1701](https://github.com/oauth2-proxy/oauth2-proxy/pull/1701) Watch the htpasswd file for changes and update the htpasswdMap (@aiciobanu)
@@ -33,6 +39,8 @@ to remain consistent with CLI flags. You should specify `code_challenge_method`
 - [#1760](https://github.com/oauth2-proxy/oauth2-proxy/pull/1760) Option to configure API routes
 - [#1825](https://github.com/oauth2-proxy/oauth2-proxy/pull/1825) Fix vulnerabilities CVE-2022-32149 and CVE-2022-27664. (@crbednarz)
 - [#1750](https://github.com/oauth2-proxy/oauth2-proxy/pull/1750) Fix Nextcloud provider
+- [#1574](https://github.com/oauth2-proxy/oauth2-proxy/pull/1574) Add Azure groups support and Azure OAuth v2.0 (@adriananeci)
+- [#1851](https://github.com/oauth2-proxy/oauth2-proxy/pull/1851) Bump golang to 1.19 and min allowed version to 1.18
 
 # V7.3.0
 
diff --git a/Dockerfile b/Dockerfile
index 207aed43..847c7415 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,7 +5,7 @@ ARG RUNTIME_IMAGE=alpine:3.16
 #  cache sharing of the go mod download step.
 # Go cross compilation is also faster than emulation the go compilation across
 #  multiple platforms.
-FROM --platform=${BUILDPLATFORM} golang:1.18-buster AS builder
+FROM --platform=${BUILDPLATFORM} golang:1.19-buster AS builder
 
 # Copy sources
 WORKDIR $GOPATH/src/github.com/oauth2-proxy/oauth2-proxy
diff --git a/Makefile b/Makefile
index 6bf7b193..7ad59551 100644
--- a/Makefile
+++ b/Makefile
@@ -10,7 +10,7 @@ REGISTRY ?= quay.io/oauth2-proxy
 GO_MAJOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f1)
 GO_MINOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f2)
 MINIMUM_SUPPORTED_GO_MAJOR_VERSION = 1
-MINIMUM_SUPPORTED_GO_MINOR_VERSION = 15
+MINIMUM_SUPPORTED_GO_MINOR_VERSION = 18
 GO_VERSION_VALIDATION_ERR_MSG = Golang version is not supported, please update to at least $(MINIMUM_SUPPORTED_GO_MAJOR_VERSION).$(MINIMUM_SUPPORTED_GO_MINOR_VERSION)
 
 ifeq ($(COVER),true)
diff --git a/docs/docs/configuration/alpha_config.md b/docs/docs/configuration/alpha_config.md
index ce8357b4..84dac78d 100644
--- a/docs/docs/configuration/alpha_config.md
+++ b/docs/docs/configuration/alpha_config.md
@@ -164,6 +164,7 @@ They may change between releases without notice.
 | Field | Type | Description |
 | ----- | ---- | ----------- |
 | `tenant` | _string_ | Tenant directs to a tenant-specific or common (tenant-independent) endpoint<br/>Default value is 'common' |
+| `graphGroupField` | _string_ | GraphGroupField configures the group field to be used when building the groups list from Microsoft Graph<br/>Default value is 'id' |
 
 ### BitbucketOptions
 
@@ -306,7 +307,7 @@ the caller provides it, and no value will be sent otherwise.
 
 Examples:
 
-A parameter whose value is fixed
+# A parameter whose value is fixed
 
 ```
 name: organization
@@ -354,8 +355,9 @@ as backslash is not considered to be an escape character.  Alternatively
 use the "chomped block" format `|-`:
 
 ```
-- pattern: |-
+  - pattern: |-
     ^[^@]*@example\.com$
+
 ```
 
 The hyphen is important, a `|` block would have a trailing newline
diff --git a/docs/docs/configuration/auth.md b/docs/docs/configuration/auth.md
index 29b0d70a..4e9b41eb 100644
--- a/docs/docs/configuration/auth.md
+++ b/docs/docs/configuration/auth.md
@@ -72,23 +72,45 @@ Note: The user is checked against the group members list on initial authenticati
 
 ### Azure Auth Provider
 
-1. Add an application: go to [https://portal.azure.com](https://portal.azure.com), choose **"Azure Active Directory"** in the left menu, select **"App registrations"** and then click on **"New app registration"**.
-2. Pick a name and choose **"Webapp / API"** as application type. Use `https://internal.yourcompany.com` as Sign-on URL. Click **"Create"**.
-3. On the **"Settings"** / **"Properties"** page of the app, pick a logo and select **"Multi-tenanted"** if you want to allow users from multiple organizations to access your app. Note down the application ID. Click **"Save"**.
-4. On the **"Settings"** / **"API Permissions"** page of the app, click on **"Add a permission"**, then select **"Microsoft Graph"**, then **"Delegated permissions"** and finally check the **"openid (Sign users in)"** permission. Hit **"Save"** and then on **"Grant permissions"** (you might need another admin to do this).
+1. Add an application: go to [https://portal.azure.com](https://portal.azure.com), choose **Azure Active Directory**, select 
+**App registrations** and then click on **New registration**.
+2. Pick a name, check the supported account type(single-tenant, multi-tenant, etc). In the **Redirect URI** section create a new 
+**Web** platform entry for each app that you want to protect by the oauth2 proxy(e.g. 
+https://internal.yourcompanycom/oauth2/callback). Click **Register**.
+3. Next we need to add group read permissions for the app registration, on the **API Permissions** page of the app, click on
+**Add a permission**, select **Microsoft Graph**, then select **Application permissions**, then click on **Group** and select
+**Group.Read.All**. Hit **Add permissions** and then on **Grant admin consent** (you might need an admin to do this).
 <br/>**IMPORTANT**: Even if this permission is listed with **"Admin consent required=No"** the consent might actually be required, due to AAD policies you won't be able to see. If you get a **"Need admin approval"** during login, most likely this is what you're missing!
-5. On the **"Settings"** / **"Reply URLs"** page of the app, add `https://internal.yourcompanycom/oauth2/callback` for each host that you want to protect by the oauth2 proxy. Click **"Save"**.
-6. On the **"Settings"** / **"Keys"** page of the app, add a new key and note down the value after hitting **"Save"**.
-7. Configure the proxy with
+4. Next, if you are planning to use v2.0 Azure Auth endpoint, go to the **Manifest** page and set `"accessTokenAcceptedVersion": 2`
+in the App registration manifest file.
+5. On the **Certificates & secrets** page of the app, add a new client secret and note down the value after hitting **Add**.
+6. Configure the proxy with:
+- for V1 Azure Auth endpoint (Azure Active Directory Endpoints - https://login.microsoftonline.com/common/oauth2/authorize)
 
 ```
    --provider=azure
    --client-id=<application ID from step 3>
-   --client-secret=<value from step 6>
+   --client-secret=<value from step 5>
+   --azure-tenant={tenant-id}
    --oidc-issuer-url=https://sts.windows.net/{tenant-id}/
 ```
 
-Note: When using the Azure Auth provider with nginx and the cookie session store you may find the cookie is too large and doesn't get passed through correctly. Increasing the proxy_buffer_size in nginx or implementing the [redis session storage](sessions.md#redis-storage) should resolve this.
+- for V2 Azure Auth endpoint (Microsoft Identity Platform Endpoints - https://login.microsoftonline.com/common/oauth2/v2.0/authorize)
+```
+   --provider=azure
+   --client-id=<application ID from step 3>
+   --client-secret=<value from step 5>
+   --azure-tenant={tenant-id}
+   --oidc-issuer-url=https://login.microsoftonline.com/{tenant-id}/v2.0
+```
+
+***Notes***:
+- When using v2.0 Azure Auth endpoint (`https://login.microsoftonline.com/{tenant-id}/v2.0`) as `--oidc_issuer_url`, in conjunction 
+with `--resource` flag, be sure to append `/.default` at the end of the resource name. See
+https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#the-default-scope for more details.
+- When using the Azure Auth provider with nginx and the cookie session store you may find the cookie is too large and doesn't 
+get passed through correctly. Increasing the proxy_buffer_size in nginx or implementing the [redis session storage](sessions.md#redis-storage) 
+should resolve this.
 
 ### ADFS Auth Provider
 
diff --git a/docs/docs/features/endpoints.md b/docs/docs/features/endpoints.md
index 61e1107b..bacb5439 100644
--- a/docs/docs/features/endpoints.md
+++ b/docs/docs/features/endpoints.md
@@ -33,7 +33,7 @@ X-Auth-Request-Redirect: https://my-oidc-provider/sign_out_page
 
 (The "sign_out_page" should be the [`end_session_endpoint`](https://openid.net/specs/openid-connect-session-1_0.html#rfc.section.2.1) from [the metadata](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig) if your OIDC provider supports Session Management and Discovery.)
 
-BEWARE that the domain you want to redirect to (`my-oidc-provider.example.com` in the example) must be added to the [`--whitelist-domain`](../configuration/overview) configuration option otherwise the redirect will be ignored.
+BEWARE that the domain you want to redirect to (`my-oidc-provider.example.com` in the example) must be added to the [`--whitelist-domain`](../configuration/overview) configuration option otherwise the redirect will be ignored. Make sure to include the actual domain and port (if needed) and not the URL (e.g "localhost:8081" instead of "http://localhost:8081").
 
 ### Auth
 
diff --git a/go.mod b/go.mod
index ee8b8b6b..241520a4 100644
--- a/go.mod
+++ b/go.mod
@@ -30,6 +30,7 @@ require (
 	github.com/stretchr/testify v1.7.0
 	github.com/vmihailenco/msgpack/v4 v4.3.11
 	golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b
+	golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d
 	golang.org/x/net v0.0.0-20221012135044-0b7e1fb9d458
 	golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
 	golang.org/x/sync v0.0.0-20201207232520-09787c993a3a
@@ -70,7 +71,7 @@ require (
 	go.opentelemetry.io/otel v0.11.0 // indirect
 	golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
 	golang.org/x/text v0.3.8 // indirect
-	golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect
+	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 	google.golang.org/appengine v1.6.5 // indirect
 	google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 // indirect
 	google.golang.org/grpc v1.27.0 // indirect
diff --git a/go.sum b/go.sum
index 5572e25f..5c25c2d8 100644
--- a/go.sum
+++ b/go.sum
@@ -132,8 +132,8 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
@@ -318,6 +318,8 @@ golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20200908183739-ae8ad444f925/go.mod h1:1phAWC201xIgDyaFpmDeZkgf70Q4Pd/CNqfRtVPtxNw=
+golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d h1:vtUKgx8dahOomfFzLREU8nSv25YHnTgLBn4rDnWZdU0=
+golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA=
 golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
 golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -422,8 +424,9 @@ golang.org/x/tools v0.0.0-20200505023115-26f46d2f7ef8/go.mod h1:EkVYQZoAsY45+roY
 golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
 google.golang.org/api v0.20.0 h1:jz2KixHX7EcCPiQrySzPdnYT7DbINAypCqKZ1Z7GM40=
 google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go
index 2be960f9..3de9786b 100644
--- a/pkg/apis/options/legacy_options.go
+++ b/pkg/apis/options/legacy_options.go
@@ -483,6 +483,7 @@ type LegacyProvider struct {
 
 	KeycloakGroups           []string `flag:"keycloak-group" cfg:"keycloak_groups"`
 	AzureTenant              string   `flag:"azure-tenant" cfg:"azure_tenant"`
+	AzureGraphGroupField     string   `flag:"azure-graph-group-field" cfg:"azure_graph_group_field"`
 	BitbucketTeam            string   `flag:"bitbucket-team" cfg:"bitbucket_team"`
 	BitbucketRepository      string   `flag:"bitbucket-repository" cfg:"bitbucket_repository"`
 	GitHubOrg                string   `flag:"github-org" cfg:"github_org"`
@@ -538,6 +539,7 @@ func legacyProviderFlagSet() *pflag.FlagSet {
 
 	flagSet.StringSlice("keycloak-group", []string{}, "restrict logins to members of these groups (may be given multiple times)")
 	flagSet.String("azure-tenant", "common", "go to a tenant-specific or common (tenant-independent) endpoint.")
+	flagSet.String("azure-graph-group-field", "", "configures the group field to be used when building the groups list(`id` or `displayName`. Default is `id`) from Microsoft Graph(available only for v2.0 oidc url). Based on this value, the `allowed-group` config values should be adjusted accordingly. If using `id` as group field, `allowed-group` should contains groups IDs, if using `displayName` as group field, `allowed-group` should contains groups name")
 	flagSet.String("bitbucket-team", "", "restrict logins to members of this team")
 	flagSet.String("bitbucket-repository", "", "restrict logins to user with access to this repository")
 	flagSet.String("github-org", "", "restrict logins to members of this organisation")
@@ -676,7 +678,8 @@ func (l *LegacyProvider) convert() (Providers, error) {
 	// This part is out of the switch section because azure has a default tenant
 	// that needs to be added from legacy options
 	provider.AzureConfig = AzureOptions{
-		Tenant: l.AzureTenant,
+		Tenant:          l.AzureTenant,
+		GraphGroupField: l.AzureGraphGroupField,
 	}
 
 	switch provider.Type {
diff --git a/pkg/apis/options/load.go b/pkg/apis/options/load.go
index 9ead8a3b..ec39cd5c 100644
--- a/pkg/apis/options/load.go
+++ b/pkg/apis/options/load.go
@@ -3,7 +3,7 @@ package options
 import (
 	"errors"
 	"fmt"
-	"io/ioutil"
+	"os"
 	"reflect"
 	"strings"
 
@@ -17,7 +17,9 @@ import (
 // variables (prefixed with `OAUTH2_PROXY`) and finally merges in flags from the flagSet.
 // If a config value is unset and the flag has a non-zero value default, this default will be used.
 // Eg. A field defined:
-//    FooBar `cfg:"foo_bar" flag:"foo-bar"`
+//
+//	FooBar `cfg:"foo_bar" flag:"foo-bar"`
+//
 // Can be set in the config file as `foo_bar="baz"`, in the environment as `OAUTH2_PROXY_FOO_BAR=baz`,
 // or via the command line flag `--foo-bar=baz`.
 func Load(configFileName string, flagSet *pflag.FlagSet, into interface{}) error {
@@ -147,7 +149,7 @@ func LoadYAML(configFileName string, into interface{}) error {
 		return errors.New("no configuration file provided")
 	}
 
-	data, err := ioutil.ReadFile(configFileName)
+	data, err := os.ReadFile(configFileName)
 	if err != nil {
 		return fmt.Errorf("unable to load config file: %w", err)
 	}
diff --git a/pkg/apis/options/login_url_parameters.go b/pkg/apis/options/login_url_parameters.go
index 2ebe550a..1cb763b9 100644
--- a/pkg/apis/options/login_url_parameters.go
+++ b/pkg/apis/options/login_url_parameters.go
@@ -14,7 +14,7 @@ package options
 //
 // Examples:
 //
-// A parameter whose value is fixed
+// # A parameter whose value is fixed
 //
 // ```
 // name: organization
@@ -62,8 +62,9 @@ package options
 // use the "chomped block" format `|-`:
 //
 // ```
-// - pattern: |-
+//   - pattern: |-
 //     ^[^@]*@example\.com$
+//
 // ```
 //
 // The hyphen is important, a `|` block would have a trailing newline
diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go
index c5d83c23..b559e438 100644
--- a/pkg/apis/options/providers.go
+++ b/pkg/apis/options/providers.go
@@ -142,6 +142,9 @@ type AzureOptions struct {
 	// Tenant directs to a tenant-specific or common (tenant-independent) endpoint
 	// Default value is 'common'
 	Tenant string `json:"tenant,omitempty"`
+	// GraphGroupField configures the group field to be used when building the groups list from Microsoft Graph
+	// Default value is 'id'
+	GraphGroupField string `json:"graphGroupField,omitempty"`
 }
 
 type ADFSOptions struct {
diff --git a/pkg/apis/options/util/util.go b/pkg/apis/options/util/util.go
index 99988bdc..03f0a134 100644
--- a/pkg/apis/options/util/util.go
+++ b/pkg/apis/options/util/util.go
@@ -2,7 +2,6 @@ package util
 
 import (
 	"errors"
-	"io/ioutil"
 	"os"
 
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
@@ -16,7 +15,7 @@ func GetSecretValue(source *options.SecretSource) ([]byte, error) {
 	case len(source.Value) == 0 && source.FromEnv != "" && source.FromFile == "":
 		return []byte(os.Getenv(source.FromEnv)), nil
 	case len(source.Value) == 0 && source.FromEnv == "" && source.FromFile != "":
-		return ioutil.ReadFile(source.FromFile)
+		return os.ReadFile(source.FromFile)
 	default:
 		return nil, errors.New("secret source is invalid: exactly one entry required, specify either value, fromEnv or fromFile")
 	}
diff --git a/pkg/apis/sessions/session_state.go b/pkg/apis/sessions/session_state.go
index 08538dae..a69d03df 100644
--- a/pkg/apis/sessions/session_state.go
+++ b/pkg/apis/sessions/session_state.go
@@ -5,7 +5,6 @@ import (
 	"context"
 	"fmt"
 	"io"
-	"io/ioutil"
 	"time"
 
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/clock"
@@ -225,7 +224,7 @@ func lz4Compress(payload []byte) ([]byte, error) {
 		return nil, fmt.Errorf("error closing lz4 writer: %w", err)
 	}
 
-	compressed, err := ioutil.ReadAll(buf)
+	compressed, err := io.ReadAll(buf)
 	if err != nil {
 		return nil, fmt.Errorf("error reading lz4 buffer: %w", err)
 	}
@@ -244,7 +243,7 @@ func lz4Decompress(compressed []byte) ([]byte, error) {
 		return nil, fmt.Errorf("error copying lz4 stream to buffer: %w", err)
 	}
 
-	payload, err := ioutil.ReadAll(buf)
+	payload, err := io.ReadAll(buf)
 	if err != nil {
 		return nil, fmt.Errorf("error reading lz4 buffer: %w", err)
 	}
diff --git a/pkg/http/server.go b/pkg/http/server.go
index 9daadf39..1771dc93 100644
--- a/pkg/http/server.go
+++ b/pkg/http/server.go
@@ -186,7 +186,7 @@ func (s *server) Start(ctx context.Context) error {
 // When the given context is cancelled the server will be shutdown.
 // If any errors occur, only the first error will be returned.
 func (s *server) startServer(ctx context.Context, listener net.Listener) error {
-	srv := &http.Server{Handler: s.handler}
+	srv := &http.Server{Handler: s.handler, ReadHeaderTimeout: time.Minute}
 	g, groupCtx := errgroup.WithContext(ctx)
 
 	g.Go(func() error {
diff --git a/pkg/providers/util/claim_extractor.go b/pkg/providers/util/claim_extractor.go
index 883cc64f..c2bd3d1b 100644
--- a/pkg/providers/util/claim_extractor.go
+++ b/pkg/providers/util/claim_extractor.go
@@ -98,7 +98,7 @@ func (c *claimExtractor) loadProfileClaims() (*simplejson.Json, error) {
 		WithContext(c.ctx).
 		WithHeaders(c.requestHeaders).
 		Do().
-		UnmarshalJSON()
+		UnmarshalSimpleJSON()
 	if err != nil {
 		return nil, fmt.Errorf("error making request to profile URL: %v", err)
 	}
diff --git a/pkg/requests/builder.go b/pkg/requests/builder.go
index 95d88101..a9b084dc 100644
--- a/pkg/requests/builder.go
+++ b/pkg/requests/builder.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"fmt"
 	"io"
-	"io/ioutil"
 	"net/http"
 )
 
@@ -107,7 +106,7 @@ func (r *builder) do() Result {
 	}
 
 	defer resp.Body.Close()
-	body, err := ioutil.ReadAll(resp.Body)
+	body, err := io.ReadAll(resp.Body)
 	if err != nil {
 		r.result = &result{err: fmt.Errorf("error reading response body: %v", err)}
 		return r.result
diff --git a/pkg/requests/result.go b/pkg/requests/result.go
index 305fefe2..70a8b334 100644
--- a/pkg/requests/result.go
+++ b/pkg/requests/result.go
@@ -15,7 +15,7 @@ type Result interface {
 	Headers() http.Header
 	Body() []byte
 	UnmarshalInto(interface{}) error
-	UnmarshalJSON() (*simplejson.Json, error)
+	UnmarshalSimpleJSON() (*simplejson.Json, error)
 }
 
 type result struct {
@@ -66,10 +66,10 @@ func (r *result) UnmarshalInto(into interface{}) error {
 	return nil
 }
 
-// UnmarshalJSON performs the request and attempts to unmarshal the response into a
+// UnmarshalSimpleJSON performs the request and attempts to unmarshal the response into a
 // simplejson.Json. The response body is assume to be JSON.
 // The response must have a 200 status otherwise an error will be returned.
-func (r *result) UnmarshalJSON() (*simplejson.Json, error) {
+func (r *result) UnmarshalSimpleJSON() (*simplejson.Json, error) {
 	body, err := r.getBodyForUnmarshal()
 	if err != nil {
 		return nil, err
diff --git a/pkg/sessions/redis/redis_store.go b/pkg/sessions/redis/redis_store.go
index 1243c7c5..9b400cd0 100644
--- a/pkg/sessions/redis/redis_store.go
+++ b/pkg/sessions/redis/redis_store.go
@@ -5,7 +5,7 @@ import (
 	"crypto/tls"
 	"crypto/x509"
 	"fmt"
-	"io/ioutil"
+	"os"
 	"time"
 
 	"github.com/go-redis/redis/v8"
@@ -170,7 +170,7 @@ func setupTLSConfig(opts options.RedisStoreOptions, opt *redis.Options) error {
 		if rootCAs == nil {
 			rootCAs = x509.NewCertPool()
 		}
-		certs, err := ioutil.ReadFile(opts.CAPath)
+		certs, err := os.ReadFile(opts.CAPath)
 		if err != nil {
 			return fmt.Errorf("failed to load %q, %v", opts.CAPath, err)
 		}
diff --git a/pkg/util/util.go b/pkg/util/util.go
index d8c1fc06..a4425d9b 100644
--- a/pkg/util/util.go
+++ b/pkg/util/util.go
@@ -6,10 +6,10 @@ import (
 	"crypto/x509"
 	"crypto/x509/pkix"
 	"fmt"
-	"io/ioutil"
 	"math/big"
 	"net"
 	"net/url"
+	"os"
 	"strings"
 	"time"
 )
@@ -21,7 +21,7 @@ func GetCertPool(paths []string) (*x509.CertPool, error) {
 	pool := x509.NewCertPool()
 	for _, path := range paths {
 		// Cert paths are a configurable option
-		data, err := ioutil.ReadFile(path) // #nosec G304
+		data, err := os.ReadFile(path) // #nosec G304
 		if err != nil {
 			return nil, fmt.Errorf("certificate authority file (%s) could not be read - %s", path, err)
 		}
@@ -149,3 +149,16 @@ func isHostnameAllowed(hostname, allowedHost string) bool {
 
 	return false
 }
+
+// RemoveDuplicateStr removes duplicates from a slice of strings.
+func RemoveDuplicateStr(strSlice []string) []string {
+	allKeys := make(map[string]struct{})
+	var list []string
+	for _, item := range strSlice {
+		if _, ok := allKeys[item]; !ok {
+			allKeys[item] = struct{}{}
+			list = append(list, item)
+		}
+	}
+	return list
+}
diff --git a/pkg/validation/providers.go b/pkg/validation/providers.go
index 587b9289..cb84ad90 100644
--- a/pkg/validation/providers.go
+++ b/pkg/validation/providers.go
@@ -2,7 +2,6 @@ package validation
 
 import (
 	"fmt"
-	"io/ioutil"
 	"os"
 
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
@@ -53,7 +52,7 @@ func validateProvider(provider options.Provider, providerIDs map[string]struct{}
 			msgs = append(msgs, "missing setting: client-secret or client-secret-file")
 		}
 		if provider.ClientSecret == "" && provider.ClientSecretFile != "" {
-			_, err := ioutil.ReadFile(provider.ClientSecretFile)
+			_, err := os.ReadFile(provider.ClientSecretFile)
 			if err != nil {
 				msgs = append(msgs, "could not read client secret file: "+provider.ClientSecretFile)
 			}
diff --git a/pkg/watcher/watcher.go b/pkg/watcher/watcher.go
index 6a8a60d5..b609e969 100644
--- a/pkg/watcher/watcher.go
+++ b/pkg/watcher/watcher.go
@@ -44,8 +44,8 @@ func WatchFileForUpdates(filename string, done <-chan bool, action func()) error
 
 // Filter file operations based on the events sent by the watcher.
 // Execute the action() function when the following conditions are met:
-//  - the real path of the file was changed (Kubernetes ConfigMap/Secret)
-//  - the file is modified or created
+//   - the real path of the file was changed (Kubernetes ConfigMap/Secret)
+//   - the file is modified or created
 func filterEvent(watcher *fsnotify.Watcher, event fsnotify.Event, filename string, action func()) {
 	switch filepath.Clean(event.Name) == filename {
 	// In Kubernetes the file path is a symlink, so we should take action
diff --git a/providers/azure.go b/providers/azure.go
index 48d55ec2..a3e80f3f 100644
--- a/providers/azure.go
+++ b/providers/azure.go
@@ -3,63 +3,60 @@ package providers
 import (
 	"bytes"
 	"context"
-	"errors"
 	"fmt"
 	"net/http"
 	"net/url"
+	"strings"
 	"time"
 
+	"golang.org/x/exp/slices"
+
 	"github.com/bitly/go-simplejson"
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
+	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
 )
 
 // AzureProvider represents an Azure based Identity Provider
 type AzureProvider struct {
 	*ProviderData
-	Tenant string
+	Tenant          string
+	GraphGroupField string
+	isV2Endpoint    bool
 }
 
 var _ Provider = (*AzureProvider)(nil)
 
 const (
-	azureProviderName = "Azure"
-	azureDefaultScope = "openid"
+	azureProviderName           = "Azure"
+	azureDefaultScope           = "openid"
+	azureDefaultGraphGroupField = "id"
+	azureV2Scope                = "https://graph.microsoft.com/.default"
 )
 
 var (
-	// Default Login URL for Azure.
-	// Pre-parsed URL of https://login.microsoftonline.com/common/oauth2/authorize.
+	// Default Login URL for Azure. Pre-parsed URL of https://login.microsoftonline.com/common/oauth2/authorize.
 	azureDefaultLoginURL = &url.URL{
 		Scheme: "https",
 		Host:   "login.microsoftonline.com",
 		Path:   "/common/oauth2/authorize",
 	}
 
-	// Default Redeem URL for Azure.
-	// Pre-parsed URL of https://login.microsoftonline.com/common/oauth2/token.
+	// Default Redeem URL for Azure. Pre-parsed URL of https://login.microsoftonline.com/common/oauth2/token.
 	azureDefaultRedeemURL = &url.URL{
 		Scheme: "https",
 		Host:   "login.microsoftonline.com",
 		Path:   "/common/oauth2/token",
 	}
 
-	// Default Profile URL for Azure.
-	// Pre-parsed URL of https://graph.microsoft.com/v1.0/me.
+	// Default Profile URL for Azure. Pre-parsed URL of https://graph.microsoft.com/v1.0/me.
 	azureDefaultProfileURL = &url.URL{
 		Scheme: "https",
 		Host:   "graph.microsoft.com",
 		Path:   "/v1.0/me",
 	}
-
-	// Default ProtectedResource URL for Azure.
-	// Pre-parsed URL of https://graph.microsoft.com.
-	azureDefaultProtectResourceURL = &url.URL{
-		Scheme: "https",
-		Host:   "graph.microsoft.com",
-	}
 )
 
 // NewAzureProvider initiates a new AzureProvider
@@ -73,9 +70,6 @@ func NewAzureProvider(p *ProviderData, opts options.AzureOptions) *AzureProvider
 		scope:       azureDefaultScope,
 	})
 
-	if p.ProtectedResource == nil || p.ProtectedResource.String() == "" {
-		p.ProtectedResource = azureDefaultProtectResourceURL
-	}
 	if p.ValidateURL == nil || p.ValidateURL.String() == "" {
 		p.ValidateURL = p.ProfileURL
 	}
@@ -88,9 +82,35 @@ func NewAzureProvider(p *ProviderData, opts options.AzureOptions) *AzureProvider
 		overrideTenantURL(p.RedeemURL, azureDefaultRedeemURL, tenant, "token")
 	}
 
+	graphGroupField := azureDefaultGraphGroupField
+	if opts.GraphGroupField != "" {
+		graphGroupField = opts.GraphGroupField
+	}
+
+	isV2Endpoint := false
+	if strings.Contains(p.LoginURL.String(), "v2.0") {
+		isV2Endpoint = true
+
+		if strings.Contains(p.Scope, " groups") {
+			logger.Print("WARNING: `groups` scope is not an accepted scope when using Azure OAuth V2 endpoint. Removing it from the scope list")
+			p.Scope = strings.ReplaceAll(p.Scope, " groups", "")
+		}
+
+		if !strings.Contains(p.Scope, " "+azureV2Scope) {
+			// In order to be able to query MS Graph we must pass the ms graph default endpoint
+			p.Scope += " " + azureV2Scope
+		}
+
+		if p.ProtectedResource != nil && p.ProtectedResource.String() != "" {
+			logger.Print("WARNING: `--resource` option has no effect when using the Azure OAuth V2 endpoint.")
+		}
+	}
+
 	return &AzureProvider{
-		ProviderData: p,
-		Tenant:       tenant,
+		ProviderData:    p,
+		Tenant:          tenant,
+		GraphGroupField: graphGroupField,
+		isV2Endpoint:    isV2Endpoint,
 	}
 }
 
@@ -103,8 +123,26 @@ func overrideTenantURL(current, defaultURL *url.URL, tenant, path string) {
 	}
 }
 
+func getMicrosoftGraphGroupsURL(graphGroupField string) *url.URL {
+
+	selectStatement := "$select=displayName,id"
+	if !slices.Contains([]string{"displayName", "id"}, graphGroupField) {
+		selectStatement += "," + graphGroupField
+	}
+
+	// Select only security groups. Due to the filter option, count param is mandatory even if unused otherwise
+	return &url.URL{
+		Scheme:   "https",
+		Host:     "graph.microsoft.com",
+		Path:     "/v1.0/me/transitiveMemberOf",
+		RawQuery: "$count=true&$filter=securityEnabled+eq+true&" + selectStatement,
+	}
+}
+
 func (p *AzureProvider) GetLoginURL(redirectURI, state, _ string, extraParams url.Values) string {
-	if p.ProtectedResource != nil && p.ProtectedResource.String() != "" {
+	// In azure oauth v2 there is no resource param so add it only if V1 endpoint
+	// https://docs.microsoft.com/en-us/azure/active-directory/azuread-dev/azure-ad-endpoint-comparison#scopes-not-resources
+	if p.ProtectedResource != nil && p.ProtectedResource.String() != "" && !p.isV2Endpoint {
 		extraParams.Add("resource", p.ProtectedResource.String())
 	}
 	a := makeLoginURL(p.ProviderData, redirectURI, state, extraParams)
@@ -145,45 +183,39 @@ func (p *AzureProvider) Redeem(ctx context.Context, redirectURL, code, codeVerif
 	session.CreatedAtNow()
 	session.SetExpiresOn(time.Unix(jsonResponse.ExpiresOn, 0))
 
-	email, err := p.verifyTokenAndExtractEmail(ctx, session.IDToken, session.AccessToken)
+	err = p.extractClaimsIntoSession(ctx, session)
 
-	// https://github.com/oauth2-proxy/oauth2-proxy/pull/914#issuecomment-782285814
-	// https://github.com/AzureAD/azure-activedirectory-library-for-java/issues/117
-	// due to above issues, id_token may not be signed by AAD
-	// in that case, we will fallback to access token
-	if err == nil && email != "" {
-		session.Email = email
-	} else {
-		logger.Printf("unable to get email claim from id_token: %v", err)
-	}
-
-	if session.Email == "" {
-		email, err = p.verifyTokenAndExtractEmail(ctx, session.AccessToken, session.AccessToken)
-		if err == nil && email != "" {
-			session.Email = email
-		} else {
-			logger.Printf("unable to get email claim from access token: %v", err)
-		}
+	if err != nil {
+		return nil, fmt.Errorf("unable to get email and/or groups claims from token: %v", err)
 	}
 
 	return session, nil
 }
 
-// EnrichSession finds the email to enrich the session state
-func (p *AzureProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
-	if s.Email != "" {
-		return nil
-	}
+// EnrichSession enriches the session state with userID, mail and groups
+func (p *AzureProvider) EnrichSession(ctx context.Context, session *sessions.SessionState) error {
+	err := p.extractClaimsIntoSession(ctx, session)
 
-	email, err := p.getEmailFromProfileAPI(ctx, s.AccessToken)
 	if err != nil {
-		return fmt.Errorf("unable to get email address: %v", err)
+		logger.Printf("unable to get email and/or groups claims from token: %v", err)
 	}
-	if email == "" {
-		return errors.New("unable to get email address")
+
+	if session.Email == "" {
+		email, err := p.getEmailFromProfileAPI(ctx, session.AccessToken)
+		if err != nil {
+			return fmt.Errorf("unable to get email address from profile URL: %v", err)
+		}
+		session.Email = email
 	}
-	s.Email = email
 
+	// If using the v2.0 oidc endpoint we're also querying Microsoft Graph
+	if p.isV2Endpoint {
+		groups, err := p.getGroupsFromProfileAPI(ctx, session)
+		if err != nil {
+			return fmt.Errorf("unable to get groups from Microsoft Graph: %v", err)
+		}
+		session.Groups = util.RemoveDuplicateStr(append(session.Groups, groups...))
+	}
 	return nil
 }
 
@@ -205,33 +237,66 @@ func (p *AzureProvider) prepareRedeem(redirectURL, code, codeVerifier string) (u
 	if codeVerifier != "" {
 		params.Add("code_verifier", codeVerifier)
 	}
-	if p.ProtectedResource != nil && p.ProtectedResource.String() != "" {
+
+	// In azure oauth v2 there is no resource param so add it only if V1 endpoint
+	// https://docs.microsoft.com/en-us/azure/active-directory/azuread-dev/azure-ad-endpoint-comparison#scopes-not-resources
+	if p.ProtectedResource != nil && p.ProtectedResource.String() != "" && !p.isV2Endpoint {
 		params.Add("resource", p.ProtectedResource.String())
 	}
+
 	return params, nil
 }
 
-// verifyTokenAndExtractEmail tries to extract email claim from either id_token or access token
+// extractClaimsIntoSession tries to extract email and groups claims from either id_token or access token
 // when oidc verifier is configured
-func (p *AzureProvider) verifyTokenAndExtractEmail(ctx context.Context, rawIDToken string, accessToken string) (string, error) {
-	email := ""
-
-	if rawIDToken != "" && p.Verifier != nil {
-		_, err := p.Verifier.Verify(ctx, rawIDToken)
-		// due to issues mentioned above, id_token may not be signed by AAD
-		if err == nil {
-			s, err := p.buildSessionFromClaims(rawIDToken, accessToken)
-			if err == nil {
-				email = s.Email
-			} else {
-				logger.Printf("unable to get claims from token: %v", err)
+func (p *AzureProvider) extractClaimsIntoSession(ctx context.Context, session *sessions.SessionState) error {
+
+	var s *sessions.SessionState
+
+	// First let's verify session token
+	if err := p.verifySessionToken(ctx, session); err != nil {
+		return fmt.Errorf("unable to verify token: %v", err)
+	}
+
+	// https://github.com/oauth2-proxy/oauth2-proxy/pull/914#issuecomment-782285814
+	// https://github.com/AzureAD/azure-activedirectory-library-for-java/issues/117
+	// due to above issues, id_token may not be signed by AAD
+	// in that case, we will fallback to access token
+	var err error
+	s, err = p.buildSessionFromClaims(session.IDToken, session.AccessToken)
+	if err != nil || s.Email == "" {
+		s, err = p.buildSessionFromClaims(session.AccessToken, session.AccessToken)
+	}
+	if err != nil {
+		return fmt.Errorf("unable to get claims from token: %v", err)
+	}
+
+	session.Email = s.Email
+	if s.Groups != nil {
+		session.Groups = s.Groups
+	}
+
+	return nil
+}
+
+// verifySessionToken tries to validate id_token if present or access token when oidc verifier is configured
+func (p *AzureProvider) verifySessionToken(ctx context.Context, session *sessions.SessionState) error {
+	// Without a verifier there's no way to verify
+	if p.Verifier == nil {
+		return nil
+	}
+
+	if session.IDToken != "" {
+		if _, err := p.Verifier.Verify(ctx, session.IDToken); err != nil {
+			logger.Printf("unable to verify ID token, fallback to access token: %v", err)
+			if _, err = p.Verifier.Verify(ctx, session.AccessToken); err != nil {
+				return fmt.Errorf("unable to verify access token: %v", err)
 			}
-		} else {
-			logger.Printf("unable to verify token: %v", err)
 		}
+	} else if _, err := p.Verifier.Verify(ctx, session.AccessToken); err != nil {
+		return fmt.Errorf("unable to verify access token: %v", err)
 	}
-
-	return email, nil
+	return nil
 }
 
 // RefreshSession uses the RefreshToken to fetch new Access and ID Tokens
@@ -285,25 +350,10 @@ func (p *AzureProvider) redeemRefreshToken(ctx context.Context, s *sessions.Sess
 	s.CreatedAtNow()
 	s.SetExpiresOn(time.Unix(jsonResponse.ExpiresOn, 0))
 
-	email, err := p.verifyTokenAndExtractEmail(ctx, s.IDToken, s.AccessToken)
+	err = p.extractClaimsIntoSession(ctx, s)
 
-	// https://github.com/oauth2-proxy/oauth2-proxy/pull/914#issuecomment-782285814
-	// https://github.com/AzureAD/azure-activedirectory-library-for-java/issues/117
-	// due to above issues, id_token may not be signed by AAD
-	// in that case, we will fallback to access token
-	if err == nil && email != "" {
-		s.Email = email
-	} else {
-		logger.Printf("unable to get email claim from id_token: %v", err)
-	}
-
-	if s.Email == "" {
-		email, err = p.verifyTokenAndExtractEmail(ctx, s.AccessToken, s.AccessToken)
-		if err == nil && email != "" {
-			s.Email = email
-		} else {
-			logger.Printf("unable to get email claim from access token: %v", err)
-		}
+	if err != nil {
+		logger.Printf("unable to get email and/or groups claims from token: %v", err)
 	}
 
 	return nil
@@ -313,46 +363,93 @@ func makeAzureHeader(accessToken string) http.Header {
 	return makeAuthorizationHeader(tokenTypeBearer, accessToken, nil)
 }
 
-func getEmailFromJSON(json *simplejson.Json) (string, error) {
-	var email string
-	var err error
+func (p *AzureProvider) getGroupsFromProfileAPI(ctx context.Context, s *sessions.SessionState) ([]string, error) {
+	if s.AccessToken == "" {
+		return nil, fmt.Errorf("missing access token")
+	}
 
-	email, err = json.Get("mail").String()
+	groupsURL := getMicrosoftGraphGroupsURL(p.GraphGroupField).String()
 
-	if err != nil || email == "" {
-		otherMails, otherMailsErr := json.Get("otherMails").Array()
-		if len(otherMails) > 0 {
-			email = otherMails[0].(string)
-		}
-		err = otherMailsErr
-	}
+	// Need and extra header while talking with MS Graph. For more context see
+	// https://docs.microsoft.com/en-us/graph/api/group-list-transitivememberof?view=graph-rest-1.0&tabs=http#request-headers
+	extraHeader := makeAzureHeader(s.AccessToken)
+	extraHeader.Add("ConsistencyLevel", "eventual")
 
-	if err != nil || email == "" {
-		email, err = json.Get("userPrincipalName").String()
+	var groups []string
+
+	for groupsURL != "" {
+		jsonRequest, err := requests.New(groupsURL).
+			WithContext(ctx).
+			WithHeaders(extraHeader).
+			Do().
+			UnmarshalSimpleJSON()
 		if err != nil {
-			logger.Errorf("unable to find userPrincipalName: %s", err)
-			return "", err
+			return nil, fmt.Errorf("unable to unmarshal Microsoft Graph response: %v", err)
+
 		}
+		groupsURL, err = jsonRequest.Get("@odata.nextLink").String()
+		if err != nil {
+			groupsURL = ""
+		}
+		groupsPage := getGroupsFromJSON(jsonRequest, p.GraphGroupField)
+		groups = append(groups, groupsPage...)
+	}
+
+	return groups, nil
+}
+
+func getGroupsFromJSON(json *simplejson.Json, graphGroupField string) []string {
+	groups := []string{}
+
+	for i := range json.Get("value").MustArray() {
+		value := json.Get("value").GetIndex(i).Get(graphGroupField).MustString()
+		groups = append(groups, value)
 	}
 
-	return email, err
+	return groups
 }
 
 func (p *AzureProvider) getEmailFromProfileAPI(ctx context.Context, accessToken string) (string, error) {
 	if accessToken == "" {
-		return "", errors.New("missing access token")
+		return "", fmt.Errorf("missing access token")
 	}
 
 	json, err := requests.New(p.ProfileURL.String()).
 		WithContext(ctx).
 		WithHeaders(makeAzureHeader(accessToken)).
 		Do().
-		UnmarshalJSON()
+		UnmarshalSimpleJSON()
 	if err != nil {
 		return "", err
 	}
 
-	return getEmailFromJSON(json)
+	email, err := getEmailFromJSON(json)
+	if email == "" {
+		return "", fmt.Errorf("empty email address: %v", err)
+	}
+	return email, nil
+}
+
+func getEmailFromJSON(json *simplejson.Json) (string, error) {
+	email, err := json.Get("mail").String()
+
+	if err != nil || email == "" {
+		otherMails, otherMailsErr := json.Get("otherMails").Array()
+		if len(otherMails) > 0 {
+			email = otherMails[0].(string)
+		}
+		err = otherMailsErr
+	}
+
+	if err != nil || email == "" {
+		email, err = json.Get("userPrincipalName").String()
+		if err != nil {
+			logger.Errorf("unable to find userPrincipalName: %s", err)
+			return "", err
+		}
+	}
+
+	return email, nil
 }
 
 // ValidateSession validates the AccessToken
diff --git a/providers/digitalocean.go b/providers/digitalocean.go
index acbd6f70..bc1a6b26 100644
--- a/providers/digitalocean.go
+++ b/providers/digitalocean.go
@@ -72,7 +72,7 @@ func (p *DigitalOceanProvider) GetEmailAddress(ctx context.Context, s *sessions.
 		WithContext(ctx).
 		WithHeaders(makeOIDCHeader(s.AccessToken)).
 		Do().
-		UnmarshalJSON()
+		UnmarshalSimpleJSON()
 	if err != nil {
 		return "", err
 	}
diff --git a/providers/google.go b/providers/google.go
index 40501aa6..62399f09 100644
--- a/providers/google.go
+++ b/providers/google.go
@@ -8,7 +8,6 @@ import (
 	"errors"
 	"fmt"
 	"io"
-	"io/ioutil"
 	"net/url"
 	"os"
 	"strings"
@@ -231,7 +230,7 @@ func (p *GoogleProvider) setGroupRestriction(groups []string, adminEmail string,
 }
 
 func getAdminService(adminEmail string, credentialsReader io.Reader) *admin.Service {
-	data, err := ioutil.ReadAll(credentialsReader)
+	data, err := io.ReadAll(credentialsReader)
 	if err != nil {
 		logger.Fatal("can't read Google credentials file:", err)
 	}
diff --git a/providers/keycloak.go b/providers/keycloak.go
index 4a8af231..7cab75c7 100644
--- a/providers/keycloak.go
+++ b/providers/keycloak.go
@@ -77,7 +77,7 @@ func (p *KeycloakProvider) EnrichSession(ctx context.Context, s *sessions.Sessio
 		WithContext(ctx).
 		SetHeader("Authorization", "Bearer "+s.AccessToken).
 		Do().
-		UnmarshalJSON()
+		UnmarshalSimpleJSON()
 	if err != nil {
 		logger.Errorf("failed making request %v", err)
 		return err
diff --git a/providers/keycloak_oidc.go b/providers/keycloak_oidc.go
index 28abae3a..6603f0ea 100644
--- a/providers/keycloak_oidc.go
+++ b/providers/keycloak_oidc.go
@@ -124,20 +124,21 @@ func (p *KeycloakOIDCProvider) getAccessClaims(ctx context.Context, s *sessions.
 // the format `client:role`.
 //
 // ResourceAccess format:
-// "resource_access": {
-//   "clientA": {
-//     "roles": [
-//       "roleA"
-//     ]
-//   },
-//   "clientB": {
-//     "roles": [
-//       "roleA",
-//       "roleB",
-//       "roleC"
-//     ]
-//   }
-// }
+//
+//	"resource_access": {
+//	  "clientA": {
+//	    "roles": [
+//	      "roleA"
+//	    ]
+//	  },
+//	  "clientB": {
+//	    "roles": [
+//	      "roleA",
+//	      "roleB",
+//	      "roleC"
+//	    ]
+//	  }
+//	}
 func getClientRoles(claims *accessClaims) []string {
 	var clientRoles []string
 	for clientName, access := range claims.ResourceAccess {
diff --git a/providers/linkedin.go b/providers/linkedin.go
index 3904d840..62b971ca 100644
--- a/providers/linkedin.go
+++ b/providers/linkedin.go
@@ -90,7 +90,7 @@ func (p *LinkedInProvider) GetEmailAddress(ctx context.Context, s *sessions.Sess
 		WithContext(ctx).
 		WithHeaders(makeLinkedInHeader(s.AccessToken)).
 		Do().
-		UnmarshalJSON()
+		UnmarshalSimpleJSON()
 	if err != nil {
 		return "", err
 	}
diff --git a/providers/logingov.go b/providers/logingov.go
index e321b9e0..2660b015 100644
--- a/providers/logingov.go
+++ b/providers/logingov.go
@@ -7,9 +7,9 @@ import (
 	"crypto/rsa"
 	"errors"
 	"fmt"
-	"io/ioutil"
 	"math/big"
 	"net/url"
+	"os"
 	"time"
 
 	"github.com/golang-jwt/jwt"
@@ -123,7 +123,7 @@ func (p *LoginGovProvider) configure(opts options.LoginGovOptions) error {
 		p.JWTKey = signKey
 	case opts.JWTKeyFile != "":
 		// The JWT key is in the filesystem
-		keyData, err := ioutil.ReadFile(opts.JWTKeyFile)
+		keyData, err := os.ReadFile(opts.JWTKeyFile)
 		if err != nil {
 			return fmt.Errorf("could not read key file: %v", opts.JWTKeyFile)
 		}
diff --git a/providers/nextcloud.go b/providers/nextcloud.go
index beffb19a..c1538b36 100644
--- a/providers/nextcloud.go
+++ b/providers/nextcloud.go
@@ -44,7 +44,7 @@ func (p *NextcloudProvider) EnrichSession(ctx context.Context, s *sessions.Sessi
 		WithContext(ctx).
 		SetHeader("Authorization", "Bearer "+s.AccessToken).
 		Do().
-		UnmarshalJSON()
+		UnmarshalSimpleJSON()
 	if err != nil {
 		logger.Errorf("failed making request %v", err)
 		return err
diff --git a/providers/oidc.go b/providers/oidc.go
index d3902afb..aadaf7c5 100644
--- a/providers/oidc.go
+++ b/providers/oidc.go
@@ -22,7 +22,14 @@ type OIDCProvider struct {
 
 // NewOIDCProvider initiates a new OIDCProvider
 func NewOIDCProvider(p *ProviderData, opts options.OIDCOptions) *OIDCProvider {
-	p.ProviderName = "OpenID Connect"
+	p.setProviderDefaults(providerDefaults{
+		name:        "OpenID Connect",
+		loginURL:    nil,
+		redeemURL:   nil,
+		profileURL:  nil,
+		validateURL: nil,
+		scope:       "",
+	})
 	p.getAuthorizationHeaderFunc = makeOIDCHeader
 
 	return &OIDCProvider{
diff --git a/providers/provider_data.go b/providers/provider_data.go
index 647ea172..0e3f090f 100644
--- a/providers/provider_data.go
+++ b/providers/provider_data.go
@@ -4,9 +4,9 @@ import (
 	"context"
 	"errors"
 	"fmt"
-	"io/ioutil"
 	"net/http"
 	"net/url"
+	"os"
 	"regexp"
 	"strings"
 
@@ -67,7 +67,7 @@ func (p *ProviderData) GetClientSecret() (clientSecret string, err error) {
 	}
 
 	// Getting ClientSecret can fail in runtime so we need to report it without returning the file name to the user
-	fileClientSecret, err := ioutil.ReadFile(p.ClientSecretFile)
+	fileClientSecret, err := os.ReadFile(p.ClientSecretFile)
 	if err != nil {
 		logger.Errorf("error reading client secret file %s: %s", p.ClientSecretFile, err)
 		return "", errors.New("could not read client secret file")
@@ -267,14 +267,16 @@ func (p *ProviderData) buildSessionFromClaims(rawIDToken, accessToken string) (*
 	// considered unverified.
 	verifyEmail := (p.EmailClaim == options.OIDCEmailClaim) && !p.AllowUnverifiedEmail
 
-	var verified bool
-	exists, err := extractor.GetClaimInto("email_verified", &verified)
-	if err != nil {
-		return nil, err
-	}
+	if verifyEmail {
+		var verified bool
+		exists, err := extractor.GetClaimInto("email_verified", &verified)
+		if err != nil {
+			return nil, err
+		}
 
-	if verifyEmail && exists && !verified {
-		return nil, fmt.Errorf("email in id_token (%s) isn't verified", ss.Email)
+		if exists && !verified {
+			return nil, fmt.Errorf("email in id_token (%s) isn't verified", ss.Email)
+		}
 	}
 
 	return ss, nil


Test patch:
diff --git a/main_test.go b/main_test.go
index 2ed44cb0..6a640817 100644
--- a/main_test.go
+++ b/main_test.go
@@ -3,7 +3,6 @@ package main
 import (
 	"errors"
 	"fmt"
-	"io/ioutil"
 	"os"
 	"strings"
 	"time"
@@ -191,7 +190,7 @@ redirect_url="http://localhost:4180/oauth2/callback"
 
 			if in.configContent != "" {
 				By("Writing the config to a temporary file", func() {
-					file, err := ioutil.TempFile("", "oauth2-proxy-test-config-XXXX.cfg")
+					file, err := os.CreateTemp("", "oauth2-proxy-test-config-XXXX.cfg")
 					Expect(err).ToNot(HaveOccurred())
 					defer file.Close()
 
@@ -204,7 +203,7 @@ redirect_url="http://localhost:4180/oauth2/callback"
 
 			if in.alphaConfigContent != "" {
 				By("Writing the config to a temporary file", func() {
-					file, err := ioutil.TempFile("", "oauth2-proxy-test-alpha-config-XXXX.yaml")
+					file, err := os.CreateTemp("", "oauth2-proxy-test-alpha-config-XXXX.yaml")
 					Expect(err).ToNot(HaveOccurred())
 					defer file.Close()
 
diff --git a/oauthproxy_test.go b/oauthproxy_test.go
index 10229366..0d8bc91a 100644
--- a/oauthproxy_test.go
+++ b/oauthproxy_test.go
@@ -6,7 +6,6 @@ import (
 	"encoding/base64"
 	"fmt"
 	"io"
-	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
 	"net/url"
@@ -837,9 +836,9 @@ func NewProcessCookieTest(opts ProcessCookieTestOpts, modifiers ...OptionsModifi
 	}
 
 	groups := pcTest.opts.Providers[0].AllowedGroups
-	testProvider.AllowedGroups = make(map[string]struct{}, len(groups))
+	testProvider.ProviderData.AllowedGroups = make(map[string]struct{}, len(groups))
 	for _, group := range groups {
-		testProvider.AllowedGroups[group] = struct{}{}
+		testProvider.ProviderData.AllowedGroups[group] = struct{}{}
 	}
 	pcTest.proxy.provider = testProvider
 
@@ -1043,7 +1042,7 @@ func TestUserInfoEndpointAccepted(t *testing.T) {
 
 			test.proxy.ServeHTTP(test.rw, test.req)
 			assert.Equal(t, http.StatusOK, test.rw.Code)
-			bodyBytes, _ := ioutil.ReadAll(test.rw.Body)
+			bodyBytes, _ := io.ReadAll(test.rw.Body)
 			assert.Equal(t, tc.expectedResponse, string(bodyBytes))
 		})
 	}
@@ -1094,7 +1093,7 @@ func TestAuthOnlyEndpointAccepted(t *testing.T) {
 
 	test.proxy.ServeHTTP(test.rw, test.req)
 	assert.Equal(t, http.StatusAccepted, test.rw.Code)
-	bodyBytes, _ := ioutil.ReadAll(test.rw.Body)
+	bodyBytes, _ := io.ReadAll(test.rw.Body)
 	assert.Equal(t, "", string(bodyBytes))
 }
 
@@ -1106,7 +1105,7 @@ func TestAuthOnlyEndpointUnauthorizedOnNoCookieSetError(t *testing.T) {
 
 	test.proxy.ServeHTTP(test.rw, test.req)
 	assert.Equal(t, http.StatusUnauthorized, test.rw.Code)
-	bodyBytes, _ := ioutil.ReadAll(test.rw.Body)
+	bodyBytes, _ := io.ReadAll(test.rw.Body)
 	assert.Equal(t, "Unauthorized\n", string(bodyBytes))
 }
 
@@ -1126,7 +1125,7 @@ func TestAuthOnlyEndpointUnauthorizedOnExpiration(t *testing.T) {
 
 	test.proxy.ServeHTTP(test.rw, test.req)
 	assert.Equal(t, http.StatusUnauthorized, test.rw.Code)
-	bodyBytes, _ := ioutil.ReadAll(test.rw.Body)
+	bodyBytes, _ := io.ReadAll(test.rw.Body)
 	assert.Equal(t, "Unauthorized\n", string(bodyBytes))
 }
 
@@ -1145,7 +1144,7 @@ func TestAuthOnlyEndpointUnauthorizedOnEmailValidationFailure(t *testing.T) {
 
 	test.proxy.ServeHTTP(test.rw, test.req)
 	assert.Equal(t, http.StatusUnauthorized, test.rw.Code)
-	bodyBytes, _ := ioutil.ReadAll(test.rw.Body)
+	bodyBytes, _ := io.ReadAll(test.rw.Body)
 	assert.Equal(t, "Unauthorized\n", string(bodyBytes))
 }
 
@@ -1561,7 +1560,7 @@ func (st *SignatureTest) MakeRequestWithExpectedKey(method, body, key string) er
 
 	var bodyBuf io.ReadCloser
 	if body != "" {
-		bodyBuf = ioutil.NopCloser(&fakeNetConn{reqBody: body})
+		bodyBuf = io.NopCloser(&fakeNetConn{reqBody: body})
 	}
 	req := httptest.NewRequest(method, "/foo/bar", bodyBuf)
 	req.Header = st.header
diff --git a/pkg/apis/options/load_test.go b/pkg/apis/options/load_test.go
index 57efd926..cface514 100644
--- a/pkg/apis/options/load_test.go
+++ b/pkg/apis/options/load_test.go
@@ -3,7 +3,6 @@ package options
 import (
 	"errors"
 	"fmt"
-	"io/ioutil"
 	"os"
 	"time"
 
@@ -118,7 +117,7 @@ var _ = Describe("Load", func() {
 
 				if o.configFile != nil {
 					By("Creating a config file")
-					configFile, err := ioutil.TempFile("", "oauth2-proxy-test-legacy-config-file")
+					configFile, err := os.CreateTemp("", "oauth2-proxy-test-legacy-config-file")
 					Expect(err).ToNot(HaveOccurred())
 					defer configFile.Close()
 
@@ -390,7 +389,7 @@ sub:
 
 				if in.configFile != nil {
 					By("Creating a config file")
-					configFile, err := ioutil.TempFile("", "oauth2-proxy-test-config-file")
+					configFile, err := os.CreateTemp("", "oauth2-proxy-test-config-file")
 					Expect(err).ToNot(HaveOccurred())
 					defer configFile.Close()
 
@@ -488,7 +487,7 @@ injectResponseHeaders:
 `)
 
 		By("Creating a config file")
-		configFile, err := ioutil.TempFile("", "oauth2-proxy-test-alpha-config-file")
+		configFile, err := os.CreateTemp("", "oauth2-proxy-test-alpha-config-file")
 		Expect(err).ToNot(HaveOccurred())
 		defer configFile.Close()
 
diff --git a/pkg/apis/options/util/util_test.go b/pkg/apis/options/util/util_test.go
index 8ee8cc30..b96c1453 100644
--- a/pkg/apis/options/util/util_test.go
+++ b/pkg/apis/options/util/util_test.go
@@ -1,7 +1,6 @@
 package util
 
 import (
-	"io/ioutil"
 	"os"
 	"path"
 
@@ -20,9 +19,9 @@ var _ = Describe("GetSecretValue", func() {
 		os.Setenv(secretEnvKey, secretEnvValue)
 
 		var err error
-		fileDir, err = ioutil.TempDir("", "oauth2-proxy-util-get-secret-value")
+		fileDir, err = os.MkdirTemp("", "oauth2-proxy-util-get-secret-value")
 		Expect(err).ToNot(HaveOccurred())
-		Expect(ioutil.WriteFile(path.Join(fileDir, "secret-file"), secretFileValue, 0600)).To(Succeed())
+		Expect(os.WriteFile(path.Join(fileDir, "secret-file"), secretFileValue, 0600)).To(Succeed())
 	})
 
 	AfterEach(func() {
diff --git a/pkg/app/pagewriter/error_page_test.go b/pkg/app/pagewriter/error_page_test.go
index f8e3bfb7..7c4f0136 100644
--- a/pkg/app/pagewriter/error_page_test.go
+++ b/pkg/app/pagewriter/error_page_test.go
@@ -3,7 +3,7 @@ package pagewriter
 import (
 	"errors"
 	"html/template"
-	"io/ioutil"
+	"io"
 	"net/http/httptest"
 
 	middlewareapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware"
@@ -36,7 +36,7 @@ var _ = Describe("Error Page Writer", func() {
 				AppError:    "Access Denied",
 			})
 
-			body, err := ioutil.ReadAll(recorder.Result().Body)
+			body, err := io.ReadAll(recorder.Result().Body)
 			Expect(err).ToNot(HaveOccurred())
 			Expect(string(body)).To(Equal("Forbidden You do not have permission to access this resource. /prefix/ 403 /redirect 11111111-2222-4333-8444-555555555555 Custom Footer Text v0.0.0-test"))
 		})
@@ -50,7 +50,7 @@ var _ = Describe("Error Page Writer", func() {
 				AppError:    "Access Denied",
 			})
 
-			body, err := ioutil.ReadAll(recorder.Result().Body)
+			body, err := io.ReadAll(recorder.Result().Body)
 			Expect(err).ToNot(HaveOccurred())
 			Expect(string(body)).To(Equal("Internal Server Error Oops! Something went wrong. For more information contact your server administrator. /prefix/ 500 /redirect 11111111-2222-4333-8444-555555555555 Custom Footer Text v0.0.0-test"))
 		})
@@ -68,7 +68,7 @@ var _ = Describe("Error Page Writer", func() {
 				},
 			})
 
-			body, err := ioutil.ReadAll(recorder.Result().Body)
+			body, err := io.ReadAll(recorder.Result().Body)
 			Expect(err).ToNot(HaveOccurred())
 			Expect(string(body)).To(Equal("Forbidden An extra message: with more context. /prefix/ 403 /redirect 11111111-2222-4333-8444-555555555555 Custom Footer Text v0.0.0-test"))
 		})
@@ -82,7 +82,7 @@ var _ = Describe("Error Page Writer", func() {
 				AppError:    "Access Denied",
 			})
 
-			body, err := ioutil.ReadAll(recorder.Result().Body)
+			body, err := io.ReadAll(recorder.Result().Body)
 			Expect(err).ToNot(HaveOccurred())
 			Expect(string(body)).To(Equal("Forbidden You do not have permission to access this resource. /prefix/ 403 /redirect &lt;script&gt;alert(1)&lt;/script&gt; Custom Footer Text v0.0.0-test"))
 		})
@@ -97,7 +97,7 @@ var _ = Describe("Error Page Writer", func() {
 			recorder := httptest.NewRecorder()
 			errorPage.ProxyErrorHandler(recorder, req, errors.New("some upstream error"))
 
-			body, err := ioutil.ReadAll(recorder.Result().Body)
+			body, err := io.ReadAll(recorder.Result().Body)
 			Expect(err).ToNot(HaveOccurred())
 			Expect(string(body)).To(Equal("Bad Gateway There was a problem connecting to the upstream server. /prefix/ 502  11111111-2222-4333-8444-555555555555 Custom Footer Text v0.0.0-test"))
 		})
@@ -121,7 +121,7 @@ var _ = Describe("Error Page Writer", func() {
 					AppError:    "Debug error",
 				})
 
-				body, err := ioutil.ReadAll(recorder.Result().Body)
+				body, err := io.ReadAll(recorder.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal("Debug error"))
 			})
@@ -136,7 +136,7 @@ var _ = Describe("Error Page Writer", func() {
 				recorder := httptest.NewRecorder()
 				errorPage.ProxyErrorHandler(recorder, req, errors.New("some upstream error"))
 
-				body, err := ioutil.ReadAll(recorder.Result().Body)
+				body, err := io.ReadAll(recorder.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal("some upstream error"))
 			})
diff --git a/pkg/app/pagewriter/pagewriter_test.go b/pkg/app/pagewriter/pagewriter_test.go
index cfea153b..eca0c760 100644
--- a/pkg/app/pagewriter/pagewriter_test.go
+++ b/pkg/app/pagewriter/pagewriter_test.go
@@ -3,7 +3,7 @@ package pagewriter
 import (
 	"errors"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"os"
@@ -50,7 +50,7 @@ var _ = Describe("Writer", func() {
 					AppError:    "Some debug error",
 				})
 
-				body, err := ioutil.ReadAll(recorder.Result().Body)
+				body, err := io.ReadAll(recorder.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(HavePrefix("\n<!DOCTYPE html>"))
 			})
@@ -59,7 +59,7 @@ var _ = Describe("Writer", func() {
 				recorder := httptest.NewRecorder()
 				writer.WriteSignInPage(recorder, request, "/redirect", http.StatusOK)
 
-				body, err := ioutil.ReadAll(recorder.Result().Body)
+				body, err := io.ReadAll(recorder.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(HavePrefix("\n<!DOCTYPE html>"))
 			})
@@ -70,14 +70,14 @@ var _ = Describe("Writer", func() {
 
 			BeforeEach(func() {
 				var err error
-				customDir, err = ioutil.TempDir("", "oauth2-proxy-pagewriter-test")
+				customDir, err = os.MkdirTemp("", "oauth2-proxy-pagewriter-test")
 				Expect(err).ToNot(HaveOccurred())
 
 				templateHTML := `Custom Template`
 				signInFile := filepath.Join(customDir, signInTemplateName)
-				Expect(ioutil.WriteFile(signInFile, []byte(templateHTML), 0600)).To(Succeed())
+				Expect(os.WriteFile(signInFile, []byte(templateHTML), 0600)).To(Succeed())
 				errorFile := filepath.Join(customDir, errorTemplateName)
-				Expect(ioutil.WriteFile(errorFile, []byte(templateHTML), 0600)).To(Succeed())
+				Expect(os.WriteFile(errorFile, []byte(templateHTML), 0600)).To(Succeed())
 
 				opts.TemplatesPath = customDir
 
@@ -97,7 +97,7 @@ var _ = Describe("Writer", func() {
 					AppError:    "Some debug error",
 				})
 
-				body, err := ioutil.ReadAll(recorder.Result().Body)
+				body, err := io.ReadAll(recorder.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal("Custom Template"))
 			})
@@ -106,7 +106,7 @@ var _ = Describe("Writer", func() {
 				recorder := httptest.NewRecorder()
 				writer.WriteSignInPage(recorder, request, "/redirect", http.StatusOK)
 
-				body, err := ioutil.ReadAll(recorder.Result().Body)
+				body, err := io.ReadAll(recorder.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal("Custom Template"))
 			})
@@ -117,12 +117,12 @@ var _ = Describe("Writer", func() {
 
 			BeforeEach(func() {
 				var err error
-				customDir, err = ioutil.TempDir("", "oauth2-proxy-pagewriter-test")
+				customDir, err = os.MkdirTemp("", "oauth2-proxy-pagewriter-test")
 				Expect(err).ToNot(HaveOccurred())
 
 				templateHTML := `{{ Custom Broken Template`
 				signInFile := filepath.Join(customDir, signInTemplateName)
-				Expect(ioutil.WriteFile(signInFile, []byte(templateHTML), 0600)).To(Succeed())
+				Expect(os.WriteFile(signInFile, []byte(templateHTML), 0600)).To(Succeed())
 
 				opts.TemplatesPath = customDir
 			})
@@ -155,7 +155,7 @@ var _ = Describe("Writer", func() {
 
 				Expect(rw.Result().StatusCode).To(Equal(in.expectedStatus))
 
-				body, err := ioutil.ReadAll(rw.Result().Body)
+				body, err := io.ReadAll(rw.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(in.expectedBody))
 			},
@@ -188,7 +188,7 @@ var _ = Describe("Writer", func() {
 
 				Expect(rw.Result().StatusCode).To(Equal(in.expectedStatus))
 
-				body, err := ioutil.ReadAll(rw.Result().Body)
+				body, err := io.ReadAll(rw.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(in.expectedBody))
 			},
@@ -218,7 +218,7 @@ var _ = Describe("Writer", func() {
 
 				Expect(rw.Result().StatusCode).To(Equal(in.expectedStatus))
 
-				body, err := ioutil.ReadAll(rw.Result().Body)
+				body, err := io.ReadAll(rw.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(in.expectedBody))
 			},
@@ -257,7 +257,7 @@ var _ = Describe("Writer", func() {
 
 				Expect(rw.Result().StatusCode).To(Equal(in.expectedStatus))
 
-				body, err := ioutil.ReadAll(rw.Result().Body)
+				body, err := io.ReadAll(rw.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(in.expectedBody))
 			},
diff --git a/pkg/app/pagewriter/sign_in_page_test.go b/pkg/app/pagewriter/sign_in_page_test.go
index 05c0b2e9..521e92d7 100644
--- a/pkg/app/pagewriter/sign_in_page_test.go
+++ b/pkg/app/pagewriter/sign_in_page_test.go
@@ -4,7 +4,7 @@ import (
 	"errors"
 	"fmt"
 	"html/template"
-	"io/ioutil"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"os"
@@ -56,7 +56,7 @@ var _ = Describe("SignIn Page", func() {
 				recorder := httptest.NewRecorder()
 				signInPage.WriteSignInPage(recorder, request, "/redirect", http.StatusOK)
 
-				body, err := ioutil.ReadAll(recorder.Result().Body)
+				body, err := io.ReadAll(recorder.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal("/prefix/ My Provider Sign In Here Custom Footer Text v0.0.0-test /redirect true Logo Data"))
 			})
@@ -70,7 +70,7 @@ var _ = Describe("SignIn Page", func() {
 				recorder := httptest.NewRecorder()
 				signInPage.WriteSignInPage(recorder, request, "/redirect", http.StatusOK)
 
-				body, err := ioutil.ReadAll(recorder.Result().Body)
+				body, err := io.ReadAll(recorder.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(fmt.Sprintf("Internal Server Error | %s", testRequestID)))
 			})
@@ -84,12 +84,12 @@ var _ = Describe("SignIn Page", func() {
 
 		BeforeEach(func() {
 			var err error
-			customDir, err = ioutil.TempDir("", "oauth2-proxy-sign-in-page-test")
+			customDir, err = os.MkdirTemp("", "oauth2-proxy-sign-in-page-test")
 			Expect(err).ToNot(HaveOccurred())
 
 			for _, ext := range []string{".svg", ".png", ".jpg", ".jpeg", ".gif"} {
 				fileName := filepath.Join(customDir, fmt.Sprintf("logo%s", ext))
-				Expect(ioutil.WriteFile(fileName, []byte(fakeImageData), 0600)).To(Succeed())
+				Expect(os.WriteFile(fileName, []byte(fakeImageData), 0600)).To(Succeed())
 			}
 		})
 
diff --git a/pkg/app/pagewriter/static_pages_test.go b/pkg/app/pagewriter/static_pages_test.go
index f52451ba..9f317517 100644
--- a/pkg/app/pagewriter/static_pages_test.go
+++ b/pkg/app/pagewriter/static_pages_test.go
@@ -3,7 +3,7 @@ package pagewriter
 import (
 	"errors"
 	"html/template"
-	"io/ioutil"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"os"
@@ -27,11 +27,11 @@ var _ = Describe("Static Pages", func() {
 			template: errorTmpl,
 		}
 
-		customDir, err = ioutil.TempDir("", "oauth2-proxy-static-pages-test")
+		customDir, err = os.MkdirTemp("", "oauth2-proxy-static-pages-test")
 		Expect(err).ToNot(HaveOccurred())
 
 		robotsTxtFile := filepath.Join(customDir, robotsTxtName)
-		Expect(ioutil.WriteFile(robotsTxtFile, []byte(customRobots), 0400)).To(Succeed())
+		Expect(os.WriteFile(robotsTxtFile, []byte(customRobots), 0400)).To(Succeed())
 
 		request = httptest.NewRequest("", "http://127.0.0.1/", nil)
 		request = middlewareapi.AddRequestScope(request, &middlewareapi.RequestScope{
@@ -58,7 +58,7 @@ var _ = Describe("Static Pages", func() {
 					recorder := httptest.NewRecorder()
 					pageWriter.WriteRobotsTxt(recorder, request)
 
-					body, err := ioutil.ReadAll(recorder.Result().Body)
+					body, err := io.ReadAll(recorder.Result().Body)
 					Expect(err).ToNot(HaveOccurred())
 					Expect(string(body)).To(Equal(customRobots))
 
@@ -81,7 +81,7 @@ var _ = Describe("Static Pages", func() {
 					recorder := httptest.NewRecorder()
 					pageWriter.WriteRobotsTxt(recorder, request)
 
-					body, err := ioutil.ReadAll(recorder.Result().Body)
+					body, err := io.ReadAll(recorder.Result().Body)
 					Expect(err).ToNot(HaveOccurred())
 					Expect(string(body)).To(Equal(string(defaultRobotsTxt)))
 
@@ -94,7 +94,7 @@ var _ = Describe("Static Pages", func() {
 					}
 					pageWriter.WriteRobotsTxt(recorder, request)
 
-					body, err := ioutil.ReadAll(recorder.Result().Body)
+					body, err := io.ReadAll(recorder.Result().Body)
 					Expect(err).ToNot(HaveOccurred())
 					Expect(string(body)).To(Equal(string("Internal Server Error")))
 
diff --git a/pkg/app/pagewriter/templates_test.go b/pkg/app/pagewriter/templates_test.go
index cfcbcfca..a836c621 100644
--- a/pkg/app/pagewriter/templates_test.go
+++ b/pkg/app/pagewriter/templates_test.go
@@ -3,7 +3,6 @@ package pagewriter
 import (
 	"bytes"
 	"html/template"
-	"io/ioutil"
 	"os"
 	"path/filepath"
 
@@ -16,14 +15,14 @@ var _ = Describe("Templates", func() {
 
 	BeforeEach(func() {
 		var err error
-		customDir, err = ioutil.TempDir("", "oauth2-proxy-templates-test")
+		customDir, err = os.MkdirTemp("", "oauth2-proxy-templates-test")
 		Expect(err).ToNot(HaveOccurred())
 
 		templateHTML := `{{.TestString}} {{.TestString | ToLower}} {{.TestString | ToUpper}}`
 		signInFile := filepath.Join(customDir, signInTemplateName)
-		Expect(ioutil.WriteFile(signInFile, []byte(templateHTML), 0600)).To(Succeed())
+		Expect(os.WriteFile(signInFile, []byte(templateHTML), 0600)).To(Succeed())
 		errorFile := filepath.Join(customDir, errorTemplateName)
-		Expect(ioutil.WriteFile(errorFile, []byte(templateHTML), 0600)).To(Succeed())
+		Expect(os.WriteFile(errorFile, []byte(templateHTML), 0600)).To(Succeed())
 	})
 
 	AfterEach(func() {
@@ -162,7 +161,7 @@ var _ = Describe("Templates", func() {
 			Context("With an invalid sign_in template", func() {
 				BeforeEach(func() {
 					signInFile := filepath.Join(customDir, signInTemplateName)
-					Expect(ioutil.WriteFile(signInFile, []byte("{{"), 0600))
+					Expect(os.WriteFile(signInFile, []byte("{{"), 0600))
 				})
 
 				It("Should return an error when loading templates", func() {
@@ -175,7 +174,7 @@ var _ = Describe("Templates", func() {
 			Context("With an invalid error template", func() {
 				BeforeEach(func() {
 					errorFile := filepath.Join(customDir, errorTemplateName)
-					Expect(ioutil.WriteFile(errorFile, []byte("{{"), 0600))
+					Expect(os.WriteFile(errorFile, []byte("{{"), 0600))
 				})
 
 				It("Should return an error when loading templates", func() {
diff --git a/pkg/header/header_suite_test.go b/pkg/header/header_suite_test.go
index 00f1a028..51685b20 100644
--- a/pkg/header/header_suite_test.go
+++ b/pkg/header/header_suite_test.go
@@ -1,7 +1,6 @@
 package header
 
 import (
-	"io/ioutil"
 	"os"
 	"path"
 	"testing"
@@ -26,9 +25,9 @@ func TestHeaderSuite(t *testing.T) {
 var _ = BeforeSuite(func() {
 	os.Setenv("SECRET_ENV", "super-secret-env")
 
-	dir, err := ioutil.TempDir("", "oauth2-proxy-header-suite")
+	dir, err := os.MkdirTemp("", "oauth2-proxy-header-suite")
 	Expect(err).ToNot(HaveOccurred())
-	Expect(ioutil.WriteFile(path.Join(dir, "secret-file"), []byte("super-secret-file"), 0644)).To(Succeed())
+	Expect(os.WriteFile(path.Join(dir, "secret-file"), []byte("super-secret-file"), 0644)).To(Succeed())
 	filesDir = dir
 })
 
diff --git a/pkg/http/server_test.go b/pkg/http/server_test.go
index 6944c491..dac49601 100644
--- a/pkg/http/server_test.go
+++ b/pkg/http/server_test.go
@@ -4,7 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"net/http"
 
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
@@ -565,7 +565,7 @@ var _ = Describe("Server", func() {
 				Expect(err).ToNot(HaveOccurred())
 				Expect(resp.StatusCode).To(Equal(http.StatusOK))
 
-				body, err := ioutil.ReadAll(resp.Body)
+				body, err := io.ReadAll(resp.Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(hello))
 			})
@@ -619,7 +619,7 @@ var _ = Describe("Server", func() {
 				Expect(err).ToNot(HaveOccurred())
 				Expect(resp.StatusCode).To(Equal(http.StatusOK))
 
-				body, err := ioutil.ReadAll(resp.Body)
+				body, err := io.ReadAll(resp.Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(hello))
 			})
@@ -690,7 +690,7 @@ var _ = Describe("Server", func() {
 				Expect(err).ToNot(HaveOccurred())
 				Expect(resp.StatusCode).To(Equal(http.StatusOK))
 
-				body, err := ioutil.ReadAll(resp.Body)
+				body, err := io.ReadAll(resp.Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(hello))
 			})
@@ -705,7 +705,7 @@ var _ = Describe("Server", func() {
 				Expect(err).ToNot(HaveOccurred())
 				Expect(resp.StatusCode).To(Equal(http.StatusOK))
 
-				body, err := ioutil.ReadAll(resp.Body)
+				body, err := io.ReadAll(resp.Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(hello))
 			})
@@ -761,7 +761,7 @@ var _ = Describe("Server", func() {
 				Expect(err).ToNot(HaveOccurred())
 				Expect(resp.StatusCode).To(Equal(http.StatusOK))
 
-				body, err := ioutil.ReadAll(resp.Body)
+				body, err := io.ReadAll(resp.Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(hello))
 			})
@@ -815,7 +815,7 @@ var _ = Describe("Server", func() {
 				Expect(err).ToNot(HaveOccurred())
 				Expect(resp.StatusCode).To(Equal(http.StatusOK))
 
-				body, err := ioutil.ReadAll(resp.Body)
+				body, err := io.ReadAll(resp.Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(hello))
 			})
@@ -886,7 +886,7 @@ var _ = Describe("Server", func() {
 				Expect(err).ToNot(HaveOccurred())
 				Expect(resp.StatusCode).To(Equal(http.StatusOK))
 
-				body, err := ioutil.ReadAll(resp.Body)
+				body, err := io.ReadAll(resp.Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(hello))
 			})
@@ -901,7 +901,7 @@ var _ = Describe("Server", func() {
 				Expect(err).ToNot(HaveOccurred())
 				Expect(resp.StatusCode).To(Equal(http.StatusOK))
 
-				body, err := ioutil.ReadAll(resp.Body)
+				body, err := io.ReadAll(resp.Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(hello))
 			})
diff --git a/pkg/requests/builder_test.go b/pkg/requests/builder_test.go
index 0c0f0d03..552741f7 100644
--- a/pkg/requests/builder_test.go
+++ b/pkg/requests/builder_test.go
@@ -285,7 +285,7 @@ func assertSuccessfulRequest(builder func() Builder, expectedRequest testHTTPReq
 
 		BeforeEach(func() {
 			var err error
-			response, err = builder().Do().UnmarshalJSON()
+			response, err = builder().Do().UnmarshalSimpleJSON()
 			Expect(err).ToNot(HaveOccurred())
 		})
 
@@ -340,7 +340,7 @@ func assertRequestError(builder func() Builder, errorMessage string) {
 
 	Context("UnmarshalJSON", func() {
 		It("returns an error", func() {
-			resp, err := builder().Do().UnmarshalJSON()
+			resp, err := builder().Do().UnmarshalSimpleJSON()
 			Expect(err).To(MatchError(ContainSubstring(errorMessage)))
 			Expect(resp).To(BeNil())
 		})
@@ -368,7 +368,7 @@ func assertJSONError(builder func() Builder, errorMessage string) {
 
 	Context("UnmarshalJSON", func() {
 		It("returns an error", func() {
-			resp, err := builder().Do().UnmarshalJSON()
+			resp, err := builder().Do().UnmarshalSimpleJSON()
 			Expect(err).To(MatchError(ContainSubstring(errorMessage)))
 			Expect(resp).To(BeNil())
 		})
diff --git a/pkg/requests/requests_suite_test.go b/pkg/requests/requests_suite_test.go
index 9c22e4d0..5bc684b0 100644
--- a/pkg/requests/requests_suite_test.go
+++ b/pkg/requests/requests_suite_test.go
@@ -3,7 +3,7 @@ package requests
 import (
 	"encoding/json"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"log"
 	"net/http"
 	"net/http/httptest"
@@ -82,7 +82,7 @@ func toTestHTTPRequest(req *http.Request) (testHTTPRequest, error) {
 	requestBody := []byte{}
 	if req.Body != http.NoBody {
 		var err error
-		requestBody, err = ioutil.ReadAll(req.Body)
+		requestBody, err = io.ReadAll(req.Body)
 		if err != nil {
 			return testHTTPRequest{}, err
 		}
diff --git a/pkg/requests/result_test.go b/pkg/requests/result_test.go
index 2d5c95ca..70c2814a 100644
--- a/pkg/requests/result_test.go
+++ b/pkg/requests/result_test.go
@@ -198,7 +198,7 @@ var _ = Describe("Result suite", func() {
 
 		DescribeTable("with a result",
 			func(in unmarshalJSONTableInput) {
-				j, err := in.result.UnmarshalJSON()
+				j, err := in.result.UnmarshalSimpleJSON()
 				if in.expectedErr != nil {
 					Expect(err).To(MatchError(in.expectedErr))
 					Expect(j).To(BeNil())
diff --git a/pkg/upstream/upstream_suite_test.go b/pkg/upstream/upstream_suite_test.go
index e91da077..95eb6811 100644
--- a/pkg/upstream/upstream_suite_test.go
+++ b/pkg/upstream/upstream_suite_test.go
@@ -3,7 +3,7 @@ package upstream
 import (
 	"encoding/json"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"log"
 	"net/http"
 	"net/http/httptest"
@@ -35,12 +35,12 @@ func TestUpstreamSuite(t *testing.T) {
 
 var _ = BeforeSuite(func() {
 	// Set up files for serving via file servers
-	dir, err := ioutil.TempDir("", "oauth2-proxy-upstream-suite")
+	dir, err := os.MkdirTemp("", "oauth2-proxy-upstream-suite")
 	Expect(err).ToNot(HaveOccurred())
-	Expect(ioutil.WriteFile(path.Join(dir, "foo"), []byte("foo"), 0644)).To(Succeed())
-	Expect(ioutil.WriteFile(path.Join(dir, "bar"), []byte("bar"), 0644)).To(Succeed())
+	Expect(os.WriteFile(path.Join(dir, "foo"), []byte("foo"), 0644)).To(Succeed())
+	Expect(os.WriteFile(path.Join(dir, "bar"), []byte("bar"), 0644)).To(Succeed())
 	Expect(os.Mkdir(path.Join(dir, "subdir"), os.ModePerm)).To(Succeed())
-	Expect(ioutil.WriteFile(path.Join(dir, "subdir", "baz"), []byte("baz"), 0644)).To(Succeed())
+	Expect(os.WriteFile(path.Join(dir, "subdir", "baz"), []byte("baz"), 0644)).To(Succeed())
 	filesDir = dir
 
 	// Set up a webserver that reflects requests
@@ -148,7 +148,7 @@ func toTestHTTPRequest(req *http.Request) (testHTTPRequest, error) {
 	requestBody := []byte{}
 	if req.Body != http.NoBody {
 		var err error
-		requestBody, err = ioutil.ReadAll(req.Body)
+		requestBody, err = io.ReadAll(req.Body)
 		if err != nil {
 			return testHTTPRequest{}, err
 		}
diff --git a/pkg/util/util_test.go b/pkg/util/util_test.go
index d5f7860b..b8eff502 100644
--- a/pkg/util/util_test.go
+++ b/pkg/util/util_test.go
@@ -3,7 +3,6 @@ package util
 import (
 	"crypto/x509"
 	"encoding/pem"
-	"io/ioutil"
 	"os"
 	"testing"
 
@@ -183,7 +182,7 @@ WrW4JMzLaGDtoHxRNNfo8E7fGkQ=
 )
 
 func makeTestCertFile(t *testing.T, pem, dir string) *os.File {
-	file, err := ioutil.TempFile(dir, "test-certfile")
+	file, err := os.CreateTemp(dir, "test-certfile")
 	assert.NoError(t, err)
 	_, err = file.Write([]byte(pem))
 	assert.NoError(t, err)
@@ -196,7 +195,7 @@ func TestGetCertPool_NoRoots(t *testing.T) {
 }
 
 func TestGetCertPool(t *testing.T) {
-	tempDir, err := ioutil.TempDir("", "certtest")
+	tempDir, err := os.MkdirTemp("", "certtest")
 	assert.NoError(t, err)
 	defer func(path string) {
 		rerr := os.RemoveAll(path)
diff --git a/pkg/validation/common_test.go b/pkg/validation/common_test.go
index 10c179b9..809e1cf6 100644
--- a/pkg/validation/common_test.go
+++ b/pkg/validation/common_test.go
@@ -1,7 +1,6 @@
 package validation
 
 import (
-	"io/ioutil"
 	"os"
 
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
@@ -18,7 +17,7 @@ var _ = Describe("Common", func() {
 	BeforeEach(func() {
 		validSecretSourceValue = []byte("This is a secret source value")
 		Expect(os.Setenv(validSecretSourceEnv, "This is a secret source env")).To(Succeed())
-		tmp, err := ioutil.TempFile("", "oauth2-proxy-secret-source-test")
+		tmp, err := os.CreateTemp("", "oauth2-proxy-secret-source-test")
 		Expect(err).ToNot(HaveOccurred())
 		defer tmp.Close()
 
diff --git a/pkg/validation/options_test.go b/pkg/validation/options_test.go
index 3d22ec9e..fa45221c 100644
--- a/pkg/validation/options_test.go
+++ b/pkg/validation/options_test.go
@@ -2,7 +2,6 @@ package validation
 
 import (
 	"crypto"
-	"io/ioutil"
 	"net/url"
 	"os"
 	"strings"
@@ -205,7 +204,7 @@ func TestRealClientIPHeader(t *testing.T) {
 }
 
 func TestProviderCAFilesError(t *testing.T) {
-	file, err := ioutil.TempFile("", "absent.*.crt")
+	file, err := os.CreateTemp("", "absent.*.crt")
 	assert.NoError(t, err)
 	assert.NoError(t, file.Close())
 	assert.NoError(t, os.Remove(file.Name()))
diff --git a/providers/azure_test.go b/providers/azure_test.go
index b198d019..27d70b88 100644
--- a/providers/azure_test.go
+++ b/providers/azure_test.go
@@ -55,6 +55,7 @@ func testAzureProvider(hostname string, opts options.AzureOptions) *AzureProvide
 			ProtectedResource: &url.URL{},
 			Scope:             "",
 			EmailClaim:        "email",
+			GroupsClaim:       "groups",
 			Verifier: internaloidc.NewVerifier(oidc.NewVerifier(
 				"https://issuer.example.com",
 				fakeAzureKeySetStub{},
@@ -133,14 +134,9 @@ func TestAzureSetTenant(t *testing.T) {
 	p := testAzureProvider("", options.AzureOptions{Tenant: "example"})
 	assert.Equal(t, "Azure", p.Data().ProviderName)
 	assert.Equal(t, "example", p.Tenant)
-	assert.Equal(t, "https://login.microsoftonline.com/example/oauth2/authorize",
-		p.Data().LoginURL.String())
-	assert.Equal(t, "https://login.microsoftonline.com/example/oauth2/token",
-		p.Data().RedeemURL.String())
-	assert.Equal(t, "https://graph.microsoft.com/v1.0/me",
-		p.Data().ProfileURL.String())
-	assert.Equal(t, "https://graph.microsoft.com",
-		p.Data().ProtectedResource.String())
+	assert.Equal(t, "https://login.microsoftonline.com/example/oauth2/authorize", p.Data().LoginURL.String())
+	assert.Equal(t, "https://login.microsoftonline.com/example/oauth2/token", p.Data().RedeemURL.String())
+	assert.Equal(t, "https://graph.microsoft.com/v1.0/me", p.Data().ProfileURL.String())
 	assert.Equal(t, "https://graph.microsoft.com/v1.0/me", p.Data().ValidateURL.String())
 	assert.Equal(t, "openid", p.Data().Scope)
 }
@@ -151,10 +147,25 @@ func testAzureBackend(payload string, accessToken, refreshToken string) *httptes
 
 func testAzureBackendWithError(payload string, accessToken, refreshToken string, injectError bool) *httptest.Server {
 	path := "/v1.0/me"
+	pathGroups := path + "/transitiveMemberOf/microsoft.graph.group"
 
 	return httptest.NewServer(http.HandlerFunc(
 		func(w http.ResponseWriter, r *http.Request) {
-			if (r.URL.Path != path) && r.Method != http.MethodPost {
+			if r.URL.Path == pathGroups && r.Method == http.MethodGet {
+				w.Write([]byte(`{
+					"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#groups(displayName,id)",
+					"value": [
+						{
+							"displayName": "aa",
+							"id": "11111111-2222-3333-4444-555555555555"
+						},
+						{
+							"displayName": "bb",
+							"id": "555555555555-4444-3333-2222-11111111"
+						}
+					]
+				}`))
+			} else if (r.URL.Path != path) && r.Method != http.MethodPost {
 				w.WriteHeader(404)
 			} else if r.Method == http.MethodPost && r.Body != nil {
 				if injectError {
@@ -183,7 +194,7 @@ func TestAzureProviderEnrichSession(t *testing.T) {
 	}{
 		{
 			Description:             "should return email using mail property from Azure backend",
-			PayloadFromAzureBackend: `{ "mail": "user@windows.net" }`,
+			PayloadFromAzureBackend: `{ "mail": "user@windows.net", "groups": ["aa", "bb"] }`,
 			ExpectedEmail:           "user@windows.net",
 		},
 		{
@@ -199,17 +210,17 @@ func TestAzureProviderEnrichSession(t *testing.T) {
 		{
 			Description:             "should return error when Azure backend doesn't return email information",
 			PayloadFromAzureBackend: `{ "mail": null, "otherMails": [], "userPrincipalName": null }`,
-			ExpectedError:           fmt.Errorf("unable to get email address: %v", errors.New("type assertion to string failed")),
+			ExpectedError:           fmt.Errorf("unable to get email address from profile URL: %v", errors.New("empty email address: type assertion to string failed")),
 		},
 		{
 			Description:             "should return specific error when unable to get email",
 			PayloadFromAzureBackend: `{ "mail": null, "otherMails": [], "userPrincipalName": "" }`,
-			ExpectedError:           errors.New("unable to get email address"),
+			ExpectedError:           errors.New("unable to get email address from profile URL: empty email address: <nil>"),
 		},
 		{
 			Description:             "should return error when otherMails from Azure backend is not a valid type",
 			PayloadFromAzureBackend: `{ "mail": null, "otherMails": "", "userPrincipalName": null }`,
-			ExpectedError:           fmt.Errorf("unable to get email address: %v", errors.New("type assertion to string failed")),
+			ExpectedError:           fmt.Errorf("unable to get email address from profile URL: %v", errors.New("empty email address: type assertion to string failed")),
 		},
 		{
 			Description:   "should not query profile api when email is already set in session",
@@ -224,13 +235,13 @@ func TestAzureProviderEnrichSession(t *testing.T) {
 				b    *httptest.Server
 				host string
 			)
-			if testCase.PayloadFromAzureBackend != "" {
-				b = testAzureBackend(testCase.PayloadFromAzureBackend, authorizedAccessToken, "")
-				defer b.Close()
 
-				bURL, _ := url.Parse(b.URL)
-				host = bURL.Host
-			}
+			b = testAzureBackend(testCase.PayloadFromAzureBackend, authorizedAccessToken, "")
+			defer b.Close()
+
+			bURL, _ := url.Parse(b.URL)
+			host = bURL.Host
+
 			p := testAzureProvider(host, options.AzureOptions{})
 			session := CreateAuthorizedSession()
 			session.Email = testCase.Email
@@ -250,18 +261,21 @@ func TestAzureProviderRedeem(t *testing.T) {
 		EmailFromAccessToken string
 		IsIDTokenMalformed   bool
 		InjectRedeemURLError bool
+		Groups               []string
 	}{
 		{
 			Name:             "with id_token returned",
 			EmailFromIDToken: "foo1@example.com",
 			RefreshToken:     "some_refresh_token",
 			ExpiresOn:        time.Now().Add(time.Hour),
+			Groups:           []string{"aa", "bb"},
 		},
 		{
 			Name:                 "without id_token returned, fallback to access token",
 			EmailFromAccessToken: "foo2@example.com",
 			RefreshToken:         "some_refresh_token",
 			ExpiresOn:            time.Now().Add(time.Hour),
+			Groups:               []string{"aa", "bb"},
 		},
 		{
 			Name:                 "id_token malformed, fallback to access token",
@@ -269,6 +283,7 @@ func TestAzureProviderRedeem(t *testing.T) {
 			RefreshToken:         "some_refresh_token",
 			ExpiresOn:            time.Now().Add(time.Hour),
 			IsIDTokenMalformed:   true,
+			Groups:               []string{"aa", "bb"},
 		},
 		{
 			Name:                 "both id_token and access tokens are valid, return email from id_token",
@@ -276,6 +291,7 @@ func TestAzureProviderRedeem(t *testing.T) {
 			EmailFromAccessToken: "foo3@example.com",
 			RefreshToken:         "some_refresh_token",
 			ExpiresOn:            time.Now().Add(time.Hour),
+			Groups:               []string{"aa", "bb"},
 		},
 		{
 			Name:                 "redeem URL failed, should return error",
@@ -284,6 +300,7 @@ func TestAzureProviderRedeem(t *testing.T) {
 			RefreshToken:         "some_refresh_token",
 			ExpiresOn:            time.Now().Add(time.Hour),
 			InjectRedeemURLError: true,
+			Groups:               []string{"aa", "bb"},
 		},
 	}
 
@@ -295,7 +312,9 @@ func TestAzureProviderRedeem(t *testing.T) {
 				var err error
 				token := idTokenClaims{
 					StandardClaims: jwt.StandardClaims{Audience: "cd6d4fae-f6a6-4a34-8454-2c6b598e9532"},
-					Email:          testCase.EmailFromIDToken}
+					Email:          testCase.EmailFromIDToken,
+					Groups:         []string{"aa", "bb"},
+				}
 				idTokenString, err = newSignedTestIDToken(token)
 				assert.NoError(t, err)
 			}
@@ -303,7 +322,9 @@ func TestAzureProviderRedeem(t *testing.T) {
 				var err error
 				token := idTokenClaims{
 					StandardClaims: jwt.StandardClaims{Audience: "cd6d4fae-f6a6-4a34-8454-2c6b598e9532"},
-					Email:          testCase.EmailFromAccessToken}
+					Email:          testCase.EmailFromAccessToken,
+					Groups:         []string{"aa", "bb"},
+				}
 				accessTokenString, err = newSignedTestIDToken(token)
 				assert.NoError(t, err)
 			}
@@ -335,6 +356,7 @@ func TestAzureProviderRedeem(t *testing.T) {
 				assert.Equal(t, accessTokenString, s.AccessToken)
 				assert.Equal(t, testCase.ExpiresOn.Unix(), s.ExpiresOn.Unix())
 				assert.Equal(t, testCase.RefreshToken, s.RefreshToken)
+				assert.Equal(t, testCase.Groups, s.Groups)
 				if testCase.EmailFromIDToken != "" {
 					assert.Equal(t, testCase.EmailFromIDToken, s.Email)
 				} else {
@@ -345,13 +367,24 @@ func TestAzureProviderRedeem(t *testing.T) {
 	}
 }
 
-func TestAzureProviderProtectedResourceConfigured(t *testing.T) {
+func TestAzureProviderProtectedResourceConfiguredOAuthV1(t *testing.T) {
 	p := testAzureProvider("", options.AzureOptions{})
 	p.ProtectedResource, _ = url.Parse("http://my.resource.test")
 	result := p.GetLoginURL("https://my.test.app/oauth", "", "", url.Values{})
 	assert.Contains(t, result, "resource="+url.QueryEscape("http://my.resource.test"))
 }
 
+func TestAzureProviderProtectedResourceConfiguredOAuthV2(t *testing.T) {
+	p := testAzureProvider("", options.AzureOptions{})
+	testURL := "http://my.resource.test"
+	p.ProtectedResource, _ = url.Parse(testURL)
+	p.isV2Endpoint = true
+	result, _ := url.Parse(p.GetLoginURL("https://my.test.app/oauth", "", "", url.Values{}))
+	parsedQuery, _ := url.ParseQuery(result.RawQuery)
+	assert.NotContains(t, parsedQuery["scope"], " "+testURL)
+	assert.NotContains(t, result.RawQuery, "resource="+url.QueryEscape(testURL))
+}
+
 func TestAzureProviderRefresh(t *testing.T) {
 	email := "foo@example.com"
 	subject := "foo"
diff --git a/providers/google_test.go b/providers/google_test.go
index 780420e4..6ec25d84 100644
--- a/providers/google_test.go
+++ b/providers/google_test.go
@@ -166,7 +166,6 @@ func TestGoogleProviderGroupValidator(t *testing.T) {
 	}
 }
 
-//
 func TestGoogleProviderGetEmailAddressInvalidEncoding(t *testing.T) {
 	p := newGoogleProvider(t)
 	body, err := json.Marshal(redeemResponse{
diff --git a/providers/providers_test.go b/providers/providers_test.go
index ed2a10a0..a69a0641 100644
--- a/providers/providers_test.go
+++ b/providers/providers_test.go
@@ -1,7 +1,6 @@
 package providers
 
 import (
-	"io/ioutil"
 	"os"
 	"testing"
 
@@ -43,7 +42,7 @@ func TestClientSecretFileOptionFails(t *testing.T) {
 func TestClientSecretFileOption(t *testing.T) {
 	g := NewWithT(t)
 
-	f, err := ioutil.TempFile("", "client_secret_temp_file_")
+	f, err := os.CreateTemp("", "client_secret_temp_file_")
 	g.Expect(err).ToNot(HaveOccurred())
 
 	clientSecretFileName := f.Name()
diff --git a/validator_test.go b/validator_test.go
index 0856ed90..976c0d7d 100644
--- a/validator_test.go
+++ b/validator_test.go
@@ -1,7 +1,6 @@
 package main
 
 import (
-	"io/ioutil"
 	"os"
 	"strings"
 	"testing"
@@ -18,7 +17,7 @@ type ValidatorTest struct {
 func NewValidatorTest(t *testing.T) *ValidatorTest {
 	vt := &ValidatorTest{}
 	var err error
-	f, err := ioutil.TempFile("", "test_auth_emails_")
+	f, err := os.CreateTemp("", "test_auth_emails_")
 	if err != nil {
 		t.Fatalf("failed to create temp file: %v", err)
 	}