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 - name: Check out code
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Set up Go 1.17 - name: Set up Go 1.18
uses: actions/setup-go@v2 uses: actions/setup-go@v2
with: with:
go-version: 1.17.x go-version: 1.18.x
id: go id: go
- name: Get dependencies - name: Get dependencies
run: | 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 curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
chmod +x ./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). - 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. - 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 ## Breaking Changes
N/A 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 - [#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) - [#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 - [#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 # V7.3.0

View File

@ -164,6 +164,7 @@ They may change between releases without notice.
| Field | Type | Description | | Field | Type | Description |
| ----- | ---- | ----------- | | ----- | ---- | ----------- |
| `tenant` | _string_ | Tenant directs to a tenant-specific or common (tenant-independent) endpoint<br/>Default value is 'common' | | `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 ### BitbucketOptions

View File

@ -72,23 +72,45 @@ Note: The user is checked against the group members list on initial authenticati
### Azure Auth Provider ### 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"**. 1. Add an application: go to [https://portal.azure.com](https://portal.azure.com), choose **Azure Active Directory**, select
2. Pick a name and choose **"Webapp / API"** as application type. Use `https://internal.yourcompany.com` as Sign-on URL. Click **"Create"**. **App registrations** and then click on **New registration**.
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"**. 2. Pick a name, check the supported account type(single-tenant, multi-tenant, etc). In the **Redirect URI** section create a new
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). **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! <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"**. 4. Next, if you are planning to use v2.0 Azure Auth endpoint, go to the **Manifest** page and set `"accessTokenAcceptedVersion": 2`
6. On the **"Settings"** / **"Keys"** page of the app, add a new key and note down the value after hitting **"Save"**. in the App registration manifest file.
7. Configure the proxy with 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 --provider=azure
--client-id=<application ID from step 3> --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}/ --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 ### ADFS Auth Provider

3
go.mod
View File

@ -30,6 +30,7 @@ require (
github.com/stretchr/testify v1.7.0 github.com/stretchr/testify v1.7.0
github.com/vmihailenco/msgpack/v4 v4.3.11 github.com/vmihailenco/msgpack/v4 v4.3.11
golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b 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/net v0.0.0-20221012135044-0b7e1fb9d458
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a golang.org/x/sync v0.0.0-20201207232520-09787c993a3a
@ -70,7 +71,7 @@ require (
go.opentelemetry.io/otel v0.11.0 // indirect go.opentelemetry.io/otel v0.11.0 // indirect
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
golang.org/x/text v0.3.8 // 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/appengine v1.6.5 // indirect
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 // indirect google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 // indirect
google.golang.org/grpc v1.27.0 // 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.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.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.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.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.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.1.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= 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-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-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-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-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/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= 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/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-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-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-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.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.20.0 h1:jz2KixHX7EcCPiQrySzPdnYT7DbINAypCqKZ1Z7GM40= google.golang.org/api v0.20.0 h1:jz2KixHX7EcCPiQrySzPdnYT7DbINAypCqKZ1Z7GM40=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 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"` KeycloakGroups []string `flag:"keycloak-group" cfg:"keycloak_groups"`
AzureTenant string `flag:"azure-tenant" cfg:"azure_tenant"` 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"` BitbucketTeam string `flag:"bitbucket-team" cfg:"bitbucket_team"`
BitbucketRepository string `flag:"bitbucket-repository" cfg:"bitbucket_repository"` BitbucketRepository string `flag:"bitbucket-repository" cfg:"bitbucket_repository"`
GitHubOrg string `flag:"github-org" cfg:"github_org"` 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.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-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-team", "", "restrict logins to members of this team")
flagSet.String("bitbucket-repository", "", "restrict logins to user with access to this repository") flagSet.String("bitbucket-repository", "", "restrict logins to user with access to this repository")
flagSet.String("github-org", "", "restrict logins to members of this organisation") flagSet.String("github-org", "", "restrict logins to members of this organisation")
@ -677,6 +679,7 @@ func (l *LegacyProvider) convert() (Providers, error) {
// that needs to be added from legacy options // that needs to be added from legacy options
provider.AzureConfig = AzureOptions{ provider.AzureConfig = AzureOptions{
Tenant: l.AzureTenant, Tenant: l.AzureTenant,
GraphGroupField: l.AzureGraphGroupField,
} }
switch provider.Type { switch provider.Type {

View File

@ -142,6 +142,9 @@ type AzureOptions struct {
// Tenant directs to a tenant-specific or common (tenant-independent) endpoint // Tenant directs to a tenant-specific or common (tenant-independent) endpoint
// Default value is 'common' // Default value is 'common'
Tenant string `json:"tenant,omitempty"` 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 { type ADFSOptions struct {

View File

@ -149,3 +149,16 @@ func isHostnameAllowed(hostname, allowedHost string) bool {
return false 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,23 +3,28 @@ package providers
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"time" "time"
"golang.org/x/exp/slices"
"github.com/bitly/go-simplejson" "github.com/bitly/go-simplejson"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" "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/apis/sessions"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" "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/requests"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
) )
// AzureProvider represents an Azure based Identity Provider // AzureProvider represents an Azure based Identity Provider
type AzureProvider struct { type AzureProvider struct {
*ProviderData *ProviderData
Tenant string Tenant string
GraphGroupField string
isV2Endpoint bool
} }
var _ Provider = (*AzureProvider)(nil) var _ Provider = (*AzureProvider)(nil)
@ -27,39 +32,31 @@ var _ Provider = (*AzureProvider)(nil)
const ( const (
azureProviderName = "Azure" azureProviderName = "Azure"
azureDefaultScope = "openid" azureDefaultScope = "openid"
azureDefaultGraphGroupField = "id"
azureV2Scope = "https://graph.microsoft.com/.default"
) )
var ( var (
// Default Login URL for Azure. // Default Login URL for Azure. Pre-parsed URL of https://login.microsoftonline.com/common/oauth2/authorize.
// Pre-parsed URL of https://login.microsoftonline.com/common/oauth2/authorize.
azureDefaultLoginURL = &url.URL{ azureDefaultLoginURL = &url.URL{
Scheme: "https", Scheme: "https",
Host: "login.microsoftonline.com", Host: "login.microsoftonline.com",
Path: "/common/oauth2/authorize", Path: "/common/oauth2/authorize",
} }
// Default Redeem URL for Azure. // Default Redeem URL for Azure. Pre-parsed URL of https://login.microsoftonline.com/common/oauth2/token.
// Pre-parsed URL of https://login.microsoftonline.com/common/oauth2/token.
azureDefaultRedeemURL = &url.URL{ azureDefaultRedeemURL = &url.URL{
Scheme: "https", Scheme: "https",
Host: "login.microsoftonline.com", Host: "login.microsoftonline.com",
Path: "/common/oauth2/token", Path: "/common/oauth2/token",
} }
// Default Profile URL for Azure. // Default Profile URL for Azure. Pre-parsed URL of https://graph.microsoft.com/v1.0/me.
// Pre-parsed URL of https://graph.microsoft.com/v1.0/me.
azureDefaultProfileURL = &url.URL{ azureDefaultProfileURL = &url.URL{
Scheme: "https", Scheme: "https",
Host: "graph.microsoft.com", Host: "graph.microsoft.com",
Path: "/v1.0/me", 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 // NewAzureProvider initiates a new AzureProvider
@ -73,9 +70,6 @@ func NewAzureProvider(p *ProviderData, opts options.AzureOptions) *AzureProvider
scope: azureDefaultScope, scope: azureDefaultScope,
}) })
if p.ProtectedResource == nil || p.ProtectedResource.String() == "" {
p.ProtectedResource = azureDefaultProtectResourceURL
}
if p.ValidateURL == nil || p.ValidateURL.String() == "" { if p.ValidateURL == nil || p.ValidateURL.String() == "" {
p.ValidateURL = p.ProfileURL p.ValidateURL = p.ProfileURL
} }
@ -88,9 +82,35 @@ func NewAzureProvider(p *ProviderData, opts options.AzureOptions) *AzureProvider
overrideTenantURL(p.RedeemURL, azureDefaultRedeemURL, tenant, "token") 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{ return &AzureProvider{
ProviderData: p, ProviderData: p,
Tenant: tenant, 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 { 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()) extraParams.Add("resource", p.ProtectedResource.String())
} }
a := makeLoginURL(p.ProviderData, redirectURI, state, extraParams) a := makeLoginURL(p.ProviderData, redirectURI, state, extraParams)
@ -145,45 +183,39 @@ func (p *AzureProvider) Redeem(ctx context.Context, redirectURL, code, codeVerif
session.CreatedAtNow() session.CreatedAtNow()
session.SetExpiresOn(time.Unix(jsonResponse.ExpiresOn, 0)) 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 if err != nil {
// https://github.com/AzureAD/azure-activedirectory-library-for-java/issues/117 return nil, fmt.Errorf("unable to get email and/or groups claims from token: %v", err)
// 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)
}
} }
return session, nil return session, nil
} }
// EnrichSession finds the email to enrich the session state // EnrichSession enriches the session state with userID, mail and groups
func (p *AzureProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error { func (p *AzureProvider) EnrichSession(ctx context.Context, session *sessions.SessionState) error {
if s.Email != "" { err := p.extractClaimsIntoSession(ctx, session)
return nil
}
email, err := p.getEmailFromProfileAPI(ctx, s.AccessToken)
if err != nil { 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 return nil
} }
@ -205,33 +237,66 @@ func (p *AzureProvider) prepareRedeem(redirectURL, code, codeVerifier string) (u
if codeVerifier != "" { if codeVerifier != "" {
params.Add("code_verifier", 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()) params.Add("resource", p.ProtectedResource.String())
} }
return params, nil 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 // when oidc verifier is configured
func (p *AzureProvider) verifyTokenAndExtractEmail(ctx context.Context, rawIDToken string, accessToken string) (string, error) { func (p *AzureProvider) extractClaimsIntoSession(ctx context.Context, session *sessions.SessionState) error {
email := ""
if rawIDToken != "" && p.Verifier != nil { var s *sessions.SessionState
_, err := p.Verifier.Verify(ctx, rawIDToken)
// due to issues mentioned above, id_token may not be signed by AAD // First let's verify session token
if err == nil { if err := p.verifySessionToken(ctx, session); err != nil {
s, err := p.buildSessionFromClaims(rawIDToken, accessToken) return fmt.Errorf("unable to verify token: %v", err)
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)
}
} }
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 // 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.CreatedAtNow()
s.SetExpiresOn(time.Unix(jsonResponse.ExpiresOn, 0)) 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 if err != nil {
// https://github.com/AzureAD/azure-activedirectory-library-for-java/issues/117 logger.Printf("unable to get email and/or groups claims from token: %v", err)
// 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)
}
} }
return nil return nil
@ -313,11 +363,75 @@ func makeAzureHeader(accessToken string) http.Header {
return makeAuthorizationHeader(tokenTypeBearer, accessToken, nil) return makeAuthorizationHeader(tokenTypeBearer, accessToken, nil)
} }
func getEmailFromJSON(json *simplejson.Json) (string, error) { func (p *AzureProvider) getGroupsFromProfileAPI(ctx context.Context, s *sessions.SessionState) ([]string, error) {
var email string if s.AccessToken == "" {
var err error 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 == "" { if err != nil || email == "" {
otherMails, otherMailsErr := json.Get("otherMails").Array() otherMails, otherMailsErr := json.Get("otherMails").Array()
@ -335,24 +449,7 @@ func getEmailFromJSON(json *simplejson.Json) (string, error) {
} }
} }
return email, err return email, nil
}
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)
} }
// ValidateSession validates the AccessToken // ValidateSession validates the AccessToken

View File

@ -55,6 +55,7 @@ func testAzureProvider(hostname string, opts options.AzureOptions) *AzureProvide
ProtectedResource: &url.URL{}, ProtectedResource: &url.URL{},
Scope: "", Scope: "",
EmailClaim: "email", EmailClaim: "email",
GroupsClaim: "groups",
Verifier: internaloidc.NewVerifier(oidc.NewVerifier( Verifier: internaloidc.NewVerifier(oidc.NewVerifier(
"https://issuer.example.com", "https://issuer.example.com",
fakeAzureKeySetStub{}, fakeAzureKeySetStub{},
@ -133,14 +134,9 @@ func TestAzureSetTenant(t *testing.T) {
p := testAzureProvider("", options.AzureOptions{Tenant: "example"}) p := testAzureProvider("", options.AzureOptions{Tenant: "example"})
assert.Equal(t, "Azure", p.Data().ProviderName) assert.Equal(t, "Azure", p.Data().ProviderName)
assert.Equal(t, "example", p.Tenant) assert.Equal(t, "example", p.Tenant)
assert.Equal(t, "https://login.microsoftonline.com/example/oauth2/authorize", assert.Equal(t, "https://login.microsoftonline.com/example/oauth2/authorize", p.Data().LoginURL.String())
p.Data().LoginURL.String()) assert.Equal(t, "https://login.microsoftonline.com/example/oauth2/token", p.Data().RedeemURL.String())
assert.Equal(t, "https://login.microsoftonline.com/example/oauth2/token", assert.Equal(t, "https://graph.microsoft.com/v1.0/me", p.Data().ProfileURL.String())
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://graph.microsoft.com/v1.0/me", p.Data().ValidateURL.String()) assert.Equal(t, "https://graph.microsoft.com/v1.0/me", p.Data().ValidateURL.String())
assert.Equal(t, "openid", p.Data().Scope) 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 { func testAzureBackendWithError(payload string, accessToken, refreshToken string, injectError bool) *httptest.Server {
path := "/v1.0/me" path := "/v1.0/me"
pathGroups := path + "/transitiveMemberOf/microsoft.graph.group"
return httptest.NewServer(http.HandlerFunc( return httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { 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) w.WriteHeader(404)
} else if r.Method == http.MethodPost && r.Body != nil { } else if r.Method == http.MethodPost && r.Body != nil {
if injectError { if injectError {
@ -183,7 +194,7 @@ func TestAzureProviderEnrichSession(t *testing.T) {
}{ }{
{ {
Description: "should return email using mail property from Azure backend", 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", 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", Description: "should return error when Azure backend doesn't return email information",
PayloadFromAzureBackend: `{ "mail": null, "otherMails": [], "userPrincipalName": null }`, 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", Description: "should return specific error when unable to get email",
PayloadFromAzureBackend: `{ "mail": null, "otherMails": [], "userPrincipalName": "" }`, 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", Description: "should return error when otherMails from Azure backend is not a valid type",
PayloadFromAzureBackend: `{ "mail": null, "otherMails": "", "userPrincipalName": null }`, 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", 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 b *httptest.Server
host string host string
) )
if testCase.PayloadFromAzureBackend != "" {
b = testAzureBackend(testCase.PayloadFromAzureBackend, authorizedAccessToken, "") b = testAzureBackend(testCase.PayloadFromAzureBackend, authorizedAccessToken, "")
defer b.Close() defer b.Close()
bURL, _ := url.Parse(b.URL) bURL, _ := url.Parse(b.URL)
host = bURL.Host host = bURL.Host
}
p := testAzureProvider(host, options.AzureOptions{}) p := testAzureProvider(host, options.AzureOptions{})
session := CreateAuthorizedSession() session := CreateAuthorizedSession()
session.Email = testCase.Email session.Email = testCase.Email
@ -250,18 +261,21 @@ func TestAzureProviderRedeem(t *testing.T) {
EmailFromAccessToken string EmailFromAccessToken string
IsIDTokenMalformed bool IsIDTokenMalformed bool
InjectRedeemURLError bool InjectRedeemURLError bool
Groups []string
}{ }{
{ {
Name: "with id_token returned", Name: "with id_token returned",
EmailFromIDToken: "foo1@example.com", EmailFromIDToken: "foo1@example.com",
RefreshToken: "some_refresh_token", RefreshToken: "some_refresh_token",
ExpiresOn: time.Now().Add(time.Hour), ExpiresOn: time.Now().Add(time.Hour),
Groups: []string{"aa", "bb"},
}, },
{ {
Name: "without id_token returned, fallback to access token", Name: "without id_token returned, fallback to access token",
EmailFromAccessToken: "foo2@example.com", EmailFromAccessToken: "foo2@example.com",
RefreshToken: "some_refresh_token", RefreshToken: "some_refresh_token",
ExpiresOn: time.Now().Add(time.Hour), ExpiresOn: time.Now().Add(time.Hour),
Groups: []string{"aa", "bb"},
}, },
{ {
Name: "id_token malformed, fallback to access token", Name: "id_token malformed, fallback to access token",
@ -269,6 +283,7 @@ func TestAzureProviderRedeem(t *testing.T) {
RefreshToken: "some_refresh_token", RefreshToken: "some_refresh_token",
ExpiresOn: time.Now().Add(time.Hour), ExpiresOn: time.Now().Add(time.Hour),
IsIDTokenMalformed: true, IsIDTokenMalformed: true,
Groups: []string{"aa", "bb"},
}, },
{ {
Name: "both id_token and access tokens are valid, return email from id_token", 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", EmailFromAccessToken: "foo3@example.com",
RefreshToken: "some_refresh_token", RefreshToken: "some_refresh_token",
ExpiresOn: time.Now().Add(time.Hour), ExpiresOn: time.Now().Add(time.Hour),
Groups: []string{"aa", "bb"},
}, },
{ {
Name: "redeem URL failed, should return error", Name: "redeem URL failed, should return error",
@ -284,6 +300,7 @@ func TestAzureProviderRedeem(t *testing.T) {
RefreshToken: "some_refresh_token", RefreshToken: "some_refresh_token",
ExpiresOn: time.Now().Add(time.Hour), ExpiresOn: time.Now().Add(time.Hour),
InjectRedeemURLError: true, InjectRedeemURLError: true,
Groups: []string{"aa", "bb"},
}, },
} }
@ -295,7 +312,9 @@ func TestAzureProviderRedeem(t *testing.T) {
var err error var err error
token := idTokenClaims{ token := idTokenClaims{
StandardClaims: jwt.StandardClaims{Audience: "cd6d4fae-f6a6-4a34-8454-2c6b598e9532"}, StandardClaims: jwt.StandardClaims{Audience: "cd6d4fae-f6a6-4a34-8454-2c6b598e9532"},
Email: testCase.EmailFromIDToken} Email: testCase.EmailFromIDToken,
Groups: []string{"aa", "bb"},
}
idTokenString, err = newSignedTestIDToken(token) idTokenString, err = newSignedTestIDToken(token)
assert.NoError(t, err) assert.NoError(t, err)
} }
@ -303,7 +322,9 @@ func TestAzureProviderRedeem(t *testing.T) {
var err error var err error
token := idTokenClaims{ token := idTokenClaims{
StandardClaims: jwt.StandardClaims{Audience: "cd6d4fae-f6a6-4a34-8454-2c6b598e9532"}, StandardClaims: jwt.StandardClaims{Audience: "cd6d4fae-f6a6-4a34-8454-2c6b598e9532"},
Email: testCase.EmailFromAccessToken} Email: testCase.EmailFromAccessToken,
Groups: []string{"aa", "bb"},
}
accessTokenString, err = newSignedTestIDToken(token) accessTokenString, err = newSignedTestIDToken(token)
assert.NoError(t, err) assert.NoError(t, err)
} }
@ -335,6 +356,7 @@ func TestAzureProviderRedeem(t *testing.T) {
assert.Equal(t, accessTokenString, s.AccessToken) assert.Equal(t, accessTokenString, s.AccessToken)
assert.Equal(t, testCase.ExpiresOn.Unix(), s.ExpiresOn.Unix()) assert.Equal(t, testCase.ExpiresOn.Unix(), s.ExpiresOn.Unix())
assert.Equal(t, testCase.RefreshToken, s.RefreshToken) assert.Equal(t, testCase.RefreshToken, s.RefreshToken)
assert.Equal(t, testCase.Groups, s.Groups)
if testCase.EmailFromIDToken != "" { if testCase.EmailFromIDToken != "" {
assert.Equal(t, testCase.EmailFromIDToken, s.Email) assert.Equal(t, testCase.EmailFromIDToken, s.Email)
} else { } 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 := testAzureProvider("", options.AzureOptions{})
p.ProtectedResource, _ = url.Parse("http://my.resource.test") p.ProtectedResource, _ = url.Parse("http://my.resource.test")
result := p.GetLoginURL("https://my.test.app/oauth", "", "", url.Values{}) result := p.GetLoginURL("https://my.test.app/oauth", "", "", url.Values{})
assert.Contains(t, result, "resource="+url.QueryEscape("http://my.resource.test")) 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) { func TestAzureProviderRefresh(t *testing.T) {
email := "foo@example.com" email := "foo@example.com"
subject := "foo" subject := "foo"

View File

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