Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Entra ID: Workload Identity support #2902

Merged
merged 1 commit into from
Jan 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/docs/configuration/alpha_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<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). |
| `federatedTokenAuth` | _bool_ | FederatedTokenAuth enable oAuth2 client authentication with federated token projected<br/>by Entra Workload Identity plugin, instead of client secret. |

### OIDCOptions

Expand Down
39 changes: 39 additions & 0 deletions docs/docs/configuration/providers/ms_entra_id.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
<details>
<summary>See federated credential terraform example</summary>
```
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
}
```
</details>

* Kubernetes service account associated with oauth2-proxy deployment, is annotated with `azure.workload.identity/client-id: <app-registration-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
Expand Down Expand Up @@ -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/<tenant-id>/v2.0"
client_id="<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"
Expand Down
5 changes: 4 additions & 1 deletion pkg/apis/options/legacy_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
}
}

Expand Down
4 changes: 4 additions & 0 deletions pkg/apis/options/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
71 changes: 59 additions & 12 deletions pkg/validation/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)...)
}

if provider.Type == "google" {
msgs = append(msgs, validateGoogleConfig(provider)...)
}

msgs = append(msgs, validateGoogleConfig(provider)...)
if provider.Type == "entra-id" {
msgs = append(msgs, validateEntraConfig(provider)...)
}
jjlakis marked this conversation as resolved.
Show resolved Hide resolved

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
}
Expand Down Expand Up @@ -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")
tuunit marked this conversation as resolved.
Show resolved Hide resolved

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
}
61 changes: 61 additions & 0 deletions providers/ms_entra_id.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
}
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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)
tuunit marked this conversation as resolved.
Show resolved Hide resolved
}

// 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)
Expand Down
Loading