git.iko.soy/typeable/oauth2-proxy
on master help sign in
Merge pull request #1574 from adriananeci/azure_support_upstream

Add Azure groups support and Azure OAuth v2.0

commit19bb0d0e86853d2137f67a7b800734e2b39ab139 authorJoel Speed <Joel.speed@hotmail.co.uk> dateFri 21 Oct 2022, 19:31 (4 years ago) parent7fe6384a5d9188
12 files changed+334−150
.github/workflows/ci.yaml+3−3 CHANGELOG.md+6 docs/docs/configuration/alpha_config.md+1 docs/docs/configuration/auth.md+31−9 go.mod+2−1 go.sum+5−2 pkg/apis/options/legacy_options.go+4−1 pkg/apis/options/providers.go+3 pkg/util/util.go+13 providers/azure.go+202−105 providers/azure_test.go+55−22 providers/provider_data.go+9−7
.github/workflows/ci.yaml+3−3
@@ -20,15 +20,15 @@ jobs:
2020 - name: Check out code
2121 uses: actions/checkout@v2
2222
23− - name: Set up Go 1.17
23+ - name: Set up Go 1.18
2424 uses: actions/setup-go@v2
2525 with:
26− go-version: 1.17.x
26+ go-version: 1.18.x
2727 id: go
2828
2929 - name: Get dependencies
3030 run: |
31− curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.36.0
31+ curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.50.0
3232 curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
3333 chmod +x ./cc-test-reporter
3434
CHANGELOG.md+6
@@ -8,6 +8,11 @@
88 - 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).
99 - 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.
1010
11+- [#1574](https://github.com/oauth2-proxy/oauth2-proxy/pull/1574) Add Azure groups support and Azure OAuth v2.0 (@adriananeci)
12+ - group membership check is now validated while using the the azure provider.
13+ - 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
14+ - 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.
15+
1116## Breaking Changes
1217
1318N/A
@@ -33,6 +38,7 @@ to remain consistent with CLI flags. You should specify `code_challenge_method`
3338- [#1760](https://github.com/oauth2-proxy/oauth2-proxy/pull/1760) Option to configure API routes
3439- [#1825](https://github.com/oauth2-proxy/oauth2-proxy/pull/1825) Fix vulnerabilities CVE-2022-32149 and CVE-2022-27664. (@crbednarz)
3540- [#1750](https://github.com/oauth2-proxy/oauth2-proxy/pull/1750) Fix Nextcloud provider
41+- [#1574](https://github.com/oauth2-proxy/oauth2-proxy/pull/1574) Add Azure groups support and Azure OAuth v2.0 (@adriananeci)
3642
3743# V7.3.0
3844
docs/docs/configuration/alpha_config.md+1
@@ -164,6 +164,7 @@ They may change between releases without notice.
164164| Field | Type | Description |
165165| ----- | ---- | ----------- |
166166| `tenant` | _string_ | Tenant directs to a tenant-specific or common (tenant-independent) endpoint<br/>Default value is 'common' |
167+| `graphGroupField` | _string_ | GraphGroupField configures the group field to be used when building the groups list from Microsoft Graph<br/>Default value is 'id' |
167168
168169### BitbucketOptions
169170
docs/docs/configuration/auth.md+31−9
@@ -72,23 +72,45 @@ Note: The user is checked against the group members list on initial authenticati
7272
7373### Azure Auth Provider
7474
75−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"**.
76−2. Pick a name and choose **"Webapp / API"** as application type. Use `https://internal.yourcompany.com` as Sign-on URL. Click **"Create"**.
77−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"**.
78−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).
75+1. Add an application: go to [https://portal.azure.com](https://portal.azure.com), choose **Azure Active Directory**, select
76+**App registrations** and then click on **New registration**.
77+2. Pick a name, check the supported account type(single-tenant, multi-tenant, etc). In the **Redirect URI** section create a new
78+**Web** platform entry for each app that you want to protect by the oauth2 proxy(e.g.
79+https://internal.yourcompanycom/oauth2/callback). Click **Register**.
80+3. Next we need to add group read permissions for the app registration, on the **API Permissions** page of the app, click on
81+**Add a permission**, select **Microsoft Graph**, then select **Application permissions**, then click on **Group** and select
82+**Group.Read.All**. Hit **Add permissions** and then on **Grant admin consent** (you might need an admin to do this).
7983<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!
80−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"**.
81−6. On the **"Settings"** / **"Keys"** page of the app, add a new key and note down the value after hitting **"Save"**.
82−7. Configure the proxy with
84+4. Next, if you are planning to use v2.0 Azure Auth endpoint, go to the **Manifest** page and set `"accessTokenAcceptedVersion": 2`
85+in the App registration manifest file.
86+5. On the **Certificates & secrets** page of the app, add a new client secret and note down the value after hitting **Add**.
87+6. Configure the proxy with:
88+- for V1 Azure Auth endpoint (Azure Active Directory Endpoints - https://login.microsoftonline.com/common/oauth2/authorize)
8389
8490```
8591 --provider=azure
8692 --client-id=<application ID from step 3>
87− --client-secret=<value from step 6>
93+ --client-secret=<value from step 5>
94+ --azure-tenant={tenant-id}
8895 --oidc-issuer-url=https://sts.windows.net/{tenant-id}/
8996```
9097
91−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.
98+- for V2 Azure Auth endpoint (Microsoft Identity Platform Endpoints - https://login.microsoftonline.com/common/oauth2/v2.0/authorize)
99+```
100+ --provider=azure
101+ --client-id=<application ID from step 3>
102+ --client-secret=<value from step 5>
103+ --azure-tenant={tenant-id}
104+ --oidc-issuer-url=https://login.microsoftonline.com/{tenant-id}/v2.0
105+```
106+
107+***Notes***:
108+- When using v2.0 Azure Auth endpoint (`https://login.microsoftonline.com/{tenant-id}/v2.0`) as `--oidc_issuer_url`, in conjunction
109+with `--resource` flag, be sure to append `/.default` at the end of the resource name. See
110+https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#the-default-scope for more details.
111+- When using the Azure Auth provider with nginx and the cookie session store you may find the cookie is too large and doesn't
112+get passed through correctly. Increasing the proxy_buffer_size in nginx or implementing the [redis session storage](sessions.md#redis-storage)
113+should resolve this.
92114
93115### ADFS Auth Provider
94116
go.mod+2−1
@@ -30,6 +30,7 @@ require (
3030 github.com/stretchr/testify v1.7.0
3131 github.com/vmihailenco/msgpack/v4 v4.3.11
3232 golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b
33+ golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d
3334 golang.org/x/net v0.0.0-20221012135044-0b7e1fb9d458
3435 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
3536 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a
@@ -70,7 +71,7 @@ require (
7071 go.opentelemetry.io/otel v0.11.0 // indirect
7172 golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
7273 golang.org/x/text v0.3.8 // indirect
73− golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect
74+ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
7475 google.golang.org/appengine v1.6.5 // indirect
7576 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 // indirect
7677 google.golang.org/grpc v1.27.0 // indirect
go.sum+5−2
@@ -132,8 +132,8 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
132132github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
133133github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
134134github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
135−github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
136135github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
136+github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
137137github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
138138github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
139139github.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
318318golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
319319golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
320320golang.org/x/exp v0.0.0-20200908183739-ae8ad444f925/go.mod h1:1phAWC201xIgDyaFpmDeZkgf70Q4Pd/CNqfRtVPtxNw=
321+golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d h1:vtUKgx8dahOomfFzLREU8nSv25YHnTgLBn4rDnWZdU0=
322+golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA=
321323golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
322324golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
323325golang.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
422424golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
423425golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
424426golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
425−golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
426427golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
428+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
429+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
427430google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
428431google.golang.org/api v0.20.0 h1:jz2KixHX7EcCPiQrySzPdnYT7DbINAypCqKZ1Z7GM40=
429432google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
pkg/apis/options/legacy_options.go+4−1
@@ -483,6 +483,7 @@ type LegacyProvider struct {
483483
484484 KeycloakGroups []string `flag:"keycloak-group" cfg:"keycloak_groups"`
485485 AzureTenant string `flag:"azure-tenant" cfg:"azure_tenant"`
486+ AzureGraphGroupField string `flag:"azure-graph-group-field" cfg:"azure_graph_group_field"`
486487 BitbucketTeam string `flag:"bitbucket-team" cfg:"bitbucket_team"`
487488 BitbucketRepository string `flag:"bitbucket-repository" cfg:"bitbucket_repository"`
488489 GitHubOrg string `flag:"github-org" cfg:"github_org"`
@@ -538,6 +539,7 @@ func legacyProviderFlagSet() *pflag.FlagSet {
538539
539540 flagSet.StringSlice("keycloak-group", []string{}, "restrict logins to members of these groups (may be given multiple times)")
540541 flagSet.String("azure-tenant", "common", "go to a tenant-specific or common (tenant-independent) endpoint.")
542+ 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")
541543 flagSet.String("bitbucket-team", "", "restrict logins to members of this team")
542544 flagSet.String("bitbucket-repository", "", "restrict logins to user with access to this repository")
543545 flagSet.String("github-org", "", "restrict logins to members of this organisation")
@@ -676,7 +678,8 @@ func (l *LegacyProvider) convert() (Providers, error) {
676678 // This part is out of the switch section because azure has a default tenant
677679 // that needs to be added from legacy options
678680 provider.AzureConfig = AzureOptions{
679− Tenant: l.AzureTenant,
681+ Tenant: l.AzureTenant,
682+ GraphGroupField: l.AzureGraphGroupField,
680683 }
681684
682685 switch provider.Type {
pkg/apis/options/providers.go+3
@@ -142,6 +142,9 @@ type AzureOptions struct {
142142 // Tenant directs to a tenant-specific or common (tenant-independent) endpoint
143143 // Default value is 'common'
144144 Tenant string `json:"tenant,omitempty"`
145+ // GraphGroupField configures the group field to be used when building the groups list from Microsoft Graph
146+ // Default value is 'id'
147+ GraphGroupField string `json:"graphGroupField,omitempty"`
145148}
146149
147150type ADFSOptions struct {
pkg/util/util.go+13
@@ -149,3 +149,16 @@ func isHostnameAllowed(hostname, allowedHost string) bool {
149149
150150 return false
151151}
152+
153+// RemoveDuplicateStr removes duplicates from a slice of strings.
154+func RemoveDuplicateStr(strSlice []string) []string {
155+ allKeys := make(map[string]struct{})
156+ var list []string
157+ for _, item := range strSlice {
158+ if _, ok := allKeys[item]; !ok {
159+ allKeys[item] = struct{}{}
160+ list = append(list, item)
161+ }
162+ }
163+ return list
164+}
providers/azure.go+202−105
@@ -3,63 +3,60 @@ package providers
33import (
44 "bytes"
55 "context"
6− "errors"
76 "fmt"
87 "net/http"
98 "net/url"
9+ "strings"
1010 "time"
1111
12+ "golang.org/x/exp/slices"
13+
1214 "github.com/bitly/go-simplejson"
1315 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
1416 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
1517 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
1618 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
19+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
1720)
1821
1922// AzureProvider represents an Azure based Identity Provider
2023type AzureProvider struct {
2124 *ProviderData
22− Tenant string
25+ Tenant string
26+ GraphGroupField string
27+ isV2Endpoint bool
2328}
2429
2530var _ Provider = (*AzureProvider)(nil)
2631
2732const (
28− azureProviderName = "Azure"
29− azureDefaultScope = "openid"
33+ azureProviderName = "Azure"
34+ azureDefaultScope = "openid"
35+ azureDefaultGraphGroupField = "id"
36+ azureV2Scope = "https://graph.microsoft.com/.default"
3037)
3138
3239var (
33− // Default Login URL for Azure.
34− // Pre-parsed URL of https://login.microsoftonline.com/common/oauth2/authorize.
40+ // Default Login URL for Azure. Pre-parsed URL of https://login.microsoftonline.com/common/oauth2/authorize.
3541 azureDefaultLoginURL = &url.URL{
3642 Scheme: "https",
3743 Host: "login.microsoftonline.com",
3844 Path: "/common/oauth2/authorize",
3945 }
4046
41− // Default Redeem URL for Azure.
42− // Pre-parsed URL of https://login.microsoftonline.com/common/oauth2/token.
47+ // Default Redeem URL for Azure. Pre-parsed URL of https://login.microsoftonline.com/common/oauth2/token.
4348 azureDefaultRedeemURL = &url.URL{
4449 Scheme: "https",
4550 Host: "login.microsoftonline.com",
4651 Path: "/common/oauth2/token",
4752 }
4853
49− // Default Profile URL for Azure.
50− // Pre-parsed URL of https://graph.microsoft.com/v1.0/me.
54+ // Default Profile URL for Azure. Pre-parsed URL of https://graph.microsoft.com/v1.0/me.
5155 azureDefaultProfileURL = &url.URL{
5256 Scheme: "https",
5357 Host: "graph.microsoft.com",
5458 Path: "/v1.0/me",
5559 }
56−
57− // Default ProtectedResource URL for Azure.
58− // Pre-parsed URL of https://graph.microsoft.com.
59− azureDefaultProtectResourceURL = &url.URL{
60− Scheme: "https",
61− Host: "graph.microsoft.com",
62− }
6360)
6461
6562// NewAzureProvider initiates a new AzureProvider
@@ -73,9 +70,6 @@ func NewAzureProvider(p *ProviderData, opts options.AzureOptions) *AzureProvider
7370 scope: azureDefaultScope,
7471 })
7572
76− if p.ProtectedResource == nil || p.ProtectedResource.String() == "" {
77− p.ProtectedResource = azureDefaultProtectResourceURL
78− }
7973 if p.ValidateURL == nil || p.ValidateURL.String() == "" {
8074 p.ValidateURL = p.ProfileURL
8175 }
@@ -88,9 +82,35 @@ func NewAzureProvider(p *ProviderData, opts options.AzureOptions) *AzureProvider
8882 overrideTenantURL(p.RedeemURL, azureDefaultRedeemURL, tenant, "token")
8983 }
9084
85+ graphGroupField := azureDefaultGraphGroupField
86+ if opts.GraphGroupField != "" {
87+ graphGroupField = opts.GraphGroupField
88+ }
89+
90+ isV2Endpoint := false
91+ if strings.Contains(p.LoginURL.String(), "v2.0") {
92+ isV2Endpoint = true
93+
94+ if strings.Contains(p.Scope, " groups") {
95+ logger.Print("WARNING: `groups` scope is not an accepted scope when using Azure OAuth V2 endpoint. Removing it from the scope list")
96+ p.Scope = strings.ReplaceAll(p.Scope, " groups", "")
97+ }
98+
99+ if !strings.Contains(p.Scope, " "+azureV2Scope) {
100+ // In order to be able to query MS Graph we must pass the ms graph default endpoint
101+ p.Scope += " " + azureV2Scope
102+ }
103+
104+ if p.ProtectedResource != nil && p.ProtectedResource.String() != "" {
105+ logger.Print("WARNING: `--resource` option has no effect when using the Azure OAuth V2 endpoint.")
106+ }
107+ }
108+
91109 return &AzureProvider{
92− ProviderData: p,
93− Tenant: tenant,
110+ ProviderData: p,
111+ Tenant: tenant,
112+ GraphGroupField: graphGroupField,
113+ isV2Endpoint: isV2Endpoint,
94114 }
95115}
96116
@@ -103,8 +123,26 @@ func overrideTenantURL(current, defaultURL *url.URL, tenant, path string) {
103123 }
104124}
105125
126+func getMicrosoftGraphGroupsURL(graphGroupField string) *url.URL {
127+
128+ selectStatement := "$select=displayName,id"
129+ if !slices.Contains([]string{"displayName", "id"}, graphGroupField) {
130+ selectStatement += "," + graphGroupField
131+ }
132+
133+ // Select only security groups. Due to the filter option, count param is mandatory even if unused otherwise
134+ return &url.URL{
135+ Scheme: "https",
136+ Host: "graph.microsoft.com",
137+ Path: "/v1.0/me/transitiveMemberOf",
138+ RawQuery: "$count=true&$filter=securityEnabled+eq+true&" + selectStatement,
139+ }
140+}
141+
106142func (p *AzureProvider) GetLoginURL(redirectURI, state, _ string, extraParams url.Values) string {
107− if p.ProtectedResource != nil && p.ProtectedResource.String() != "" {
143+ // In azure oauth v2 there is no resource param so add it only if V1 endpoint
144+ // https://docs.microsoft.com/en-us/azure/active-directory/azuread-dev/azure-ad-endpoint-comparison#scopes-not-resources
145+ if p.ProtectedResource != nil && p.ProtectedResource.String() != "" && !p.isV2Endpoint {
108146 extraParams.Add("resource", p.ProtectedResource.String())
109147 }
110148 a := makeLoginURL(p.ProviderData, redirectURI, state, extraParams)
@@ -145,45 +183,39 @@ func (p *AzureProvider) Redeem(ctx context.Context, redirectURL, code, codeVerif
145183 session.CreatedAtNow()
146184 session.SetExpiresOn(time.Unix(jsonResponse.ExpiresOn, 0))
147185
148− email, err := p.verifyTokenAndExtractEmail(ctx, session.IDToken, session.AccessToken)
186+ err = p.extractClaimsIntoSession(ctx, session)
149187
150− // https://github.com/oauth2-proxy/oauth2-proxy/pull/914#issuecomment-782285814
151− // https://github.com/AzureAD/azure-activedirectory-library-for-java/issues/117
152− // due to above issues, id_token may not be signed by AAD
153− // in that case, we will fallback to access token
154− if err == nil && email != "" {
155− session.Email = email
156− } else {
157− logger.Printf("unable to get email claim from id_token: %v", err)
158− }
159−
160− if session.Email == "" {
161− email, err = p.verifyTokenAndExtractEmail(ctx, session.AccessToken, session.AccessToken)
162− if err == nil && email != "" {
163− session.Email = email
164− } else {
165− logger.Printf("unable to get email claim from access token: %v", err)
166− }
188+ if err != nil {
189+ return nil, fmt.Errorf("unable to get email and/or groups claims from token: %v", err)
167190 }
168191
169192 return session, nil
170193}
171194
172−// EnrichSession finds the email to enrich the session state
173−func (p *AzureProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
174− if s.Email != "" {
175− return nil
176− }
195+// EnrichSession enriches the session state with userID, mail and groups
196+func (p *AzureProvider) EnrichSession(ctx context.Context, session *sessions.SessionState) error {
197+ err := p.extractClaimsIntoSession(ctx, session)
177198
178− email, err := p.getEmailFromProfileAPI(ctx, s.AccessToken)
179199 if err != nil {
180− return fmt.Errorf("unable to get email address: %v", err)
200+ logger.Printf("unable to get email and/or groups claims from token: %v", err)
181201 }
182− if email == "" {
183− return errors.New("unable to get email address")
202+
203+ if session.Email == "" {
204+ email, err := p.getEmailFromProfileAPI(ctx, session.AccessToken)
205+ if err != nil {
206+ return fmt.Errorf("unable to get email address from profile URL: %v", err)
207+ }
208+ session.Email = email
184209 }
185− s.Email = email
186210
211+ // If using the v2.0 oidc endpoint we're also querying Microsoft Graph
212+ if p.isV2Endpoint {
213+ groups, err := p.getGroupsFromProfileAPI(ctx, session)
214+ if err != nil {
215+ return fmt.Errorf("unable to get groups from Microsoft Graph: %v", err)
216+ }
217+ session.Groups = util.RemoveDuplicateStr(append(session.Groups, groups...))
218+ }
187219 return nil
188220}
189221
@@ -205,33 +237,66 @@ func (p *AzureProvider) prepareRedeem(redirectURL, code, codeVerifier string) (u
205237 if codeVerifier != "" {
206238 params.Add("code_verifier", codeVerifier)
207239 }
208− if p.ProtectedResource != nil && p.ProtectedResource.String() != "" {
240+
241+ // In azure oauth v2 there is no resource param so add it only if V1 endpoint
242+ // https://docs.microsoft.com/en-us/azure/active-directory/azuread-dev/azure-ad-endpoint-comparison#scopes-not-resources
243+ if p.ProtectedResource != nil && p.ProtectedResource.String() != "" && !p.isV2Endpoint {
209244 params.Add("resource", p.ProtectedResource.String())
210245 }
246+
211247 return params, nil
212248}
213249
214−// verifyTokenAndExtractEmail tries to extract email claim from either id_token or access token
250+// extractClaimsIntoSession tries to extract email and groups claims from either id_token or access token
215251// when oidc verifier is configured
216−func (p *AzureProvider) verifyTokenAndExtractEmail(ctx context.Context, rawIDToken string, accessToken string) (string, error) {
217− email := ""
218−
219− if rawIDToken != "" && p.Verifier != nil {
220− _, err := p.Verifier.Verify(ctx, rawIDToken)
221− // due to issues mentioned above, id_token may not be signed by AAD
222− if err == nil {
223− s, err := p.buildSessionFromClaims(rawIDToken, accessToken)
224− if err == nil {
225− email = s.Email
226− } else {
227− logger.Printf("unable to get claims from token: %v", err)
252+func (p *AzureProvider) extractClaimsIntoSession(ctx context.Context, session *sessions.SessionState) error {
253+
254+ var s *sessions.SessionState
255+
256+ // First let's verify session token
257+ if err := p.verifySessionToken(ctx, session); err != nil {
258+ return fmt.Errorf("unable to verify token: %v", err)
259+ }
260+
261+ // https://github.com/oauth2-proxy/oauth2-proxy/pull/914#issuecomment-782285814
262+ // https://github.com/AzureAD/azure-activedirectory-library-for-java/issues/117
263+ // due to above issues, id_token may not be signed by AAD
264+ // in that case, we will fallback to access token
265+ var err error
266+ s, err = p.buildSessionFromClaims(session.IDToken, session.AccessToken)
267+ if err != nil || s.Email == "" {
268+ s, err = p.buildSessionFromClaims(session.AccessToken, session.AccessToken)
269+ }
270+ if err != nil {
271+ return fmt.Errorf("unable to get claims from token: %v", err)
272+ }
273+
274+ session.Email = s.Email
275+ if s.Groups != nil {
276+ session.Groups = s.Groups
277+ }
278+
279+ return nil
280+}
281+
282+// verifySessionToken tries to validate id_token if present or access token when oidc verifier is configured
283+func (p *AzureProvider) verifySessionToken(ctx context.Context, session *sessions.SessionState) error {
284+ // Without a verifier there's no way to verify
285+ if p.Verifier == nil {
286+ return nil
287+ }
288+
289+ if session.IDToken != "" {
290+ if _, err := p.Verifier.Verify(ctx, session.IDToken); err != nil {
291+ logger.Printf("unable to verify ID token, fallback to access token: %v", err)
292+ if _, err = p.Verifier.Verify(ctx, session.AccessToken); err != nil {
293+ return fmt.Errorf("unable to verify access token: %v", err)
228294 }
229− } else {
230− logger.Printf("unable to verify token: %v", err)
231295 }
296+ } else if _, err := p.Verifier.Verify(ctx, session.AccessToken); err != nil {
297+ return fmt.Errorf("unable to verify access token: %v", err)
232298 }
233−
234− return email, nil
299+ return nil
235300}
236301
237302// 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
285350 s.CreatedAtNow()
286351 s.SetExpiresOn(time.Unix(jsonResponse.ExpiresOn, 0))
287352
288− email, err := p.verifyTokenAndExtractEmail(ctx, s.IDToken, s.AccessToken)
353+ err = p.extractClaimsIntoSession(ctx, s)
289354
290− // https://github.com/oauth2-proxy/oauth2-proxy/pull/914#issuecomment-782285814
291− // https://github.com/AzureAD/azure-activedirectory-library-for-java/issues/117
292− // due to above issues, id_token may not be signed by AAD
293− // in that case, we will fallback to access token
294− if err == nil && email != "" {
295− s.Email = email
296− } else {
297− logger.Printf("unable to get email claim from id_token: %v", err)
298− }
299−
300− if s.Email == "" {
301− email, err = p.verifyTokenAndExtractEmail(ctx, s.AccessToken, s.AccessToken)
302− if err == nil && email != "" {
303− s.Email = email
304− } else {
305− logger.Printf("unable to get email claim from access token: %v", err)
306− }
355+ if err != nil {
356+ logger.Printf("unable to get email and/or groups claims from token: %v", err)
307357 }
308358
309359 return nil
@@ -313,34 +363,55 @@ func makeAzureHeader(accessToken string) http.Header {
313363 return makeAuthorizationHeader(tokenTypeBearer, accessToken, nil)
314364}
315365
316−func getEmailFromJSON(json *simplejson.Json) (string, error) {
317− var email string
318− var err error
366+func (p *AzureProvider) getGroupsFromProfileAPI(ctx context.Context, s *sessions.SessionState) ([]string, error) {
367+ if s.AccessToken == "" {
368+ return nil, fmt.Errorf("missing access token")
369+ }
319370
320− email, err = json.Get("mail").String()
371+ groupsURL := getMicrosoftGraphGroupsURL(p.GraphGroupField).String()
321372
322− if err != nil || email == "" {
323− otherMails, otherMailsErr := json.Get("otherMails").Array()
324− if len(otherMails) > 0 {
325− email = otherMails[0].(string)
326− }
327− err = otherMailsErr
328− }
373+ // Need and extra header while talking with MS Graph. For more context see
374+ // https://docs.microsoft.com/en-us/graph/api/group-list-transitivememberof?view=graph-rest-1.0&tabs=http#request-headers
375+ extraHeader := makeAzureHeader(s.AccessToken)
376+ extraHeader.Add("ConsistencyLevel", "eventual")
329377
330− if err != nil || email == "" {
331− email, err = json.Get("userPrincipalName").String()
378+ var groups []string
379+
380+ for groupsURL != "" {
381+ jsonRequest, err := requests.New(groupsURL).
382+ WithContext(ctx).
383+ WithHeaders(extraHeader).
384+ Do().
385+ UnmarshalSimpleJSON()
332386 if err != nil {
333− logger.Errorf("unable to find userPrincipalName: %s", err)
334− return "", err
387+ return nil, fmt.Errorf("unable to unmarshal Microsoft Graph response: %v", err)
388+
335389 }
390+ groupsURL, err = jsonRequest.Get("@odata.nextLink").String()
391+ if err != nil {
392+ groupsURL = ""
393+ }
394+ groupsPage := getGroupsFromJSON(jsonRequest, p.GraphGroupField)
395+ groups = append(groups, groupsPage...)
396+ }
397+
398+ return groups, nil
399+}
400+
401+func getGroupsFromJSON(json *simplejson.Json, graphGroupField string) []string {
402+ groups := []string{}
403+
404+ for i := range json.Get("value").MustArray() {
405+ value := json.Get("value").GetIndex(i).Get(graphGroupField).MustString()
406+ groups = append(groups, value)
336407 }
337408
338− return email, err
409+ return groups
339410}
340411
341412func (p *AzureProvider) getEmailFromProfileAPI(ctx context.Context, accessToken string) (string, error) {
342413 if accessToken == "" {
343− return "", errors.New("missing access token")
414+ return "", fmt.Errorf("missing access token")
344415 }
345416
346417 json, err := requests.New(p.ProfileURL.String()).
@@ -352,7 +423,33 @@ func (p *AzureProvider) getEmailFromProfileAPI(ctx context.Context, accessToken
352423 return "", err
353424 }
354425
355− return getEmailFromJSON(json)
426+ email, err := getEmailFromJSON(json)
427+ if email == "" {
428+ return "", fmt.Errorf("empty email address: %v", err)
429+ }
430+ return email, nil
431+}
432+
433+func getEmailFromJSON(json *simplejson.Json) (string, error) {
434+ email, err := json.Get("mail").String()
435+
436+ if err != nil || email == "" {
437+ otherMails, otherMailsErr := json.Get("otherMails").Array()
438+ if len(otherMails) > 0 {
439+ email = otherMails[0].(string)
440+ }
441+ err = otherMailsErr
442+ }
443+
444+ if err != nil || email == "" {
445+ email, err = json.Get("userPrincipalName").String()
446+ if err != nil {
447+ logger.Errorf("unable to find userPrincipalName: %s", err)
448+ return "", err
449+ }
450+ }
451+
452+ return email, nil
356453}
357454
358455// ValidateSession validates the AccessToken
providers/azure_test.go+55−22
@@ -55,6 +55,7 @@ func testAzureProvider(hostname string, opts options.AzureOptions) *AzureProvide
5555 ProtectedResource: &url.URL{},
5656 Scope: "",
5757 EmailClaim: "email",
58+ GroupsClaim: "groups",
5859 Verifier: internaloidc.NewVerifier(oidc.NewVerifier(
5960 "https://issuer.example.com",
6061 fakeAzureKeySetStub{},
@@ -133,14 +134,9 @@ func TestAzureSetTenant(t *testing.T) {
133134 p := testAzureProvider("", options.AzureOptions{Tenant: "example"})
134135 assert.Equal(t, "Azure", p.Data().ProviderName)
135136 assert.Equal(t, "example", p.Tenant)
136− assert.Equal(t, "https://login.microsoftonline.com/example/oauth2/authorize",
137− p.Data().LoginURL.String())
138− assert.Equal(t, "https://login.microsoftonline.com/example/oauth2/token",
139− p.Data().RedeemURL.String())
140− assert.Equal(t, "https://graph.microsoft.com/v1.0/me",
141− p.Data().ProfileURL.String())
142− assert.Equal(t, "https://graph.microsoft.com",
143− p.Data().ProtectedResource.String())
137+ assert.Equal(t, "https://login.microsoftonline.com/example/oauth2/authorize", p.Data().LoginURL.String())
138+ assert.Equal(t, "https://login.microsoftonline.com/example/oauth2/token", p.Data().RedeemURL.String())
139+ assert.Equal(t, "https://graph.microsoft.com/v1.0/me", p.Data().ProfileURL.String())
144140 assert.Equal(t, "https://graph.microsoft.com/v1.0/me", p.Data().ValidateURL.String())
145141 assert.Equal(t, "openid", p.Data().Scope)
146142}
@@ -151,10 +147,25 @@ func testAzureBackend(payload string, accessToken, refreshToken string) *httptes
151147
152148func testAzureBackendWithError(payload string, accessToken, refreshToken string, injectError bool) *httptest.Server {
153149 path := "/v1.0/me"
150+ pathGroups := path + "/transitiveMemberOf/microsoft.graph.group"
154151
155152 return httptest.NewServer(http.HandlerFunc(
156153 func(w http.ResponseWriter, r *http.Request) {
157− if (r.URL.Path != path) && r.Method != http.MethodPost {
154+ if r.URL.Path == pathGroups && r.Method == http.MethodGet {
155+ w.Write([]byte(`{
156+ "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#groups(displayName,id)",
157+ "value": [
158+ {
159+ "displayName": "aa",
160+ "id": "11111111-2222-3333-4444-555555555555"
161+ },
162+ {
163+ "displayName": "bb",
164+ "id": "555555555555-4444-3333-2222-11111111"
165+ }
166+ ]
167+ }`))
168+ } else if (r.URL.Path != path) && r.Method != http.MethodPost {
158169 w.WriteHeader(404)
159170 } else if r.Method == http.MethodPost && r.Body != nil {
160171 if injectError {
@@ -183,7 +194,7 @@ func TestAzureProviderEnrichSession(t *testing.T) {
183194 }{
184195 {
185196 Description: "should return email using mail property from Azure backend",
186− PayloadFromAzureBackend: `{ "mail": "user@windows.net" }`,
197+ PayloadFromAzureBackend: `{ "mail": "user@windows.net", "groups": ["aa", "bb"] }`,
187198 ExpectedEmail: "user@windows.net",
188199 },
189200 {
@@ -199,17 +210,17 @@ func TestAzureProviderEnrichSession(t *testing.T) {
199210 {
200211 Description: "should return error when Azure backend doesn't return email information",
201212 PayloadFromAzureBackend: `{ "mail": null, "otherMails": [], "userPrincipalName": null }`,
202− ExpectedError: fmt.Errorf("unable to get email address: %v", errors.New("type assertion to string failed")),
213+ ExpectedError: fmt.Errorf("unable to get email address from profile URL: %v", errors.New("empty email address: type assertion to string failed")),
203214 },
204215 {
205216 Description: "should return specific error when unable to get email",
206217 PayloadFromAzureBackend: `{ "mail": null, "otherMails": [], "userPrincipalName": "" }`,
207− ExpectedError: errors.New("unable to get email address"),
218+ ExpectedError: errors.New("unable to get email address from profile URL: empty email address: <nil>"),
208219 },
209220 {
210221 Description: "should return error when otherMails from Azure backend is not a valid type",
211222 PayloadFromAzureBackend: `{ "mail": null, "otherMails": "", "userPrincipalName": null }`,
212− ExpectedError: fmt.Errorf("unable to get email address: %v", errors.New("type assertion to string failed")),
223+ ExpectedError: fmt.Errorf("unable to get email address from profile URL: %v", errors.New("empty email address: type assertion to string failed")),
213224 },
214225 {
215226 Description: "should not query profile api when email is already set in session",
@@ -224,13 +235,13 @@ func TestAzureProviderEnrichSession(t *testing.T) {
224235 b *httptest.Server
225236 host string
226237 )
227− if testCase.PayloadFromAzureBackend != "" {
228− b = testAzureBackend(testCase.PayloadFromAzureBackend, authorizedAccessToken, "")
229− defer b.Close()
230238
231− bURL, _ := url.Parse(b.URL)
232− host = bURL.Host
233− }
239+ b = testAzureBackend(testCase.PayloadFromAzureBackend, authorizedAccessToken, "")
240+ defer b.Close()
241+
242+ bURL, _ := url.Parse(b.URL)
243+ host = bURL.Host
244+
234245 p := testAzureProvider(host, options.AzureOptions{})
235246 session := CreateAuthorizedSession()
236247 session.Email = testCase.Email
@@ -250,18 +261,21 @@ func TestAzureProviderRedeem(t *testing.T) {
250261 EmailFromAccessToken string
251262 IsIDTokenMalformed bool
252263 InjectRedeemURLError bool
264+ Groups []string
253265 }{
254266 {
255267 Name: "with id_token returned",
256268 EmailFromIDToken: "foo1@example.com",
257269 RefreshToken: "some_refresh_token",
258270 ExpiresOn: time.Now().Add(time.Hour),
271+ Groups: []string{"aa", "bb"},
259272 },
260273 {
261274 Name: "without id_token returned, fallback to access token",
262275 EmailFromAccessToken: "foo2@example.com",
263276 RefreshToken: "some_refresh_token",
264277 ExpiresOn: time.Now().Add(time.Hour),
278+ Groups: []string{"aa", "bb"},
265279 },
266280 {
267281 Name: "id_token malformed, fallback to access token",
@@ -269,6 +283,7 @@ func TestAzureProviderRedeem(t *testing.T) {
269283 RefreshToken: "some_refresh_token",
270284 ExpiresOn: time.Now().Add(time.Hour),
271285 IsIDTokenMalformed: true,
286+ Groups: []string{"aa", "bb"},
272287 },
273288 {
274289 Name: "both id_token and access tokens are valid, return email from id_token",
@@ -276,6 +291,7 @@ func TestAzureProviderRedeem(t *testing.T) {
276291 EmailFromAccessToken: "foo3@example.com",
277292 RefreshToken: "some_refresh_token",
278293 ExpiresOn: time.Now().Add(time.Hour),
294+ Groups: []string{"aa", "bb"},
279295 },
280296 {
281297 Name: "redeem URL failed, should return error",
@@ -284,6 +300,7 @@ func TestAzureProviderRedeem(t *testing.T) {
284300 RefreshToken: "some_refresh_token",
285301 ExpiresOn: time.Now().Add(time.Hour),
286302 InjectRedeemURLError: true,
303+ Groups: []string{"aa", "bb"},
287304 },
288305 }
289306
@@ -295,7 +312,9 @@ func TestAzureProviderRedeem(t *testing.T) {
295312 var err error
296313 token := idTokenClaims{
297314 StandardClaims: jwt.StandardClaims{Audience: "cd6d4fae-f6a6-4a34-8454-2c6b598e9532"},
298− Email: testCase.EmailFromIDToken}
315+ Email: testCase.EmailFromIDToken,
316+ Groups: []string{"aa", "bb"},
317+ }
299318 idTokenString, err = newSignedTestIDToken(token)
300319 assert.NoError(t, err)
301320 }
@@ -303,7 +322,9 @@ func TestAzureProviderRedeem(t *testing.T) {
303322 var err error
304323 token := idTokenClaims{
305324 StandardClaims: jwt.StandardClaims{Audience: "cd6d4fae-f6a6-4a34-8454-2c6b598e9532"},
306− Email: testCase.EmailFromAccessToken}
325+ Email: testCase.EmailFromAccessToken,
326+ Groups: []string{"aa", "bb"},
327+ }
307328 accessTokenString, err = newSignedTestIDToken(token)
308329 assert.NoError(t, err)
309330 }
@@ -335,6 +356,7 @@ func TestAzureProviderRedeem(t *testing.T) {
335356 assert.Equal(t, accessTokenString, s.AccessToken)
336357 assert.Equal(t, testCase.ExpiresOn.Unix(), s.ExpiresOn.Unix())
337358 assert.Equal(t, testCase.RefreshToken, s.RefreshToken)
359+ assert.Equal(t, testCase.Groups, s.Groups)
338360 if testCase.EmailFromIDToken != "" {
339361 assert.Equal(t, testCase.EmailFromIDToken, s.Email)
340362 } else {
@@ -345,13 +367,24 @@ func TestAzureProviderRedeem(t *testing.T) {
345367 }
346368}
347369
348−func TestAzureProviderProtectedResourceConfigured(t *testing.T) {
370+func TestAzureProviderProtectedResourceConfiguredOAuthV1(t *testing.T) {
349371 p := testAzureProvider("", options.AzureOptions{})
350372 p.ProtectedResource, _ = url.Parse("http://my.resource.test")
351373 result := p.GetLoginURL("https://my.test.app/oauth", "", "", url.Values{})
352374 assert.Contains(t, result, "resource="+url.QueryEscape("http://my.resource.test"))
353375}
354376
377+func TestAzureProviderProtectedResourceConfiguredOAuthV2(t *testing.T) {
378+ p := testAzureProvider("", options.AzureOptions{})
379+ testURL := "http://my.resource.test"
380+ p.ProtectedResource, _ = url.Parse(testURL)
381+ p.isV2Endpoint = true
382+ result, _ := url.Parse(p.GetLoginURL("https://my.test.app/oauth", "", "", url.Values{}))
383+ parsedQuery, _ := url.ParseQuery(result.RawQuery)
384+ assert.NotContains(t, parsedQuery["scope"], " "+testURL)
385+ assert.NotContains(t, result.RawQuery, "resource="+url.QueryEscape(testURL))
386+}
387+
355388func TestAzureProviderRefresh(t *testing.T) {
356389 email := "foo@example.com"
357390 subject := "foo"
providers/provider_data.go+9−7
@@ -267,14 +267,16 @@ func (p *ProviderData) buildSessionFromClaims(rawIDToken, accessToken string) (*
267267 // considered unverified.
268268 verifyEmail := (p.EmailClaim == options.OIDCEmailClaim) && !p.AllowUnverifiedEmail
269269
270− var verified bool
271− exists, err := extractor.GetClaimInto("email_verified", &verified)
272− if err != nil {
273− return nil, err
274− }
270+ if verifyEmail {
271+ var verified bool
272+ exists, err := extractor.GetClaimInto("email_verified", &verified)
273+ if err != nil {
274+ return nil, err
275+ }
275276
276− if verifyEmail && exists && !verified {
277− return nil, fmt.Errorf("email in id_token (%s) isn't verified", ss.Email)
277+ if exists && !verified {
278+ return nil, fmt.Errorf("email in id_token (%s) isn't verified", ss.Email)
279+ }
278280 }
279281
280282 return ss, nil