1
0
mirror of https://github.com/oauth2-proxy/oauth2-proxy.git synced 2025-05-29 23:17:38 +02:00

Add azure groups support and oauth2 v2.0

This commit is contained in:
Adrian Aneci 2022-02-22 13:32:45 +02:00
parent 7fe6384f38
commit a5d918898c
12 changed files with 339 additions and 155 deletions

View File

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

View File

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

View File

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

View File

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

3
go.mod
View File

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

7
go.sum
View File

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

View File

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

View File

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

View File

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

View File

@ -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")
}
s.Email = email
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
}
// 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 := ""
func (p *AzureProvider) extractClaimsIntoSession(ctx context.Context, session *sessions.SessionState) error {
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)
}
} else {
logger.Printf("unable to verify token: %v", err)
}
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)
}
return email, nil
// 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 if _, err := p.Verifier.Verify(ctx, session.AccessToken); err != nil {
return fmt.Errorf("unable to verify access token: %v", err)
}
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,11 +363,75 @@ 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()
// 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")
var groups []string
for groupsURL != "" {
jsonRequest, err := requests.New(groupsURL).
WithContext(ctx).
WithHeaders(extraHeader).
Do().
UnmarshalSimpleJSON()
if err != nil {
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 groups
}
func (p *AzureProvider) getEmailFromProfileAPI(ctx context.Context, accessToken string) (string, error) {
if accessToken == "" {
return "", fmt.Errorf("missing access token")
}
json, err := requests.New(p.ProfileURL.String()).
WithContext(ctx).
WithHeaders(makeAzureHeader(accessToken)).
Do().
UnmarshalSimpleJSON()
if err != nil {
return "", err
}
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()
@ -335,24 +449,7 @@ func getEmailFromJSON(json *simplejson.Json) (string, error) {
}
}
return email, err
}
func (p *AzureProvider) getEmailFromProfileAPI(ctx context.Context, accessToken string) (string, error) {
if accessToken == "" {
return "", errors.New("missing access token")
}
json, err := requests.New(p.ProfileURL.String()).
WithContext(ctx).
WithHeaders(makeAzureHeader(accessToken)).
Do().
UnmarshalSimpleJSON()
if err != nil {
return "", err
}
return getEmailFromJSON(json)
return email, nil
}
// ValidateSession validates the AccessToken

View File

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

View File

@ -267,14 +267,16 @@ func (p *ProviderData) buildSessionFromClaims(rawIDToken, accessToken string) (*
// considered unverified.
verifyEmail := (p.EmailClaim == options.OIDCEmailClaim) && !p.AllowUnverifiedEmail
var verified bool
exists, err := extractor.GetClaimInto("email_verified", &verified)
if err != nil {
return nil, err
}
if verifyEmail {
var verified bool
exists, err := extractor.GetClaimInto("email_verified", &verified)
if err != nil {
return nil, err
}
if verifyEmail && exists && !verified {
return nil, fmt.Errorf("email in id_token (%s) isn't verified", ss.Email)
if exists && !verified {
return nil, fmt.Errorf("email in id_token (%s) isn't verified", ss.Email)
}
}
return ss, nil