Skip to content

Commit c4c13ff

Browse files
author
Oleg Skoromnik
committed
Add new azure flag to obtain groups from the oidc ticket
It is possible in azure to configure an optional claim such as groups. In this case there is no need to make a request to Microsoft Graph api to obtain groups. Instead azure will send the configured groups as a part of oidc ticket directly. This is usefull when it is not possible to obtain GroupMember.Read.All permissions for the applictaion. Here we provide a new flag `azure-groups-in-ticket`, which does exactly this. When the flag is set the request to the Graph api is not done and instead the groups are taken from the session directly.
1 parent 84e1cc2 commit c4c13ff

File tree

7 files changed

+72
-17
lines changed

7 files changed

+72
-17
lines changed

contrib/oauth2-proxy_autocomplete.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ _oauth2_proxy() {
2424
COMPREPLY=( $(compgen -W 'X-Real-IP X-Forwarded-For X-ProxyUser-IP' -- ${cur}) )
2525
return 0
2626
;;
27-
--@(http-address|https-address|redirect-url|upstream|basic-auth-password|skip-auth-regex|flush-interval|extra-jwt-issuers|email-domain|whitelist-domain|trusted-ip|keycloak-group|azure-tenant|bitbucket-team|bitbucket-repository|github-org|github-team|github-repo|github-token|gitlab-group|github-user|google-group|google-admin-email|google-service-account-json|client-id|client_secret|banner|footer|proxy-prefix|ping-path|ready-path|cookie-name|cookie-secret|cookie-domain|cookie-path|cookie-expire|cookie-refresh|cookie-samesite|redist-sentinel-master-name|redist-sentinel-connection-urls|redist-cluster-connection-urls|logging-max-size|logging-max-age|logging-max-backups|standard-logging-format|request-logging-format|exclude-logging-paths|auth-logging-format|oidc-issuer-url|oidc-jwks-url|login-url|redeem-url|profile-url|resource|validate-url|scope|approval-prompt|signature-key|acr-values|jwt-key|pubjwk-url|force-json-errors))
27+
--@(http-address|https-address|redirect-url|upstream|basic-auth-password|skip-auth-regex|flush-interval|extra-jwt-issuers|email-domain|whitelist-domain|trusted-ip|keycloak-group|azure-tenant|azure-groups-in-ticket|bitbucket-team|bitbucket-repository|github-org|github-team|github-repo|github-token|gitlab-group|github-user|google-group|google-admin-email|google-service-account-json|client-id|client_secret|banner|footer|proxy-prefix|ping-path|ready-path|cookie-name|cookie-secret|cookie-domain|cookie-path|cookie-expire|cookie-refresh|cookie-samesite|redist-sentinel-master-name|redist-sentinel-connection-urls|redist-cluster-connection-urls|logging-max-size|logging-max-age|logging-max-backups|standard-logging-format|request-logging-format|exclude-logging-paths|auth-logging-format|oidc-issuer-url|oidc-jwks-url|login-url|redeem-url|profile-url|resource|validate-url|scope|approval-prompt|signature-key|acr-values|jwt-key|pubjwk-url|force-json-errors))
2828
return 0
2929
;;
3030
esac

docs/docs/configuration/overview.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
7474
| `--auth-logging-format` | string | Template for authentication log lines | see [Logging Configuration](#logging-configuration) |
7575
| `--authenticated-emails-file` | string | authenticate against emails via file (one per line) | |
7676
| `--azure-tenant` | string | go to a tenant-specific or common (tenant-independent) endpoint. | `"common"` |
77+
| `--azure-groups-in-ticket` | bool | If true do not fetch groups from Microsoft graph api, but instead take them from the oidc ticket, see [docs](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims#configure-groups-optional-claims) | `false` |
7778
| `--backend-logout-url` | string | URL to perform backend logout, if you use `{id_token}` in the url it will be replaced by the actual `id_token` of the user session | |
7879
| `--basic-auth-password` | string | the password to set when passing the HTTP Basic Auth header | |
7980
| `--client-id` | string | the OAuth Client ID, e.g. `"123456.apps.googleusercontent.com"` | |

docs/docs/configuration/providers/azure.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ title: Azure
1414
<br/>**IMPORTANT**: Even if this permission is listed with **"Admin consent required=No"** the consent might actually
1515
be required, due to AAD policies you won't be able to see. If you get a **"Need admin approval"** during login,
1616
most likely this is what you're missing!
17+
18+
Alternatively to require **Group.Read.All** we can configure that the groups come as a part of the oidc ticket itself.
19+
This is enabled by providing a command line argument `--azure-groups-in-ticket`. In this case there will be no request
20+
to Microsoft Graph api to obtain the groups that the user have.
21+
For this go to **App registrations** and select your application. Then in **Manage** select **Token configuration** and
22+
click on **+ Add groups claim**. On the right a menu will open where you can select the group claims. Typically it makes sense
23+
to choose **Groups assigned to the application** in order not to exceed the number of groups that can be placed in the
24+
oidc ticket itself.
1725
4. Next, if you are planning to use v2.0 Azure Auth endpoint, go to the **Manifest** page and set `"accessTokenAcceptedVersion": 2`
1826
in the App registration manifest file.
1927
5. On the **Certificates & secrets** page of the app, add a new client secret and note down the value after hitting **Add**.

pkg/apis/options/legacy_options.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,7 @@ type LegacyProvider struct {
488488
KeycloakGroups []string `flag:"keycloak-group" cfg:"keycloak_groups"`
489489
AzureTenant string `flag:"azure-tenant" cfg:"azure_tenant"`
490490
AzureGraphGroupField string `flag:"azure-graph-group-field" cfg:"azure_graph_group_field"`
491+
AzureGroupsInTicket bool `flag:"azure-groups-in-ticket" cfg:"azure_groups_in_ticket"`
491492
BitbucketTeam string `flag:"bitbucket-team" cfg:"bitbucket_team"`
492493
BitbucketRepository string `flag:"bitbucket-repository" cfg:"bitbucket_repository"`
493494
GitHubOrg string `flag:"github-org" cfg:"github_org"`
@@ -550,6 +551,7 @@ func legacyProviderFlagSet() *pflag.FlagSet {
550551
flagSet.StringSlice("keycloak-group", []string{}, "restrict logins to members of these groups (may be given multiple times)")
551552
flagSet.String("azure-tenant", "common", "go to a tenant-specific or common (tenant-independent) endpoint.")
552553
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")
554+
flagSet.Bool("azure-groups-in-ticket", false, "configures server to take groups from azure oidc ticket. It is possible in azure to configure that the groups are sent as a part of oidc ticket. When true request to graph api is not performed and the groups are taken from the ticket.")
553555
flagSet.String("bitbucket-team", "", "restrict logins to members of this team")
554556
flagSet.String("bitbucket-repository", "", "restrict logins to user with access to this repository")
555557
flagSet.String("github-org", "", "restrict logins to members of this organisation")
@@ -703,8 +705,9 @@ func (l *LegacyProvider) convert() (Providers, error) {
703705
// This part is out of the switch section because azure has a default tenant
704706
// that needs to be added from legacy options
705707
provider.AzureConfig = AzureOptions{
706-
Tenant: l.AzureTenant,
707-
GraphGroupField: l.AzureGraphGroupField,
708+
Tenant: l.AzureTenant,
709+
GraphGroupField: l.AzureGraphGroupField,
710+
AzureGroupsInTicket: l.AzureGroupsInTicket,
708711
}
709712

710713
switch provider.Type {

pkg/apis/options/providers.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@ type AzureOptions struct {
153153
// GraphGroupField configures the group field to be used when building the groups list from Microsoft Graph
154154
// Default value is 'id'
155155
GraphGroupField string `json:"graphGroupField,omitempty"`
156+
// AzureGroupsInTicket determines whether request to Microsoft Graph api
157+
// should be done to obtain user groups, or the groups should be taken
158+
// from the ticket
159+
// Default value is false
160+
AzureGroupsInTicket bool `json:"groupsInTicket,omitempty"`
156161
}
157162

158163
type ADFSOptions struct {

providers/azure.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type AzureProvider struct {
2626
Tenant string
2727
GraphGroupField string
2828
isV2Endpoint bool
29+
isGroupsInTicket bool
2930
}
3031

3132
var _ Provider = (*AzureProvider)(nil)
@@ -108,10 +109,11 @@ func NewAzureProvider(p *ProviderData, opts options.AzureOptions) *AzureProvider
108109
}
109110

110111
return &AzureProvider{
111-
ProviderData: p,
112-
Tenant: tenant,
113-
GraphGroupField: graphGroupField,
114-
isV2Endpoint: isV2Endpoint,
112+
ProviderData: p,
113+
Tenant: tenant,
114+
GraphGroupField: graphGroupField,
115+
isV2Endpoint: isV2Endpoint,
116+
isGroupsInTicket: opts.AzureGroupsInTicket,
115117
}
116118
}
117119

@@ -133,7 +135,7 @@ func getMicrosoftGraphGroupsURL(profileURL *url.URL, graphGroupField string) *ur
133135

134136
// Select only security groups. Due to the filter option, count param is mandatory even if unused otherwise
135137
return &url.URL{
136-
Scheme: "https",
138+
Scheme: profileURL.Scheme,
137139
Host: profileURL.Host,
138140
Path: "/v1.0/me/transitiveMemberOf",
139141
RawQuery: "$count=true&$filter=securityEnabled+eq+true&" + selectStatement,
@@ -210,7 +212,7 @@ func (p *AzureProvider) EnrichSession(ctx context.Context, session *sessions.Ses
210212
}
211213

212214
// If using the v2.0 oidc endpoint we're also querying Microsoft Graph
213-
if p.isV2Endpoint {
215+
if shouldCallGraphApi(p.isV2Endpoint, p.isGroupsInTicket) {
214216
groups, err := p.getGroupsFromProfileAPI(ctx, session)
215217
if err != nil {
216218
return fmt.Errorf("unable to get groups from Microsoft Graph: %v", err)
@@ -220,6 +222,10 @@ func (p *AzureProvider) EnrichSession(ctx context.Context, session *sessions.Ses
220222
return nil
221223
}
222224

225+
func shouldCallGraphApi(isV2Endpoint bool, isGroupsInTicket bool) bool {
226+
return isV2Endpoint && !isGroupsInTicket
227+
}
228+
223229
func (p *AzureProvider) prepareRedeem(redirectURL, code, codeVerifier string) (url.Values, error) {
224230
params := url.Values{}
225231
if code == "" {

providers/azure_test.go

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -141,17 +141,20 @@ func TestAzureSetTenant(t *testing.T) {
141141
assert.Equal(t, "openid", p.Data().Scope)
142142
}
143143

144-
func testAzureBackend(payload string, accessToken, refreshToken string) *httptest.Server {
145-
return testAzureBackendWithError(payload, accessToken, refreshToken, false)
144+
func testAzureBackend(t *testing.T, payload string, accessToken, refreshToken string) *httptest.Server {
145+
return testAzureBackendWithError(t, payload, accessToken, refreshToken, false, false)
146146
}
147147

148-
func testAzureBackendWithError(payload string, accessToken, refreshToken string, injectError bool) *httptest.Server {
148+
func testAzureBackendWithError(t *testing.T, payload string, accessToken, refreshToken string, injectError bool, noCallToGraphApi bool) *httptest.Server {
149149
path := "/v1.0/me"
150-
pathGroups := path + "/transitiveMemberOf/microsoft.graph.group"
150+
pathGroups := path + "/transitiveMemberOf"
151151

152152
return httptest.NewServer(http.HandlerFunc(
153153
func(w http.ResponseWriter, r *http.Request) {
154154
if r.URL.Path == pathGroups && r.Method == http.MethodGet {
155+
if noCallToGraphApi {
156+
assert.FailNow(t, "should be no call to graph api")
157+
}
155158
w.Write([]byte(`{
156159
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#groups(displayName,id)",
157160
"value": [
@@ -189,6 +192,9 @@ func TestAzureProviderEnrichSession(t *testing.T) {
189192
Description string
190193
Email string
191194
PayloadFromAzureBackend string
195+
IsV2Endpoint bool
196+
IsGroupsInTicket bool
197+
ExpectedGroups []string
192198
ExpectedEmail string
193199
ExpectedError error
194200
}{
@@ -197,6 +203,30 @@ func TestAzureProviderEnrichSession(t *testing.T) {
197203
PayloadFromAzureBackend: `{ "mail": "[email protected]", "groups": ["aa", "bb"] }`,
198204
ExpectedEmail: "[email protected]",
199205
},
206+
{
207+
Description: "should return groups from graph api for v2 endpoint",
208+
PayloadFromAzureBackend: `{ "mail": "[email protected]" }`,
209+
ExpectedEmail: "[email protected]",
210+
ExpectedGroups: []string{"11111111-2222-3333-4444-555555555555", "555555555555-4444-3333-2222-11111111"},
211+
IsV2Endpoint: true,
212+
IsGroupsInTicket: false,
213+
},
214+
{
215+
Description: "should be no call to graph api for v1 endpoint",
216+
PayloadFromAzureBackend: `{ "mail": "[email protected]" }`,
217+
ExpectedEmail: "[email protected]",
218+
ExpectedGroups: nil,
219+
IsV2Endpoint: false,
220+
IsGroupsInTicket: false,
221+
},
222+
{
223+
Description: "should be no call to graph api for v2 endpoint and groups in ticket",
224+
PayloadFromAzureBackend: `{ "mail": "[email protected]" }`,
225+
ExpectedEmail: "[email protected]",
226+
ExpectedGroups: nil,
227+
IsV2Endpoint: false,
228+
IsGroupsInTicket: true,
229+
},
200230
{
201231
Description: "should return email using otherMails property returned from Azure backend",
202232
PayloadFromAzureBackend: `{ "mail": null, "otherMails": ["[email protected]", "[email protected]"] }`,
@@ -236,18 +266,20 @@ func TestAzureProviderEnrichSession(t *testing.T) {
236266
host string
237267
)
238268

239-
b = testAzureBackend(testCase.PayloadFromAzureBackend, authorizedAccessToken, "")
269+
b = testAzureBackendWithError(t, testCase.PayloadFromAzureBackend, authorizedAccessToken, "", false, !shouldCallGraphApi(testCase.IsV2Endpoint, testCase.IsGroupsInTicket))
240270
defer b.Close()
241271

242272
bURL, _ := url.Parse(b.URL)
243273
host = bURL.Host
244274

245-
p := testAzureProvider(host, options.AzureOptions{})
275+
p := testAzureProvider(host, options.AzureOptions{AzureGroupsInTicket: testCase.IsGroupsInTicket})
276+
p.isV2Endpoint = testCase.IsV2Endpoint
246277
session := CreateAuthorizedSession()
247278
session.Email = testCase.Email
248279
err := p.EnrichSession(context.Background(), session)
249280
assert.Equal(t, testCase.ExpectedError, err)
250281
assert.Equal(t, testCase.ExpectedEmail, session.Email)
282+
assert.Equal(t, testCase.ExpectedGroups, session.Groups)
251283
})
252284
}
253285
}
@@ -341,7 +373,7 @@ func TestAzureProviderRedeem(t *testing.T) {
341373
payloadBytes, err := json.Marshal(payload)
342374
assert.NoError(t, err)
343375

344-
b := testAzureBackendWithError(string(payloadBytes), accessTokenString, testCase.RefreshToken, testCase.InjectRedeemURLError)
376+
b := testAzureBackendWithError(t, string(payloadBytes), accessTokenString, testCase.RefreshToken, testCase.InjectRedeemURLError, false)
345377
defer b.Close()
346378

347379
bURL, _ := url.Parse(b.URL)
@@ -412,7 +444,7 @@ func TestAzureProviderRefresh(t *testing.T) {
412444
assert.NoError(t, err)
413445

414446
refreshToken := "some_refresh_token"
415-
b := testAzureBackend(string(payloadBytes), newAccessToken, refreshToken)
447+
b := testAzureBackend(t, string(payloadBytes), newAccessToken, refreshToken)
416448
defer b.Close()
417449
bURL, _ := url.Parse(b.URL)
418450
p := testAzureProvider(bURL.Host, options.AzureOptions{})

0 commit comments

Comments
 (0)