git.iko.soy/typeable/oauth2-proxy
on master help sign in
Microsoft Entra ID provider (#2390)

* Microsoft Entra ID Provider * fix typo in function name * documentation tweaks * documentation and comment tweak * docs tweaks * final tweaks * refactor: drop flag for skipping graph groups * update legacy / deprecated provider page and sort provider overview * reformat * move entra-id provider into switch (treat like every other provider * fix test case and reformat * fix sidebar configuration * apply review suggestions * add pagination for graph api * fix: do not error when groups unable to retrieve * doc: number of groups fix * restore master packages * docs: tiny docs tweak * address review comments * fix codegen --------- Co-authored-by: tuunit <jan@larwig.com>

commit5f188e5b6ba3b97d31bc7239be73b02879fb4825 authorJJ Łakis <jacek.lakis@checkatrade.com> dateTue 31 Dec 2024, 12:46 (2 years ago) parentc64ec12
14 files changed+612−46
docs/docs/configuration/alpha_config.md+11 docs/docs/configuration/providers/azure_ad.md−18 docs/docs/configuration/providers/gitea.md+4 docs/docs/configuration/providers/index.md+9−8 docs/docs/configuration/providers/keycloak.md+2−2 docs/docs/configuration/providers/azure.md → docs/docs/configuration/providers/ms_azure_ad.md+5−1 docs/docs/configuration/providers/ms_entra_id.md+158 docs/sidebars.js+16−16 docs/src/css/custom.css+14 pkg/apis/options/legacy_options.go+6 pkg/apis/options/providers.go+13 providers/ms_entra_id.go+186 providers/ms_entra_id_test.go+185 providers/providers.go+3−1
docs/docs/configuration/alpha_config.md+11
@@ -385,6 +385,16 @@ character.
385385| `default` | _[]string_ | _(Optional)_ Default specifies a default value or values that will be<br/>passed to the IdP if not overridden. |
386386| `allow` | _[[]URLParameterRule](#urlparameterrule)_ | _(Optional)_ Allow specifies rules about how the default (if any) may be<br/>overridden via the query string to `/oauth2/start`. Only<br/>values that match one or more of the allow rules will be<br/>forwarded to the IdP. |
387387
388+### MicrosoftEntraIDOptions
389+
390+(**Appears on:** [Provider](#provider))
391+
392+
393+
394+| Field | Type | Description |
395+| ----- | ---- | ----------- |
396+| `allowedTenants` | _[]string_ | AllowedTenants is a list of allowed tenants. In case of multi-tenant apps, incoming tokens are<br/>issued by different issuers and OIDC issuer verification needs to be disabled.<br/>When not specified, all tenants are allowed. Redundant for single-tenant apps<br/>(regular ID token validation matches the issuer). |
397+
388398### OIDCOptions
389399
390400(**Appears on:** [Provider](#provider))
@@ -418,6 +428,7 @@ Provider holds all configuration for a single provider
418428| `clientSecretFile` | _string_ | ClientSecretFile is the name of the file<br/>containing the OAuth Client Secret, it will be used if ClientSecret is not set. |
419429| `keycloakConfig` | _[KeycloakOptions](#keycloakoptions)_ | KeycloakConfig holds all configurations for Keycloak provider. |
420430| `azureConfig` | _[AzureOptions](#azureoptions)_ | AzureConfig holds all configurations for Azure provider. |
431+| `microsoftEntraIDConfig` | _[MicrosoftEntraIDOptions](#microsoftentraidoptions)_ | MicrosoftEntraIDConfig holds all configurations for Entra ID provider. |
421432| `ADFSConfig` | _[ADFSOptions](#adfsoptions)_ | ADFSConfig holds all configurations for ADFS provider. |
422433| `bitbucketConfig` | _[BitbucketOptions](#bitbucketoptions)_ | BitbucketConfig holds all configurations for Bitbucket provider. |
423434| `githubConfig` | _[GitHubOptions](#githuboptions)_ | GitHubConfig holds all configurations for GitHubC provider. |
docs/docs/configuration/providers/azure_ad.mddeleted−18
@@ -1,18 +0,0 @@
1−---
2−id: azure_ad
3−title: Microsoft Azure AD
4−---
5−
6−## Config Options
7−
8−| Flag | Toml Field | Type | Description | Default |
9−| ---------------- | -------------- | ------ | ---------------------------------------------------------------- | ---------- |
10−| `--azure-tenant` | `azure_tenant` | string | go to a tenant-specific or common (tenant-independent) endpoint. | `"common"` |
11−| `--resource` | `resource` | string | The resource that is protected (Azure AD only) | |
12−
13−## Usage
14−
15−For adding an application to the Microsoft Azure AD follow [these steps to add an application](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app).
16−
17−Take note of your `TenantId` if applicable for your situation. The `TenantId` can be used to override the default
18−`common` authorization server with a tenant specific server.
docs/docs/configuration/providers/gitea.md+4
@@ -3,6 +3,10 @@ id: gitea
33title: Gitea
44---
55
6+:::note
7+This is not actually its own provider. For more details and options please refer to the [GitHub Provider Options](github.md)
8+:::
9+
6101. Create a new application: `https://< your gitea host >/user/settings/applications`
7112. Under `Redirect URI` enter the correct URL i.e. `https://<proxied host>/oauth2/callback`
8123. Note the Client ID and Client Secret.
docs/docs/configuration/providers/index.md+9−8
@@ -8,21 +8,22 @@ with Redirect URI(s) for the domain you intend to run `oauth2-proxy` on.
88
99Valid providers are :
1010
11−- [Google](google.md) _default_
12−- [Azure](azure.md)
1311- [ADFS](adfs.md)
12+- [Bitbucket](bitbucket.md)
13+- [DigitalOcean](digitalocean.md)
1414- [Facebook](facebook.md)
15−- [GitHub](github.md)
1615- [Gitea](gitea.md)
17−- [Keycloak](keycloak.md)/[Keycloak OIDC](keycloak_oidc.md)
16+- [GitHub](github.md)
1817- [GitLab](gitlab.md)
18+- [Google](google.md) _default_
19+- [Keycloak](keycloak.md) (Deprecated)
20+- [Keycloak OIDC](keycloak_oidc.md)
1921- [LinkedIn](linkedin.md)
20−- [Microsoft Azure AD](azure_ad.md)
21−- [OpenID Connect](openid_connect.md)
2222- [login.gov](login_gov.md)
23+- [Microsoft Azure](ms_azure_ad.md) (Deprecated)
24+- [Microsoft Entra ID](ms_entra_id.md)
2325- [Nextcloud](nextcloud.md)
24−- [DigitalOcean](digitalocean.md)
25−- [Bitbucket](bitbucket.md)
26+- [OpenID Connect](openid_connect.md)
2627
2728The provider can be selected using the `provider` configuration value.
2829
docs/docs/configuration/providers/keycloak.md+2−2
@@ -1,10 +1,10 @@
11---
22id: keycloak
3−title: Keycloak
3+title: Keycloak (Deprecated)
44---
55
66:::note
7−This is the legacy provider for Keycloak, use [Keycloak OIDC Auth Provider](keycloak_oidc.md) if possible.
7+This is the legacy and deprecated provider for Keycloak, use [Keycloak OIDC Auth Provider](keycloak_oidc.md) if possible.
88:::
99
10101. Create new client in your Keycloak realm with **Access Type** 'confidential' and **Valid Redirect URIs** 'https://internal.yourcompany.com/oauth2/callback'
docs/docs/configuration/providers/azure.md → docs/docs/configuration/providers/ms_azure_ad.mdrenamed+5−1
@@ -1,8 +1,12 @@
11---
22id: azure
3−title: Azure
3+title: Azure (Deprecated)
44---
55
6+:::note
7+This is the legacy and deprecated provider for Azure, use [Microsoft Entra ID](ms_entra_id.md) if possible.
8+:::
9+
610## Config Options
711
812| Flag | Toml Field | Type | Description | Default |
docs/docs/configuration/providers/ms_entra_id.mdnew file+158
@@ -0,0 +1,158 @@
1+---
2+id: ms_entra_id
3+title: Microsoft Entra ID
4+---
5+
6+Provider for Microsoft Entra ID. Fully compliant with OIDC, with support for group overage and multi-tenant apps.
7+
8+## Config Options
9+
10+The provider is OIDC-compliant, so all the OIDC parameters are honored. Additional provider-specific configuration parameters are:
11+
12+| Flag | Toml Field | Type | Description | Default |
13+| --------------------------- | -------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
14+| `--entra-id-allowed-tenant` | `entra_id_allowed_tenants` | string \| list | List of allowed tenants. In case of multi-tenant apps, incoming tokens are issued by different issuers and OIDC issuer verification needs to be disabled. When not specified, all tenants are allowed. Redundant for single-tenant apps (regular ID token validation matches the issuer). | |
15+
16+## Configure App registration
17+To begin, create an App registration, set a redirect URI, and generate a secret. All account types are supported, including single-tenant, multi-tenant, multi-tenant with Microsoft accounts, and Microsoft accounts only.
18+
19+<details>
20+ <summary>See Azure Portal example</summary>
21+ <div class="videoBlock">
22+ <iframe src="https://www.youtube.com/embed/IUNfxhOzr4E"></iframe>
23+ </div>
24+</details>
25+
26+<details>
27+ <summary>See Terraform example</summary>
28+```
29+ resource "azuread_application" "auth" {
30+ display_name = "oauth2-proxy"
31+ sign_in_audience = "AzureADMyOrg" # Others are also supported
32+
33+ web {
34+ redirect_uris = [
35+ "https://podinfo.lakis.tech/oauth2/callback",
36+ ]
37+ }
38+ // We don't specify any required API permissions - we allow user consent only
39+ }
40+
41+ resource "azuread_service_principal" "sp" {
42+ client_id = azuread_application.auth.client_id
43+ app_role_assignment_required = false
44+ }
45+
46+ resource "azuread_service_principal_password" "pass" {
47+ service_principal_id = azuread_service_principal.sp.id
48+ }
49+
50+```
51+</details>
52+
53+### Configure groups
54+If you want to make use of groups, you can configure *groups claim* to be present in ID Tokens issued by the App registration.
55+<details>
56+ <summary>See Azure Portal example</summary>
57+ <div class="videoBlock">
58+ <div class="videoBlock">
59+ <iframe src="https://www.youtube.com/embed/QZmP5MKEJis"></iframe>
60+ </div>
61+ </div>
62+</details>
63+<details>
64+ <summary>See Terraform example</summary>
65+```
66+ resource "azuread_application" "auth" {
67+ display_name = "oauth2-proxy"
68+ sign_in_audience = "AzureADMyOrg"
69+
70+ group_membership_claims = [
71+ "SecurityGroup"
72+ ]
73+
74+ web {
75+ redirect_uris = [
76+ "https://podinfo.lakis.tech/oauth2/callback",
77+ ]
78+ }
79+ }
80+
81+ resource "azuread_service_principal" "sp" {
82+ client_id = azuread_application.auth.client_id
83+ app_role_assignment_required = false
84+ }
85+
86+ resource "azuread_service_principal_password" "pass" {
87+ service_principal_id = azuread_service_principal.sp.id
88+ }
89+
90+```
91+</details>
92+
93+### Scopes and claims
94+For single-tenant and multi-tenant apps without groups, the only required scope is `openid` (See: [Scopes and permissions](https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc#the-openid-scope)).
95+
96+To make use of groups - for example use `allowed_groups` setting or authorize based on groups inside your service - you need to enable *groups claims* in the App Registration. When enabled, list of groups is present in the issued ID token. No additional scopes are required besides `openid`. This works up to 200 groups.
97+
98+When user has more than 200 group memberships, OAuth2-Proxy attempts to retrieve the complete list from Microsoft Graph API's [`transitiveMemberOf`](https://learn.microsoft.com/en-us/graph/api/user-list-transitivememberof). Endpoint requires `User.Read` scope (delegated permission). This permission can be by default consented by user during first login. Set scope to `openid User.Read` to request user consent. Without proper scope, user with 200+ groups will authenticate with 0 groups. See: [group overages](https://learn.microsoft.com/en-us/security/zero-trust/develop/configure-tokens-group-claims-app-roles#group-overages).
99+
100+Alternatively to user consent, both `openid` and `User.Read` permissions can be consented by admistrator. Then, user is not asked for consent on the first login, and group overage works with `openid` scope only. Admin consent can also be required for some tenants. It can be granted with [azuread_service_principal_delegated_permission_grant](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/service_principal_delegated_permission_grant) terraform resource.
101+
102+For personal microsoft accounts, required scope is `openid profile email`.
103+
104+See: [Overview of permissions and consent in the Microsoft identity platform](https://learn.microsoft.com/en-us/entra/identity-platform/permissions-consent-overview).
105+
106+### Multi-tenant apps
107+To authenticate apps from multiple tenants (including personal Microsoft accounts), set the common OIDC issuer url and disable verification:
108+```toml
109+oidc_issuer_url=https://login.microsoftonline.com/common/v2.0
110+insecure_oidc_skip_issuer_verification=true
111+```
112+`insecure_oidc_skip_issuer_verification` setting is required to disable following checks:
113+* Startup check for matching issuer URL returned from [discovery document](https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration) with `oidc_issuer_url` setting. Required, as document's `issuer` field doesn't equal to `https://login.microsoftonline.com/common/v2.0`. See [OIDC Discovery 4.3](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationValidation).
114+* Matching ID token's `issuer` claim with `oidc_issuer_url` setting during ID token validation. Required to support tokens issued by diffrerent tenants. See [OIDC Core 3.1.3.7](https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation).
115+
116+To provide additional security, Entra ID provider performs check on the ID token's `issuer` claim to match the `https://login.microsoftonline.com/{tenant-id}/v2.0` template.
117+
118+### Example configurations
119+Single-tenant app without groups (*groups claim* not enabled). Consider using generic OIDC provider:
120+```toml
121+provider="entra-id"
122+oidc_issuer_url="https://login.microsoftonline.com/<tenant-id>/v2.0"
123+client_id="<client-id>"
124+client_secret="<client-secret>"
125+scope="openid"
126+```
127+
128+Single-tenant app with up to 200 groups (*groups claim* enabled). Consider using generic OIDC provider:
129+```toml
130+provider="entra-id"
131+oidc_issuer_url="https://login.microsoftonline.com/<tenant-id>/v2.0"
132+client_id="<client-id>"
133+client_secret="<client-secret>"
134+scope="openid"
135+allowed_groups=["ac51800c-2679-4ecb-8130-636380a3b491"]
136+```
137+
138+Single-tenant app with more than 200 groups:
139+```toml
140+provider="entra-id"
141+oidc_issuer_url="https://login.microsoftonline.com/<tenant-id>/v2.0"
142+client_id="<client-id>"
143+client_secret="<client-secret>"
144+scope="openid User.Read"
145+allowed_groups=["968b4844-d5e7-4e18-a834-59927959369f"]
146+```
147+
148+Multi-tenant app with Microsoft personal accounts & one Entra tenant allowed, with group overage considered:
149+```toml
150+provider="entra-id"
151+oidc_issuer_url="https://login.microsoftonline.com/common/v2.0"
152+client_id="<client-id>"
153+client_secret="<client-secret>"
154+insecure_oidc_skip_issuer_verification=true
155+scope="openid profile email User.Read"
156+entra_id_allowed_tenant=["9188040d-6c67-4c5b-b112-36a304b66dad","<my-tenant-id>"] # Allow only <my-tenant-id> and Personal MS Accounts tenant
157+email_domains="*"
158+```
docs/sidebars.js+16−16
@@ -31,22 +31,22 @@ const sidebars = {
3131 id: 'configuration/providers/index',
3232 },
3333 items: [
34− 'configuration/providers/google',
35− 'configuration/providers/azure',
36− 'configuration/providers/adfs',
37− 'configuration/providers/facebook',
38− 'configuration/providers/github',
39− 'configuration/providers/gitea',
40− 'configuration/providers/keycloak',
41− 'configuration/providers/keycloak_oidc',
42− 'configuration/providers/gitlab',
43− 'configuration/providers/linkedin',
44− 'configuration/providers/azure_ad',
45− 'configuration/providers/openid_connect',
46− 'configuration/providers/login_gov',
47− 'configuration/providers/nextcloud',
48− 'configuration/providers/digitalocean',
49− 'configuration/providers/bitbucket',
34+ "configuration/providers/adfs",
35+ "configuration/providers/azure",
36+ "configuration/providers/bitbucket",
37+ "configuration/providers/digitalocean",
38+ "configuration/providers/facebook",
39+ "configuration/providers/gitea",
40+ "configuration/providers/github",
41+ "configuration/providers/gitlab",
42+ "configuration/providers/google",
43+ "configuration/providers/keycloak",
44+ "configuration/providers/keycloak_oidc",
45+ "configuration/providers/linkedin",
46+ "configuration/providers/login_gov",
47+ "configuration/providers/ms_entra_id",
48+ "configuration/providers/nextcloud",
49+ "configuration/providers/openid_connect",
5050 ],
5151 },
5252 'configuration/session_storage',
docs/src/css/custom.css+14
@@ -23,3 +23,17 @@
2323 margin: 0 calc(-1 * var(--ifm-pre-padding));
2424 padding: 0 var(--ifm-pre-padding);
2525}
26+
27+.videoBlock {
28+ position: relative;
29+ padding-bottom: 75%;
30+ height: 0;
31+}
32+
33+.videoBlock iframe {
34+ position: absolute;
35+ top: 0;
36+ left: 0;
37+ width: 100%;
38+ height: 100%;
39+}
pkg/apis/options/legacy_options.go+6
@@ -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+ EntraIDAllowedTenants []string `flag:"entra-id-allowed-tenant" cfg:"entra_id_allowed_tenants"`
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.StringSlice("entra-id-allowed-tenant", []string{}, "list of tenants allowed for MS Entra ID multi-tenant application")
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")
@@ -756,6 +758,10 @@ func (l *LegacyProvider) convert() (Providers, error) {
756758 UseApplicationDefaultCredentials: l.GoogleUseApplicationDefaultCredentials,
757759 TargetPrincipal: l.GoogleTargetPrincipal,
758760 }
761+ case "entra-id":
762+ provider.MicrosoftEntraIDConfig = MicrosoftEntraIDOptions{
763+ AllowedTenants: l.EntraIDAllowedTenants,
764+ }
759765 }
760766
761767 if l.ProviderName != "" {
pkg/apis/options/providers.go+13
@@ -30,6 +30,8 @@ type Provider struct {
3030 KeycloakConfig KeycloakOptions `json:"keycloakConfig,omitempty"`
3131 // AzureConfig holds all configurations for Azure provider.
3232 AzureConfig AzureOptions `json:"azureConfig,omitempty"`
33+ // MicrosoftEntraIDConfig holds all configurations for Entra ID provider.
34+ MicrosoftEntraIDConfig MicrosoftEntraIDOptions `json:"microsoftEntraIDConfig,omitempty"`
3335 // ADFSConfig holds all configurations for ADFS provider.
3436 ADFSConfig ADFSOptions `json:"ADFSConfig,omitempty"`
3537 // BitbucketConfig holds all configurations for Bitbucket provider.
@@ -101,6 +103,9 @@ const (
101103 // AzureProvider is the provider type for Azure
102104 AzureProvider ProviderType = "azure"
103105
106+ // MicrosoftEntraIDProvider is the provider type for Entra OIDC
107+ MicrosoftEntraIDProvider ProviderType = "entra-id"
108+
104109 // BitbucketProvider is the provider type for Bitbucket
105110 BitbucketProvider ProviderType = "bitbucket"
106111
@@ -155,6 +160,14 @@ type AzureOptions struct {
155160 GraphGroupField string `json:"graphGroupField,omitempty"`
156161}
157162
163+type MicrosoftEntraIDOptions struct {
164+ // AllowedTenants is a list of allowed tenants. In case of multi-tenant apps, incoming tokens are
165+ // issued by different issuers and OIDC issuer verification needs to be disabled.
166+ // When not specified, all tenants are allowed. Redundant for single-tenant apps
167+ // (regular ID token validation matches the issuer).
168+ AllowedTenants []string `json:"allowedTenants,omitempty"`
169+}
170+
158171type ADFSOptions struct {
159172 // Skip adding the scope parameter in login request
160173 // Default value is 'false'
providers/ms_entra_id.gonew file+186
@@ -0,0 +1,186 @@
1+package providers
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/url"
7+ "regexp"
8+
9+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
10+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
11+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
12+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
13+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
14+ "github.com/spf13/cast"
15+)
16+
17+// MicrosoftEntraIDProvider represents provider for Azure Entra Authentication V2 endpoint
18+type MicrosoftEntraIDProvider struct {
19+ *OIDCProvider
20+ multiTenantAllowedTenants []string
21+
22+ microsoftGraphURL *url.URL
23+}
24+
25+const (
26+ microsoftEntraIDProviderName = "Microsoft Entra ID"
27+)
28+
29+var (
30+ microsoftGraphURL = &url.URL{
31+ Scheme: "https",
32+ Host: "graph.microsoft.com",
33+ Path: "/v1.0/me",
34+ }
35+)
36+
37+// NewMicrosoftEntraIDProvider initiates a new MicrosoftEntraIDProvider
38+func NewMicrosoftEntraIDProvider(p *ProviderData, opts options.Provider) *MicrosoftEntraIDProvider {
39+ p.setProviderDefaults(providerDefaults{
40+ name: microsoftEntraIDProviderName,
41+ })
42+
43+ return &MicrosoftEntraIDProvider{
44+ OIDCProvider: NewOIDCProvider(p, opts.OIDCConfig),
45+
46+ multiTenantAllowedTenants: opts.MicrosoftEntraIDConfig.AllowedTenants,
47+ microsoftGraphURL: microsoftGraphURL,
48+ }
49+}
50+
51+// EnrichSession checks for group overage after calling generic EnrichSession
52+func (p *MicrosoftEntraIDProvider) EnrichSession(ctx context.Context, session *sessions.SessionState) error {
53+ if err := p.OIDCProvider.EnrichSession(ctx, session); err != nil {
54+ return fmt.Errorf("unable to enrich session: %v", err)
55+ }
56+
57+ hasGroupOverage, err := p.checkGroupOverage(session)
58+ if err != nil {
59+ return fmt.Errorf("unable to check token: %v", err)
60+ }
61+
62+ if hasGroupOverage {
63+ logger.Printf("entra overage found, reading groups from Graph API")
64+ if err = p.addGraphGroupsToSession(ctx, session); err != nil {
65+ return fmt.Errorf("unable to enrich session: %v", err)
66+ }
67+ }
68+
69+ return nil
70+}
71+
72+// ValidateSession checks for allowed tenants (e.g. for multi-tenant apps) and passes through to generic ValidateSession
73+func (p *MicrosoftEntraIDProvider) ValidateSession(ctx context.Context, session *sessions.SessionState) bool {
74+ tenant, err := p.getTenantFromToken(session)
75+ if err != nil {
76+ logger.Errorf("unable to retrieve entra tenant from token: %v", err)
77+ return false
78+ }
79+
80+ if len(p.multiTenantAllowedTenants) > 0 {
81+ tenantAllowed := p.checkTenantMatchesTenantList(tenant, p.multiTenantAllowedTenants)
82+ if !tenantAllowed {
83+ logger.Printf("entra: tenant %s is not specified in the list of allowed tenants", tenant)
84+ return false
85+ }
86+ logger.Printf("entra: tenant %s is allowed", tenant)
87+ }
88+
89+ return p.OIDCProvider.ValidateSession(ctx, session)
90+}
91+
92+// checkGroupOverage checks ID token's group membership claims for the group overage
93+func (p *MicrosoftEntraIDProvider) checkGroupOverage(session *sessions.SessionState) (bool, error) {
94+ extractor, err := p.getClaimExtractor(session.IDToken, session.AccessToken)
95+ if err != nil {
96+ return false, fmt.Errorf("unable to get claim extractor: %v", err)
97+ }
98+
99+ claimNames, _, _ := extractor.GetClaim("_claim_names")
100+
101+ var claimNamesMap map[string]string
102+
103+ if claimNames != nil {
104+ claimNamesMap = cast.ToStringMapString(claimNames)
105+ }
106+
107+ if _, groupsKeyPresent := claimNamesMap["groups"]; groupsKeyPresent {
108+ return true, nil
109+ }
110+
111+ return false, nil
112+}
113+
114+func (p *MicrosoftEntraIDProvider) addGraphGroupsToSession(ctx context.Context, s *sessions.SessionState) error {
115+ groupsHeaders := makeAuthorizationHeader(tokenTypeBearer, s.AccessToken, nil)
116+ groupsHeaders.Add("ConsistencyLevel", "eventual")
117+
118+ var allGroups []string
119+ var nextLink string
120+
121+ for {
122+ if nextLink == "" {
123+ nextLink = fmt.Sprintf("%s/transitiveMemberOf?$select=id&$top=100", p.microsoftGraphURL)
124+ }
125+
126+ response, err := requests.New(nextLink).
127+ WithContext(ctx).
128+ WithHeaders(groupsHeaders).
129+ Do().
130+ UnmarshalSimpleJSON()
131+
132+ if err != nil {
133+ logger.Errorf("invalid response from microsoft graph, no groups added to session: %v", err)
134+ return nil
135+ }
136+ reqGroups := response.Get("value").MustArray()
137+
138+ for i := range reqGroups {
139+ value := response.Get("value").GetIndex(i).Get("id").MustString()
140+ allGroups = append(allGroups, value)
141+ }
142+
143+ // https://learn.microsoft.com/en-us/graph/paging?view=graph-rest-1.0&tabs=http#how-paging-works
144+ nextLink = response.Get("@odata.nextLink").MustString()
145+
146+ if nextLink == "" {
147+ break
148+ }
149+ }
150+
151+ s.Groups = util.RemoveDuplicateStr(append(s.Groups, allGroups...))
152+ return nil
153+}
154+
155+func (p *MicrosoftEntraIDProvider) getTenantFromToken(session *sessions.SessionState) (string, error) {
156+ extractor, err := p.getClaimExtractor(session.IDToken, session.AccessToken)
157+ if err != nil {
158+ return "", fmt.Errorf("unable to get claim extractor: %v", err)
159+ }
160+
161+ value, exists, err := extractor.GetClaim("iss")
162+
163+ if !exists || err != nil {
164+ return "", fmt.Errorf("iss claim does not exist in the token")
165+ }
166+
167+ pattern := `^https://login\.microsoftonline\.com/([a-zA-Z0-9-]+)/v2\.0$`
168+ re := regexp.MustCompile(pattern)
169+
170+ matches := re.FindStringSubmatch(value.(string))
171+
172+ if len(matches) > 1 {
173+ return matches[1], nil
174+ }
175+
176+ return "", fmt.Errorf("invalid issuer in the ID token")
177+}
178+
179+func (p *MicrosoftEntraIDProvider) checkTenantMatchesTenantList(tenant string, allowedTenantList []string) bool {
180+ for _, allowedTenant := range allowedTenantList {
181+ if tenant == allowedTenant {
182+ return true
183+ }
184+ }
185+ return false
186+}
providers/ms_entra_id_test.gonew file+185
@@ -0,0 +1,185 @@
1+package providers
2+
3+import (
4+ "context"
5+ "crypto/rand"
6+ "crypto/rsa"
7+ "fmt"
8+ "net/http"
9+ "net/http/httptest"
10+ "net/url"
11+ "testing"
12+
13+ "github.com/coreos/go-oidc/v3/oidc"
14+ "github.com/golang-jwt/jwt/v5"
15+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
16+ "github.com/stretchr/testify/assert"
17+
18+ . "github.com/onsi/gomega"
19+)
20+
21+func TestAzureEntraOIDCProviderNewMultiTenant(t *testing.T) {
22+ g := NewWithT(t)
23+
24+ provider := NewMicrosoftEntraIDProvider(&ProviderData{},
25+ options.Provider{OIDCConfig: options.OIDCOptions{
26+ IssuerURL: "https://login.microsoftonline.com/common/v2.0",
27+ InsecureSkipIssuerVerification: true,
28+ }},
29+ )
30+ g.Expect(provider.Data().ProviderName).To(Equal("Microsoft Entra ID"))
31+}
32+
33+func TestAzureEntraOIDCProviderNewSingleTenant(t *testing.T) {
34+ g := NewWithT(t)
35+
36+ provider := NewMicrosoftEntraIDProvider(&ProviderData{},
37+ options.Provider{OIDCConfig: options.OIDCOptions{
38+ IssuerURL: "https://login.microsoftonline.com/18014347-dd57-41a1-8191-7a1f734ea457/v2.0",
39+ }},
40+ )
41+ g.Expect(provider.Data().ProviderName).To(Equal("Microsoft Entra ID"))
42+}
43+
44+func TestAzureEntraOIDCProviderEnrichSessionGroupOverage(t *testing.T) {
45+ // Create ID Token that indicates group overage with _claim_names
46+ key, _ := rsa.GenerateKey(rand.Reader, 2048)
47+ claimsWithGroupOverage := &claimsWithGroupOverage{
48+ jwt.RegisteredClaims{
49+ Issuer: "https://login.microsoftonline.com/18014347-dd57-41a1-8191-7a1f734ea457/v2.0",
50+ },
51+ map[string]string{"groups": "src1"},
52+ }
53+
54+ jwtWithClaims := jwt.NewWithClaims(jwt.SigningMethodRS256, claimsWithGroupOverage)
55+ signedJWT, err := jwtWithClaims.SignedString(key)
56+
57+ assert.NoError(t, err)
58+
59+ session := CreateAuthorizedSession()
60+ session.IDToken = signedJWT
61+ session.Email = "mock@example.com"
62+
63+ // Create provider
64+ provider := NewMicrosoftEntraIDProvider(&ProviderData{},
65+ options.Provider{OIDCConfig: options.OIDCOptions{
66+ IssuerURL: "https://login.microsoftonline.com/18014347-dd57-41a1-8191-7a1f734ea457/v2.0",
67+ }},
68+ )
69+
70+ // Create mocked Azure Graph server and override Graph URL
71+ mockedGraph := mockGraphAPI(false)
72+ mockedGraphURL, _ := url.Parse(mockedGraph.URL)
73+ updateURL(provider.microsoftGraphURL, mockedGraphURL.Host)
74+
75+ // Test EnrichSession
76+ err = provider.EnrichSession(context.Background(), session)
77+
78+ assert.NoError(t, err)
79+ assert.Contains(t, session.Groups, "85d7d600-7804-4d92-8d43-9c33c21c130c")
80+ assert.Contains(t, session.Groups, "916f0604-8a3b-4a69-bda9-06db11a8f0cd")
81+ assert.Contains(t, session.Groups, "b1aef995-6b55-4ac6-bbfe-e829810e9352", "Pagination using $skiptoken failed")
82+}
83+
84+func TestAzureEntraOIDCProviderValidateSessionAllowedTenants(t *testing.T) {
85+ // Create multi-tenant Azure Entra provider with allowed tenants
86+ provider := NewMicrosoftEntraIDProvider(
87+ &ProviderData{
88+ Verifier: &mockedVerifier{},
89+ },
90+ options.Provider{
91+ OIDCConfig: options.OIDCOptions{
92+ IssuerURL: "https://login.microsoftonline.com/common/v2.0",
93+ InsecureSkipIssuerVerification: true,
94+ InsecureSkipNonce: true,
95+ },
96+ MicrosoftEntraIDConfig: options.MicrosoftEntraIDOptions{
97+ AllowedTenants: []string{"85d7d600-7804-4d92-8d43-9c33c21c130c"},
98+ },
99+ },
100+ )
101+
102+ // Check for invalid tenant
103+ key, _ := rsa.GenerateKey(rand.Reader, 2048)
104+
105+ idToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.RegisteredClaims{
106+ Issuer: "https://login.microsoftonline.com/invalid_tenant/v2.0",
107+ })
108+ invalidJWT, err := idToken.SignedString(key)
109+ assert.NoError(t, err)
110+
111+ session := CreateAuthorizedSession()
112+ session.IDToken = invalidJWT
113+
114+ valid := provider.ValidateSession(context.Background(), session)
115+ assert.False(t, valid)
116+
117+ // Check for valid tenant
118+ idToken = jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.RegisteredClaims{
119+ Issuer: "https://login.microsoftonline.com/85d7d600-7804-4d92-8d43-9c33c21c130c/v2.0",
120+ })
121+ validJWT, err := idToken.SignedString(key)
122+ assert.NoError(t, err)
123+
124+ session = CreateAuthorizedSession()
125+ session.IDToken = validJWT
126+
127+ valid = provider.ValidateSession(context.Background(), session)
128+ assert.True(t, valid)
129+}
130+
131+func mockGraphAPI(noGroupMemberPermissions bool) *httptest.Server {
132+ groupsPath := "/v1.0/me/transitiveMemberOf"
133+
134+ return httptest.NewServer(http.HandlerFunc(
135+ func(w http.ResponseWriter, r *http.Request) {
136+ if noGroupMemberPermissions {
137+ w.WriteHeader(401)
138+ } else if r.URL.Path == groupsPath && r.Method == http.MethodGet && len(r.URL.Query()["$skiptoken"]) > 0 {
139+ // Second page (pagination)
140+ w.Write([]byte(`{
141+ "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#directoryObjects(id)",
142+ "value": [
143+ {
144+ "@odata.type": "#microsoft.graph.group",
145+ "id": "b1aef995-6b55-4ac6-bbfe-e829810e9352"
146+ }
147+ ]
148+ }`))
149+
150+ } else if r.URL.Path == groupsPath && r.Method == http.MethodGet {
151+ // First page (pagination)
152+ w.Write([]byte(fmt.Sprintf(`{
153+ "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#directoryObjects(id)",
154+ "@odata.nextLink": "http://%s/v1.0/me/transitiveMemberOf?$select=id&$top=2&$skiptoken=TEST_TOKEN",
155+ "value": [
156+ {
157+ "@odata.type": "#microsoft.graph.group",
158+ "id": "85d7d600-7804-4d92-8d43-9c33c21c130c"
159+ },
160+ {
161+ "@odata.type": "#microsoft.graph.group",
162+ "id": "916f0604-8a3b-4a69-bda9-06db11a8f0cd"
163+ }
164+ ]
165+ }`, r.Host)))
166+ }
167+ },
168+ ))
169+}
170+
171+type claimsWithGroupOverage struct {
172+ jwt.RegisteredClaims
173+ ClaimNames interface{} `json:"_claim_names,omitempty"`
174+}
175+
176+func (c *claimsWithGroupOverage) Valid() error {
177+ return nil
178+}
179+
180+type mockedVerifier struct {
181+}
182+
183+func (v *mockedVerifier) Verify(ctx context.Context, rawIDToken string) (*oidc.IDToken, error) {
184+ return nil, nil
185+}
providers/providers.go+3−1
@@ -41,6 +41,8 @@ func NewProvider(providerConfig options.Provider) (Provider, error) {
4141 return NewADFSProvider(providerData, providerConfig), nil
4242 case options.AzureProvider:
4343 return NewAzureProvider(providerData, providerConfig.AzureConfig), nil
44+ case options.MicrosoftEntraIDProvider:
45+ return NewMicrosoftEntraIDProvider(providerData, providerConfig), nil
4446 case options.BitbucketProvider:
4547 return NewBitbucketProvider(providerData, providerConfig.BitbucketConfig), nil
4648 case options.DigitalOceanProvider:
@@ -181,7 +183,7 @@ func providerRequiresOIDCProviderVerifier(providerType options.ProviderType) (bo
181183 case options.BitbucketProvider, options.DigitalOceanProvider, options.FacebookProvider, options.GitHubProvider,
182184 options.GoogleProvider, options.KeycloakProvider, options.LinkedInProvider, options.LoginGovProvider, options.NextCloudProvider:
183185 return false, nil
184− case options.ADFSProvider, options.AzureProvider, options.GitLabProvider, options.KeycloakOIDCProvider, options.OIDCProvider:
186+ case options.ADFSProvider, options.AzureProvider, options.GitLabProvider, options.KeycloakOIDCProvider, options.OIDCProvider, options.MicrosoftEntraIDProvider:
185187 return true, nil
186188 default:
187189 return false, fmt.Errorf("unknown provider type: %s", providerType)