@@ -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