Repository: oauth2-proxy/oauth2-proxy
Pull request #1574 (2022-02-23 16:35:08)

Add Azure groups support and Azure OAuth v2.0
<!--- Provide a general summary of your changes in the Title above -->

## Description

This PR adds support for Azure groups and [Azure OAuth v2.0](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow) (https://login.microsoftonline.com/{tenant_id}/v2.0)

Resolves https://github.com/oauth2-proxy/oauth2-proxy/issues/1231 
Resolves https://github.com/oauth2-proxy/oauth2-proxy/issues/1505 
Resolves https://github.com/oauth2-proxy/oauth2-proxy/issues/888
<!--- Describe your changes in detail -->

## Motivation and Context

This is an extended PR for https://github.com/oauth2-proxy/oauth2-proxy/pull/1514 

## How Has This Been Tested?

Tested with Azure environment using both OAuth v1 and v2.0
Validated that it works with and without `allowed-group` configured.



## 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.


adriananeci: > Adding yet another URL to the list of URLs that needs to be set requires strong justification IMO. Does Azure not provide a specific profile URL in the V2 version which allows you to fetch the groups and other information? Can you link to documentation that shows you must use a separate call to access group information

@JoelSpeed the `GroupURL` has a default value pointing to Microsoft Graph, so it is not mandatory to be explicitly set. 

To ensure that the token size doesn't exceed HTTP header size limits, Azure AD limits the number of object IDs that it includes in the `groups` claim. 

If a user is member of more groups than the overage limit (200 for JWT tokens), then Azure AD does not emit the `groups` claim in the token. Instead, it includes an overage claim in the token that indicates to the application to query the Microsoft Graph API to retrieve the user's group membership. See  https://docs.microsoft.com/en-us/azure/active-directory/develop/id-tokens#groups-overage-claim for more details
JoelSpeed: > If a user is member of more groups than the overage limit (200 for JWT tokens), then Azure AD does not emit the groups claim in the token. Instead, it includes an overage claim in the token that indicates to the application to query the Microsoft Graph API to retrieve the user's group membership. See https://docs.microsoft.com/en-us/azure/active-directory/develop/id-tokens#groups-overage-claim for more details

Yikes! If a user is a member of more that 200 groups, do we really want to be storing that much data in our session anyway? Seems like we will have incredible session bloat
adriananeci: > Yikes! If a user is a member of more that 200 groups, do we really want to be storing that much data in our session anyway? Seems like we will have incredible session bloat

Unfortunately many users using our apps fronted by an auth proxy were hitting this limitation 😞 . The workaround was to suggest them unsubscribing to some groups until they get to a reasonable number.
adriananeci: @JoelSpeed @braunsonm I think this PR is ready for a last round of reviews. I've tested it with both V1(https://sts.windows.net/{tenant-id}/) and V2(https://login.microsoftonline.com/{tenant-id}/v2.0) Azure Auth endpoints.

The only still non working thing is the `--resource` flag. It is currently broken in the latest version(v7.2.1) based on https://github.com/oauth2-proxy/oauth2-proxy/issues/1505 and I think it should be addressed in a followup PR since changes on this PR has nothing to do with this problem.

Looking forward for your feedback.
braunsonm: @adriananeci wouldn't #1505 be solved by using the graph token that you're generating here? Not sure if you looked into the issue anymore but seems like it should be fixed or nearly fixed by this change.
adriananeci: > @adriananeci wouldn't #1505 be solved by using the graph token that you're generating here? Not sure if you looked into the issue anymore but seems like it should be fixed or nearly fixed by this change.

I've already tried it, but it was failing while fetching the `email` address from oidc userinfo endpoint. :(
```
Error redeeming code during OAuth2 callback: unable to get claims from token: could not get claim "email": failed to fetch claims from profile URL: error making request to profile URL: unexpected status "400": {"error":{"code":"UnknownError","message":"Token must contain sub claim."
```
braunsonm: > > @adriananeci wouldn't #1505 be solved by using the graph token that you're generating here? Not sure if you looked into the issue anymore but seems like it should be fixed or nearly fixed by this change.
> 
> I've already tried it, but it was failing while fetching the `email` address from oidc userinfo endpoint. :(
> 
> ```
> Error redeeming code during OAuth2 callback: unable to get claims from token: could not get claim "email": failed to fetch claims from profile URL: error making request to profile URL: unexpected status "400": {"error":{"code":"UnknownError","message":"Token must contain sub claim."
> ```

Aha! I'm pretty sure this is similar to what I mentioned in our other discussion about the email. I've seen that. Did you add the `profile` and `openid` scopes and the optional email claim in the token settings? Just curious if you were able to get the `email` to appear in the userinfo endpoint because that does sound like a problem with this kind of client credentials flow?
adriananeci: @braunsonm yup, all scopes were added during token acquisition:
```
result, err = app.AcquireTokenByCredential(ctx, []string{"openid", "email", "profile", azureGraphURL.String()})
```

> Just curious if you were able to get the email to appear in the userinfo endpoint

I wasn't able to access the userinfo endpoint due to
```
Error redeeming code during OAuth2 callback: unable to get claims from token: could not get claim "email": failed to fetch claims from profile URL: error making request to profile URL: unexpected status "400": {"error":{"code":"UnknownError","message":"Token must contain sub claim."
```

I've checked the token claims and the `sub` claim was present, so something strange might be happening.

Can this be due to https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow#client-limitations ?
braunsonm: > Can this be due to https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow#client-limitations ?

No, oauth2-proxy doesn't use the implicit flow
pierluigilenoci: Thank you @adriananeci for your effort! 🚀
mvalenzisi: Still an issue
pierluigilenoci: @adriananeci any update?
adriananeci: > @adriananeci any update?

I don't have any updates, I'm looking forward for any feedback from the maintainers or other interested parties. Not sure what or how to proceed further.
adriananeci: CI is failing most probably because `slices.Contains` statement is part of go 1.18 and CI runs using go 1.17. It is failing with:
```
GO111MODULE=on golangci-lint run
Error: level=warning msg="[runner] Can't run linter goanalysis_metalinter: bodyclose: failed prerequisites: [buildssa@github.com/oauth2-proxy/oauth2-proxy/v7 [github.com/oauth2-proxy/oauth2-proxy/v7.test]: analysis skipped: errors in package: [/home/runner/work/oauth2-proxy/oauth2-proxy/oauthproxy.go:35:2: could not import github.com/oauth2-proxy/oauth2-proxy/v7/pkg/middleware (/home/runner/work/oauth2-proxy/oauth2-proxy/pkg/middleware/stored_session.go:14:2: could not import github.com/oauth2-proxy/oauth2-proxy/v7/providers (/home/runner/work/oauth2-proxy/oauth2-proxy/providers/azure.go:12:2: could not import golang.org/x/exp/slices (/home/runner/go/pkg/mod/golang.org/x/exp@v0.0.0-20220613132600-b0d7[8](https://github.com/oauth2-proxy/oauth2-proxy/runs/6883863248?check_suite_focus=true#step:6:9)1184e0d/slices/slices.go:22:11: expected '(', found '['))) /home/runner/work/oauth2-proxy/oauth2-proxy/oauthproxy.go:39:2: could not import github.com/oauth2-proxy/oauth2-proxy/v7/providers (/home/runner/work/oauth2-proxy/oauth2-proxy/providers/azure.go:12:2: could not import golang.org/x/exp/slices (/home/runner/go/pkg/mod/golang.org/x/exp@v0.0.0-20220613132600-b0d781184e0d/slices/slices.go:22:11: expected '(', found '[')) /home/runner/work/oauth2-proxy/oauth2-proxy/oauthproxy_test.go:787:15: testProvider.AllowedGroups undefined (type *TestProvider has no field or method AllowedGroups) /home/runner/work/oauth2-proxy/oauth2-proxy/oauthproxy_test.go:789:16: testProvider.AllowedGroups undefined (type *TestProvider has no field or method AllowedGroups)]]"
```

and

```
 > [linux/amd64->arm64 builder 6/6] RUN case linux/arm64 in          "linux/amd64")  GOARCH=amd64  ;;          "linux/arm64" | "linux/arm64/v8")  GOARCH=arm64  ;;          "linux/ppc64le")  GOARCH=ppc64le  ;;          "linux/arm/v6") GOARCH=arm GOARM=6  ;;     esac &&     printf "Building OAuth2 Proxy for arch ${GOARCH}\n" &&     GOARCH=${GOARCH} VERSION=ebed74a make build && touch jwt_signing_key.pem:
#0 0.197 CGO_ENABLED=0 go build -a -installsuffix cgo -ldflags="-X main.VERSION=ebed74a" -o oauth2-proxy github.com/oauth2-proxy/oauth2-proxy/v7
#0 3.951 # golang.org/x/exp/constraints
#0 3.951 /go/pkg/mod/golang.org/x/exp@v0.0.0-20220613132600-b0d781184e0d/constraints/constraints.go:13:2: syntax error: unexpected ~, expecting method or interface name
#0 3.951 /go/pkg/mod/golang.org/x/exp@v0.0.0-20220613132600-b0d781184e0d/constraints/constraints.go:20:2: syntax error: unexpected ~, expecting method or interface name
#0 3.951 /go/pkg/mod/golang.org/x/exp@v0.0.0-20220613132600-b0d781184e0d/constraints/constraints.go:27:9: syntax error: unexpected |, expecting semicolon or newline or }
#0 3.951 /go/pkg/mod/golang.org/x/exp@v0.0.0-20220613132600-b0d781184e0d/constraints/constraints.go:34:2: syntax error: unexpected ~, expecting method or interface name
#0 3.951 /go/pkg/mod/golang.org/x/exp@v0.0.0-20220613132600-b0d781184e0d/constraints/constraints.go:41:2: syntax error: unexpected ~, expecting method or interface name
#0 3.951 /go/pkg/mod/golang.org/x/exp@v0.0.0-20220613132600-b0d781184e0d/constraints/constraints.go:49:10: syntax error: unexpected |, expecting semicolon or newline or }
#0 3.951 note: module requires Go 1.18
#26 130.2 make: *** [Makefile:40: oauth2-proxy] Error 2
```
@JoelSpeed mind taking a look at [github ci workflow](https://github.com/oauth2-proxy/oauth2-proxy/blob/master/.github/workflows/ci.yaml#L23-L27) please? I suspect it needs to be updated to go 1.18 to be in sync with `go.mod`
adriananeci: @JoelSpeed @braunsonm kindly reminder
pierluigilenoci: @JoelSpeed @aiciobanu @Niksko could you please take a look?
pierluigilenoci: @braunsonm @JoelSpeed this PR needs approval 😛 
braunsonm: Thanks for your patience on this @adriananeci 
adriananeci: @braunsonm can we get this to the finish line and consider merging it please? We can address any issues that may pop up in followups PRs.
Also, can you cut a new release once this one is merged so that we can pick an official build?
pierluigilenoci: @braunsonm thank you too!
The latest @adriananeci push has dismissed your approval, can you do it again? 🙏🏻 
mbelak-dtml: Hi @JoelSpeed, could you please review this PR? I would love to start using feature :blush: 
pierluigilenoci: @adriananeci could you please rebase?
pierluigilenoci: @JoelSpeed @haydn-j-evans @hevans-dglcom @braunsonm @NickMeves @codablock @johejo @leyshon @weinong @Bibob7 is there anyone who can approve this PR? It would be a really good improvement to the software.
braunsonm: I already approved the PR no need to tag me.
pierluigilenoci: @braunsonm your approval had been dismissed by @adriananeci 's latest changes.
pierluigilenoci: @braunsonm your approval had been dismissed by @adriananeci 's latest changes.
codablock: @pierluigilenoci I would also ask to not tag me anymore. I'm not involved with oauth2-proxy development and was only contributing for a short period of time.
pierluigilenoci: @codablock sorry to bother you.
I was just trying to push this PR closer to closure.
pierluigilenoci: @JoelSpeed @braunsonm and @weinong can you review this PR?
pierluigilenoci: @adriananeci could you please fix the conflicts?
antonmatsiuk: could we get it into upstream? This is a very important feature for those using OAuth with AAD
pierluigilenoci: @JoelSpeed please help this PR move forward! 🙏🏻 ❤️ 🥺 💝 
wccropper: > @JoelSpeed please help this PR move forward! 🙏🏻 ❤️ 🥺 💝

Can we get an update on this? We are waiting to roll out a project that will require this. We need to know if this is coming or if we need to look for another project to replace this one. Any update on an estimated timeline of when this will be merged is more than enough.

Thanks for the time you put into this project!
pierluigilenoci: Thank you @adriananeci for your effort!
@JoelSpeed could you please take a look?
pierluigilenoci: @JoelSpeed thank you a lot for your review! ❤️ 
@adriananeci could you please follow up on that? 🚀 🙏🏻 
I have a feeling that we are close! 😄 
pierluigilenoci: Thanks @adriananeci and @JoelSpeed, I am happy to see this effort come to an end! 🚀 
adriananeci: > Please add a changelog entry and I'll get this merged. We should probably include an important note about the changes here and what it means for azure users

Updated the changelog with changes and important notes.


Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index cb1f8f4e..34ed7450 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.18
       uses: actions/setup-go@v2
       with:
-        go-version: 1.17.x
+        go-version: 1.18.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..da36e180 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,11 @@
   - 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
@@ -33,6 +38,7 @@ 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)
 
 # V7.3.0
 
diff --git a/docs/docs/configuration/alpha_config.md b/docs/docs/configuration/alpha_config.md
index aa3852ca..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
 
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/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/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/util/util.go b/pkg/util/util.go
index 29af8575..a4425d9b 100644
--- a/pkg/util/util.go
+++ b/pkg/util/util.go
@@ -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/providers/azure.go b/providers/azure.go
index ef7ebafd..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,34 +363,55 @@ 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()).
@@ -352,7 +423,33 @@ func (p *AzureProvider) getEmailFromProfileAPI(ctx context.Context, accessToken
 		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/provider_data.go b/providers/provider_data.go
index 8233cd0a..0e3f090f 100644
--- a/providers/provider_data.go
+++ b/providers/provider_data.go
@@ -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/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"