From ae8fb08a89b162b56d22487787c4eb8172219ee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JJ=20=C5=81akis?= Date: Sat, 11 Jan 2025 12:12:41 +0100 Subject: [PATCH] feat(entra): add Workload Identity support for Entra ID (#2902) --- CHANGELOG.md | 1 + docs/docs/configuration/alpha_config.md | 1 + .../configuration/providers/ms_entra_id.md | 39 ++++++++++ pkg/apis/options/legacy_options.go | 5 +- pkg/apis/options/providers.go | 4 ++ pkg/validation/providers.go | 71 +++++++++++++++---- providers/ms_entra_id.go | 61 ++++++++++++++++ 7 files changed, 169 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f30f4b2c..bd83ae08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - [#2821](https://github.com/oauth2-proxy/oauth2-proxy/pull/2821) feat: add CF-Connecting-IP as supported real ip header (@ondrejsika) - [#2620](https://github.com/oauth2-proxy/oauth2-proxy/pull/2620) fix: update code_verifier to use recommended method (@vishvananda) - [#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) +- [#2902](https://github.com/oauth2-proxy/oauth2-proxy/pull/2902) feat(entra): add Workload Identity support for Entra ID (@jjlakis) # V7.7.1 diff --git a/docs/docs/configuration/alpha_config.md b/docs/docs/configuration/alpha_config.md index 55a1984b..0108810f 100644 --- a/docs/docs/configuration/alpha_config.md +++ b/docs/docs/configuration/alpha_config.md @@ -394,6 +394,7 @@ character. | Field | Type | Description | | ----- | ---- | ----------- | | `allowedTenants` | _[]string_ | AllowedTenants is a 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). | +| `federatedTokenAuth` | _bool_ | FederatedTokenAuth enable oAuth2 client authentication with federated token projected
by Entra Workload Identity plugin, instead of client secret. | ### OIDCOptions diff --git a/docs/docs/configuration/providers/ms_entra_id.md b/docs/docs/configuration/providers/ms_entra_id.md index 082d3247..ca9fc850 100644 --- a/docs/docs/configuration/providers/ms_entra_id.md +++ b/docs/docs/configuration/providers/ms_entra_id.md @@ -12,6 +12,7 @@ The provider is OIDC-compliant, so all the OIDC parameters are honored. Addition | Flag | Toml Field | Type | Description | Default | | --------------------------- | -------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | `--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). | | +| `--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 | ## Configure App registration 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. @@ -115,6 +116,34 @@ insecure_oidc_skip_issuer_verification=true 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. +### Workload Identity +Provider supports authentication with federated token, without need of using client secret. Following conditions have to be met: + +* 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`. +* 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). +* Appropriate federated credential is added to application registration. +
+ See federated credential terraform example +``` + resource "azuread_application_federated_identity_credential" "fedcred" { + application_id = azuread_application.application.id # ID of your application + display_name = "federation-cred" + description = "Workload identity for oauth2-proxy" + audiences = ["api://AzureADTokenExchange"] # Fixed value + issuer = "https://cluster-oidc-issuer-url..." + subject = "system:serviceaccount:oauth2-proxy-namespace-name:oauth2-proxy-sa-name" # set proper NS and SA name + } +``` +
+ +* Kubernetes service account associated with oauth2-proxy deployment, is annotated with `azure.workload.identity/client-id: ` +* oauth2-proxy pod is labeled with `azure.workload.identity/use: "true"` +* oauth2-proxy is configured with `entra_id_federated_token_auth` set to `true`. + +`client_secret` setting can be omitted when using federated token authentication. + +See: [Azure Workload Identity documentation](https://azure.github.io/azure-workload-identity/docs/). + ### Example configurations Single-tenant app without groups (*groups claim* not enabled). Consider using generic OIDC provider: ```toml @@ -145,6 +174,16 @@ scope="openid User.Read" allowed_groups=["968b4844-d5e7-4e18-a834-59927959369f"] ``` +Single-tenant app with more than 200 groups and workload identity enabled: +```toml +provider="entra-id" +oidc_issuer_url="https://login.microsoftonline.com//v2.0" +client_id="" +scope="openid User.Read" +allowed_groups=["968b4844-d5e7-4e18-a834-59927959369f"] +entra_id_federated_token_auth=true +``` + Multi-tenant app with Microsoft personal accounts & one Entra tenant allowed, with group overage considered: ```toml provider="entra-id" diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go index ed7ae7b1..6cf9ba8d 100644 --- a/pkg/apis/options/legacy_options.go +++ b/pkg/apis/options/legacy_options.go @@ -489,6 +489,7 @@ type LegacyProvider struct { AzureTenant string `flag:"azure-tenant" cfg:"azure_tenant"` AzureGraphGroupField string `flag:"azure-graph-group-field" cfg:"azure_graph_group_field"` EntraIDAllowedTenants []string `flag:"entra-id-allowed-tenant" cfg:"entra_id_allowed_tenants"` + EntraIDFederatedTokenAuth bool `flag:"entra-id-federated-token-auth" cfg:"entra_id_federated_token_auth"` BitbucketTeam string `flag:"bitbucket-team" cfg:"bitbucket_team"` BitbucketRepository string `flag:"bitbucket-repository" cfg:"bitbucket_repository"` GitHubOrg string `flag:"github-org" cfg:"github_org"` @@ -552,6 +553,7 @@ func legacyProviderFlagSet() *pflag.FlagSet { 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.StringSlice("entra-id-allowed-tenant", []string{}, "list of tenants allowed for MS Entra ID multi-tenant application") + 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.") flagSet.String("bitbucket-team", "", "restrict logins to members of this team") flagSet.String("bitbucket-repository", "", "restrict logins to user with access to this repository") flagSet.String("github-org", "", "restrict logins to members of this organisation") @@ -760,7 +762,8 @@ func (l *LegacyProvider) convert() (Providers, error) { } case "entra-id": provider.MicrosoftEntraIDConfig = MicrosoftEntraIDOptions{ - AllowedTenants: l.EntraIDAllowedTenants, + AllowedTenants: l.EntraIDAllowedTenants, + FederatedTokenAuth: l.EntraIDFederatedTokenAuth, } } diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 72df5633..740a0d0f 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -166,6 +166,10 @@ type MicrosoftEntraIDOptions struct { // When not specified, all tenants are allowed. Redundant for single-tenant apps // (regular ID token validation matches the issuer). AllowedTenants []string `json:"allowedTenants,omitempty"` + + // FederatedTokenAuth enable oAuth2 client authentication with federated token projected + // by Entra Workload Identity plugin, instead of client secret. + FederatedTokenAuth bool `json:"federatedTokenAuth,omitempty"` } type ADFSOptions struct { diff --git a/pkg/validation/providers.go b/pkg/validation/providers.go index ecc2d06d..b1106b35 100644 --- a/pkg/validation/providers.go +++ b/pkg/validation/providers.go @@ -46,20 +46,47 @@ func validateProvider(provider options.Provider, providerIDs map[string]struct{} msgs = append(msgs, "provider missing setting: client-id") } - // login.gov uses a signed JWT to authenticate, not a client-secret - if provider.Type != "login.gov" { - if provider.ClientSecret == "" && provider.ClientSecretFile == "" { - msgs = append(msgs, "missing setting: client-secret or client-secret-file") - } - if provider.ClientSecret == "" && provider.ClientSecretFile != "" { - _, err := os.ReadFile(provider.ClientSecretFile) - if err != nil { - msgs = append(msgs, "could not read client secret file: "+provider.ClientSecretFile) - } - } + if providerRequiresClientSecret(provider) { + msgs = append(msgs, validateClientSecret(provider)...) } - msgs = append(msgs, validateGoogleConfig(provider)...) + if provider.Type == "google" { + msgs = append(msgs, validateGoogleConfig(provider)...) + } + + if provider.Type == "entra-id" { + msgs = append(msgs, validateEntraConfig(provider)...) + } + + return msgs +} + +// providerRequiresClientSecret checks if provider requires client secret to be set +// or it can be omitted in favor of JWT token to authenticate oAuth client +func providerRequiresClientSecret(provider options.Provider) bool { + if provider.Type == "entra-id" && provider.MicrosoftEntraIDConfig.FederatedTokenAuth { + return false + } + + if provider.Type == "login.gov" { + return false + } + + return true +} + +func validateClientSecret(provider options.Provider) []string { + msgs := []string{} + + if provider.ClientSecret == "" && provider.ClientSecretFile == "" { + msgs = append(msgs, "missing setting: client-secret or client-secret-file") + } + if provider.ClientSecret == "" && provider.ClientSecretFile != "" { + _, err := os.ReadFile(provider.ClientSecretFile) + if err != nil { + msgs = append(msgs, "could not read client secret file: "+provider.ClientSecretFile) + } + } return msgs } @@ -96,3 +123,23 @@ func validateGoogleConfig(provider options.Provider) []string { return msgs } + +func validateEntraConfig(provider options.Provider) []string { + msgs := []string{} + + if provider.MicrosoftEntraIDConfig.FederatedTokenAuth { + federatedTokenPath := os.Getenv("AZURE_FEDERATED_TOKEN_FILE") + + if federatedTokenPath == "" { + msgs = append(msgs, "entra federated token authentication is enabled, but AZURE_FEDERATED_TOKEN_FILE variable is not set, check your workload identity configuration.") + return msgs + } + + _, err := os.ReadFile(federatedTokenPath) + if err != nil { + msgs = append(msgs, "could not read entra federated token file") + } + } + + return msgs +} diff --git a/providers/ms_entra_id.go b/providers/ms_entra_id.go index 74061701..330c7875 100644 --- a/providers/ms_entra_id.go +++ b/providers/ms_entra_id.go @@ -1,9 +1,12 @@ package providers import ( + "bytes" "context" + "encoding/json" "fmt" "net/url" + "os" "regexp" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" @@ -12,12 +15,14 @@ import ( "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util" "github.com/spf13/cast" + "golang.org/x/oauth2" ) // MicrosoftEntraIDProvider represents provider for Azure Entra Authentication V2 endpoint type MicrosoftEntraIDProvider struct { *OIDCProvider multiTenantAllowedTenants []string + federatedTokenAuth bool microsoftGraphURL *url.URL } @@ -44,6 +49,7 @@ func NewMicrosoftEntraIDProvider(p *ProviderData, opts options.Provider) *Micros OIDCProvider: NewOIDCProvider(p, opts.OIDCConfig), multiTenantAllowedTenants: opts.MicrosoftEntraIDConfig.AllowedTenants, + federatedTokenAuth: opts.MicrosoftEntraIDConfig.FederatedTokenAuth, microsoftGraphURL: microsoftGraphURL, } } @@ -89,6 +95,61 @@ func (p *MicrosoftEntraIDProvider) ValidateSession(ctx context.Context, session return p.OIDCProvider.ValidateSession(ctx, session) } +// Redeem exchanges the OAuth2 authentication token for an ID token, considering federated token authentication +func (p *MicrosoftEntraIDProvider) Redeem(ctx context.Context, redirectURL, code, codeVerifier string) (*sessions.SessionState, error) { + if p.federatedTokenAuth { + return p.redeemWithFederatedToken(ctx, redirectURL, code, codeVerifier) + } + + return p.OIDCProvider.Redeem(ctx, redirectURL, code, codeVerifier) +} + +// redeemWithFederatedToken performs custom token exchange with federated token instead of client secret +func (p *MicrosoftEntraIDProvider) redeemWithFederatedToken(ctx context.Context, redirectURL, code, codeVerifier string) (*sessions.SessionState, error) { + federatedTokenPath := os.Getenv("AZURE_FEDERATED_TOKEN_FILE") + federatedToken, err := os.ReadFile(federatedTokenPath) + if err != nil { + return nil, fmt.Errorf("error reading federated token file %s: %s", federatedTokenPath, err) + } + + params := url.Values{} + + // create custom exchange parameters + if codeVerifier != "" { + params.Add("code_verifier", codeVerifier) + } + params.Add("redirect_uri", redirectURL) + params.Add("client_id", p.ClientID) + params.Add("client_assertion", string(federatedToken)) + params.Add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") + params.Add("code", code) + params.Add("grant_type", "authorization_code") + + // perform exchange + resp := requests.New(p.RedeemURL.String()). + WithContext(ctx). + WithMethod("POST"). + WithBody(bytes.NewBufferString(params.Encode())). + SetHeader("Content-Type", "application/x-www-form-urlencoded"). + Do() + + // prepare token of type *oauth2.Token + var token *oauth2.Token + var rawResponse interface{} + + body := resp.Body() + if err := json.Unmarshal(body, &rawResponse); err != nil { + return nil, err + } + + if err := json.Unmarshal(body, &token); err != nil { + return nil, err + } + + // create session using new token and generic OIDC provider + return p.OIDCProvider.createSession(ctx, token.WithExtra(rawResponse), false) +} + // checkGroupOverage checks ID token's group membership claims for the group overage func (p *MicrosoftEntraIDProvider) checkGroupOverage(session *sessions.SessionState) (bool, error) { extractor, err := p.getClaimExtractor(session.IDToken, session.AccessToken)