git.iko.soy/typeable/oauth2-proxy
on master help sign in
feat(entra): add Workload Identity support for Entra ID (#2902)
commitae8fb08a89b162b56d22487787c4eb8172219ee2 authorJJ Łakis <jacek.lakis@checkatrade.com> dateSat 11 Jan 2025, 12:12 (2 years ago) parent60570cc
7 files changed+169−13
CHANGELOG.md+1 docs/docs/configuration/alpha_config.md+1 docs/docs/configuration/providers/ms_entra_id.md+39 pkg/apis/options/legacy_options.go+4−1 pkg/apis/options/providers.go+4 pkg/validation/providers.go+59−12 providers/ms_entra_id.go+61
CHANGELOG.md+1
@@ -15,6 +15,7 @@
1515- [#2821](https://github.com/oauth2-proxy/oauth2-proxy/pull/2821) feat: add CF-Connecting-IP as supported real ip header (@ondrejsika)
1616- [#2620](https://github.com/oauth2-proxy/oauth2-proxy/pull/2620) fix: update code_verifier to use recommended method (@vishvananda)
1717- [#2392](https://github.com/oauth2-proxy/oauth2-proxy/pull/2392) chore: extend test cases for oidc provider and documentation regarding implicit setting of the groups scope when no scope was specified in the config (@jjlakis / @tuunit)
18+- [#2902](https://github.com/oauth2-proxy/oauth2-proxy/pull/2902) feat(entra): add Workload Identity support for Entra ID (@jjlakis)
1819
1920# V7.7.1
2021
docs/docs/configuration/alpha_config.md+1
@@ -394,6 +394,7 @@ character.
394394| Field | Type | Description |
395395| ----- | ---- | ----------- |
396396| `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+| `federatedTokenAuth` | _bool_ | FederatedTokenAuth enable oAuth2 client authentication with federated token projected<br/>by Entra Workload Identity plugin, instead of client secret. |
397398
398399### OIDCOptions
399400
docs/docs/configuration/providers/ms_entra_id.md+39
@@ -12,6 +12,7 @@ The provider is OIDC-compliant, so all the OIDC parameters are honored. Addition
1212| Flag | Toml Field | Type | Description | Default |
1313| --------------------------- | -------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
1414| `--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+| `--entra-id-federated-token-auth` | `entra_id_federated_token_auth` | boolean | Enable oAuth2 client authentication with federated token projected by Entra Workload Identity plugin, instead of client secret. | false |
1516
1617## Configure App registration
1718To 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.
@@ -115,6 +116,34 @@ insecure_oidc_skip_issuer_verification=true
115116
116117To 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.
117118
119+### Workload Identity
120+Provider supports authentication with federated token, without need of using client secret. Following conditions have to be met:
121+
122+* Cluster has public OIDC provider URL. For major cloud providers, it can be enabled with a single flag, for example for [Azure Kubernetes Service deployed with Terraform](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/kubernetes_cluster), it's `oidc_issuer_enabled`.
123+* Workload Identity admission webhook is deployed on the cluster. For AKS, it can be enabled with a flag (`workload_identity_enabled` in Terraform resource), for clusters outside of Azure, it can be installed from [helm chart](https://github.com/Azure/azure-workload-identity).
124+* Appropriate federated credential is added to application registration.
125+<details>
126+ <summary>See federated credential terraform example</summary>
127+```
128+ resource "azuread_application_federated_identity_credential" "fedcred" {
129+ application_id = azuread_application.application.id # ID of your application
130+ display_name = "federation-cred"
131+ description = "Workload identity for oauth2-proxy"
132+ audiences = ["api://AzureADTokenExchange"] # Fixed value
133+ issuer = "https://cluster-oidc-issuer-url..."
134+ subject = "system:serviceaccount:oauth2-proxy-namespace-name:oauth2-proxy-sa-name" # set proper NS and SA name
135+ }
136+```
137+</details>
138+
139+* Kubernetes service account associated with oauth2-proxy deployment, is annotated with `azure.workload.identity/client-id: <app-registration-client-id>`
140+* oauth2-proxy pod is labeled with `azure.workload.identity/use: "true"`
141+* oauth2-proxy is configured with `entra_id_federated_token_auth` set to `true`.
142+
143+`client_secret` setting can be omitted when using federated token authentication.
144+
145+See: [Azure Workload Identity documentation](https://azure.github.io/azure-workload-identity/docs/).
146+
118147### Example configurations
119148Single-tenant app without groups (*groups claim* not enabled). Consider using generic OIDC provider:
120149```toml
@@ -145,6 +174,16 @@ scope="openid User.Read"
145174allowed_groups=["968b4844-d5e7-4e18-a834-59927959369f"]
146175```
147176
177+Single-tenant app with more than 200 groups and workload identity enabled:
178+```toml
179+provider="entra-id"
180+oidc_issuer_url="https://login.microsoftonline.com/<tenant-id>/v2.0"
181+client_id="<client-id>"
182+scope="openid User.Read"
183+allowed_groups=["968b4844-d5e7-4e18-a834-59927959369f"]
184+entra_id_federated_token_auth=true
185+```
186+
148187Multi-tenant app with Microsoft personal accounts & one Entra tenant allowed, with group overage considered:
149188```toml
150189provider="entra-id"
pkg/apis/options/legacy_options.go+4−1
@@ -489,6 +489,7 @@ type LegacyProvider struct {
489489 AzureTenant string `flag:"azure-tenant" cfg:"azure_tenant"`
490490 AzureGraphGroupField string `flag:"azure-graph-group-field" cfg:"azure_graph_group_field"`
491491 EntraIDAllowedTenants []string `flag:"entra-id-allowed-tenant" cfg:"entra_id_allowed_tenants"`
492+ EntraIDFederatedTokenAuth bool `flag:"entra-id-federated-token-auth" cfg:"entra_id_federated_token_auth"`
492493 BitbucketTeam string `flag:"bitbucket-team" cfg:"bitbucket_team"`
493494 BitbucketRepository string `flag:"bitbucket-repository" cfg:"bitbucket_repository"`
494495 GitHubOrg string `flag:"github-org" cfg:"github_org"`
@@ -552,6 +553,7 @@ func legacyProviderFlagSet() *pflag.FlagSet {
552553 flagSet.String("azure-tenant", "common", "go to a tenant-specific or common (tenant-independent) endpoint.")
553554 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")
554555 flagSet.StringSlice("entra-id-allowed-tenant", []string{}, "list of tenants allowed for MS Entra ID multi-tenant application")
556+ flagSet.Bool("entra-id-federated-token-auth", false, "enable oAuth client authentication with federated token projected by Azure Workload Identity plugin, instead of client secret.")
555557 flagSet.String("bitbucket-team", "", "restrict logins to members of this team")
556558 flagSet.String("bitbucket-repository", "", "restrict logins to user with access to this repository")
557559 flagSet.String("github-org", "", "restrict logins to members of this organisation")
@@ -760,7 +762,8 @@ func (l *LegacyProvider) convert() (Providers, error) {
760762 }
761763 case "entra-id":
762764 provider.MicrosoftEntraIDConfig = MicrosoftEntraIDOptions{
763− AllowedTenants: l.EntraIDAllowedTenants,
765+ AllowedTenants: l.EntraIDAllowedTenants,
766+ FederatedTokenAuth: l.EntraIDFederatedTokenAuth,
764767 }
765768 }
766769
pkg/apis/options/providers.go+4
@@ -166,6 +166,10 @@ type MicrosoftEntraIDOptions struct {
166166 // When not specified, all tenants are allowed. Redundant for single-tenant apps
167167 // (regular ID token validation matches the issuer).
168168 AllowedTenants []string `json:"allowedTenants,omitempty"`
169+
170+ // FederatedTokenAuth enable oAuth2 client authentication with federated token projected
171+ // by Entra Workload Identity plugin, instead of client secret.
172+ FederatedTokenAuth bool `json:"federatedTokenAuth,omitempty"`
169173}
170174
171175type ADFSOptions struct {
pkg/validation/providers.go+59−12
@@ -46,20 +46,47 @@ func validateProvider(provider options.Provider, providerIDs map[string]struct{}
4646 msgs = append(msgs, "provider missing setting: client-id")
4747 }
4848
49− // login.gov uses a signed JWT to authenticate, not a client-secret
50− if provider.Type != "login.gov" {
51− if provider.ClientSecret == "" && provider.ClientSecretFile == "" {
52− msgs = append(msgs, "missing setting: client-secret or client-secret-file")
53− }
54− if provider.ClientSecret == "" && provider.ClientSecretFile != "" {
55− _, err := os.ReadFile(provider.ClientSecretFile)
56− if err != nil {
57− msgs = append(msgs, "could not read client secret file: "+provider.ClientSecretFile)
58− }
59− }
49+ if providerRequiresClientSecret(provider) {
50+ msgs = append(msgs, validateClientSecret(provider)...)
51+ }
52+
53+ if provider.Type == "google" {
54+ msgs = append(msgs, validateGoogleConfig(provider)...)
6055 }
6156
62− msgs = append(msgs, validateGoogleConfig(provider)...)
57+ if provider.Type == "entra-id" {
58+ msgs = append(msgs, validateEntraConfig(provider)...)
59+ }
60+
61+ return msgs
62+}
63+
64+// providerRequiresClientSecret checks if provider requires client secret to be set
65+// or it can be omitted in favor of JWT token to authenticate oAuth client
66+func providerRequiresClientSecret(provider options.Provider) bool {
67+ if provider.Type == "entra-id" && provider.MicrosoftEntraIDConfig.FederatedTokenAuth {
68+ return false
69+ }
70+
71+ if provider.Type == "login.gov" {
72+ return false
73+ }
74+
75+ return true
76+}
77+
78+func validateClientSecret(provider options.Provider) []string {
79+ msgs := []string{}
80+
81+ if provider.ClientSecret == "" && provider.ClientSecretFile == "" {
82+ msgs = append(msgs, "missing setting: client-secret or client-secret-file")
83+ }
84+ if provider.ClientSecret == "" && provider.ClientSecretFile != "" {
85+ _, err := os.ReadFile(provider.ClientSecretFile)
86+ if err != nil {
87+ msgs = append(msgs, "could not read client secret file: "+provider.ClientSecretFile)
88+ }
89+ }
6390
6491 return msgs
6592}
@@ -96,3 +123,23 @@ func validateGoogleConfig(provider options.Provider) []string {
96123
97124 return msgs
98125}
126+
127+func validateEntraConfig(provider options.Provider) []string {
128+ msgs := []string{}
129+
130+ if provider.MicrosoftEntraIDConfig.FederatedTokenAuth {
131+ federatedTokenPath := os.Getenv("AZURE_FEDERATED_TOKEN_FILE")
132+
133+ if federatedTokenPath == "" {
134+ msgs = append(msgs, "entra federated token authentication is enabled, but AZURE_FEDERATED_TOKEN_FILE variable is not set, check your workload identity configuration.")
135+ return msgs
136+ }
137+
138+ _, err := os.ReadFile(federatedTokenPath)
139+ if err != nil {
140+ msgs = append(msgs, "could not read entra federated token file")
141+ }
142+ }
143+
144+ return msgs
145+}
providers/ms_entra_id.go+61
@@ -1,9 +1,12 @@
11package providers
22
33import (
4+ "bytes"
45 "context"
6+ "encoding/json"
57 "fmt"
68 "net/url"
9+ "os"
710 "regexp"
811
912 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
@@ -12,12 +15,14 @@ import (
1215 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
1316 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
1417 "github.com/spf13/cast"
18+ "golang.org/x/oauth2"
1519)
1620
1721// MicrosoftEntraIDProvider represents provider for Azure Entra Authentication V2 endpoint
1822type MicrosoftEntraIDProvider struct {
1923 *OIDCProvider
2024 multiTenantAllowedTenants []string
25+ federatedTokenAuth bool
2126
2227 microsoftGraphURL *url.URL
2328}
@@ -44,6 +49,7 @@ func NewMicrosoftEntraIDProvider(p *ProviderData, opts options.Provider) *Micros
4449 OIDCProvider: NewOIDCProvider(p, opts.OIDCConfig),
4550
4651 multiTenantAllowedTenants: opts.MicrosoftEntraIDConfig.AllowedTenants,
52+ federatedTokenAuth: opts.MicrosoftEntraIDConfig.FederatedTokenAuth,
4753 microsoftGraphURL: microsoftGraphURL,
4854 }
4955}
@@ -89,6 +95,61 @@ func (p *MicrosoftEntraIDProvider) ValidateSession(ctx context.Context, session
8995 return p.OIDCProvider.ValidateSession(ctx, session)
9096}
9197
98+// Redeem exchanges the OAuth2 authentication token for an ID token, considering federated token authentication
99+func (p *MicrosoftEntraIDProvider) Redeem(ctx context.Context, redirectURL, code, codeVerifier string) (*sessions.SessionState, error) {
100+ if p.federatedTokenAuth {
101+ return p.redeemWithFederatedToken(ctx, redirectURL, code, codeVerifier)
102+ }
103+
104+ return p.OIDCProvider.Redeem(ctx, redirectURL, code, codeVerifier)
105+}
106+
107+// redeemWithFederatedToken performs custom token exchange with federated token instead of client secret
108+func (p *MicrosoftEntraIDProvider) redeemWithFederatedToken(ctx context.Context, redirectURL, code, codeVerifier string) (*sessions.SessionState, error) {
109+ federatedTokenPath := os.Getenv("AZURE_FEDERATED_TOKEN_FILE")
110+ federatedToken, err := os.ReadFile(federatedTokenPath)
111+ if err != nil {
112+ return nil, fmt.Errorf("error reading federated token file %s: %s", federatedTokenPath, err)
113+ }
114+
115+ params := url.Values{}
116+
117+ // create custom exchange parameters
118+ if codeVerifier != "" {
119+ params.Add("code_verifier", codeVerifier)
120+ }
121+ params.Add("redirect_uri", redirectURL)
122+ params.Add("client_id", p.ClientID)
123+ params.Add("client_assertion", string(federatedToken))
124+ params.Add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
125+ params.Add("code", code)
126+ params.Add("grant_type", "authorization_code")
127+
128+ // perform exchange
129+ resp := requests.New(p.RedeemURL.String()).
130+ WithContext(ctx).
131+ WithMethod("POST").
132+ WithBody(bytes.NewBufferString(params.Encode())).
133+ SetHeader("Content-Type", "application/x-www-form-urlencoded").
134+ Do()
135+
136+ // prepare token of type *oauth2.Token
137+ var token *oauth2.Token
138+ var rawResponse interface{}
139+
140+ body := resp.Body()
141+ if err := json.Unmarshal(body, &rawResponse); err != nil {
142+ return nil, err
143+ }
144+
145+ if err := json.Unmarshal(body, &token); err != nil {
146+ return nil, err
147+ }
148+
149+ // create session using new token and generic OIDC provider
150+ return p.OIDCProvider.createSession(ctx, token.WithExtra(rawResponse), false)
151+}
152+
92153// checkGroupOverage checks ID token's group membership claims for the group overage
93154func (p *MicrosoftEntraIDProvider) checkGroupOverage(session *sessions.SessionState) (bool, error) {
94155 extractor, err := p.getClaimExtractor(session.IDToken, session.AccessToken)