diff --git a/CHANGELOG.md b/CHANGELOG.md index 788e82c2..e28014d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Changes since v7.15.3 +- [#2751](https://github.com/oauth2-proxy/oauth2-proxy/issues/2751) feat: add `--entra-id-redeem-scope` option to send a distinct (narrowed) scope when redeeming the authorization code for tokens, enabling single-audience access tokens with Microsoft Entra ID (@mane-tv2) + # V7.15.3 ## Release Highlights diff --git a/docs/docs/configuration/alpha_config.md b/docs/docs/configuration/alpha_config.md index 680741ba..e7de1130 100644 --- a/docs/docs/configuration/alpha_config.md +++ b/docs/docs/configuration/alpha_config.md @@ -530,6 +530,7 @@ character. | ----- | ---- | ----------- | | `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. | +| `redeemScope` | _string_ | RedeemScope is the OAuth scope specification used when redeeming the
authorization code for tokens. Entra ID cannot issue an access token
for multiple audiences; setting a narrowed, single-audience scope here
works around that. If unset, no scope parameter is sent with the token
request. | ### OIDCOptions diff --git a/docs/docs/configuration/providers/ms_entra_id.md b/docs/docs/configuration/providers/ms_entra_id.md index b9b9e1f8..e8e75363 100644 --- a/docs/docs/configuration/providers/ms_entra_id.md +++ b/docs/docs/configuration/providers/ms_entra_id.md @@ -13,6 +13,7 @@ The provider is OIDC-compliant, so all the OIDC parameters are honored. Addition | --------------------------- | -------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | `--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 | +| `--entra-id-redeem-scope` | `entra_id_redeem_scope` | string | OAuth scope specification sent when redeeming the authorization code for tokens. Entra ID cannot issue an access token for multiple audiences: request the full scope list at authorization time via `scope`, and narrow it down to a single-audience scope here. If unset, no scope parameter is sent with the token request. | | ## 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. diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go index e53fd480..f6d36dd3 100644 --- a/pkg/apis/options/legacy_options.go +++ b/pkg/apis/options/legacy_options.go @@ -511,6 +511,7 @@ type LegacyProvider struct { 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"` + EntraIDRedeemScope string `flag:"entra-id-redeem-scope" cfg:"entra_id_redeem_scope"` BitbucketTeam string `flag:"bitbucket-team" cfg:"bitbucket_team"` BitbucketRepository string `flag:"bitbucket-repository" cfg:"bitbucket_repository"` GitHubOrg string `flag:"github-org" cfg:"github_org"` @@ -580,6 +581,7 @@ func legacyProviderFlagSet() *pflag.FlagSet { 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("entra-id-redeem-scope", "", "OAuth scope specification used when redeeming the authorization code for tokens. Useful to obtain a single-audience access token. If unset, no scope parameter is sent with the token request") 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") @@ -800,6 +802,7 @@ func (l *LegacyProvider) convert() (Providers, error) { provider.MicrosoftEntraIDConfig = MicrosoftEntraIDOptions{ AllowedTenants: l.EntraIDAllowedTenants, FederatedTokenAuth: &l.EntraIDFederatedTokenAuth, + RedeemScope: l.EntraIDRedeemScope, } } diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 6f115f8a..de7df572 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -227,6 +227,13 @@ type MicrosoftEntraIDOptions struct { // FederatedTokenAuth enable oAuth2 client authentication with federated token projected // by Entra Workload Identity plugin, instead of client secret. FederatedTokenAuth *bool `yaml:"federatedTokenAuth,omitempty"` + + // RedeemScope is the OAuth scope specification used when redeeming the + // authorization code for tokens. Entra ID cannot issue an access token + // for multiple audiences; setting a narrowed, single-audience scope here + // works around that. If unset, no scope parameter is sent with the token + // request. + RedeemScope string `yaml:"redeemScope,omitempty"` } type ADFSOptions struct { diff --git a/providers/ms_entra_id.go b/providers/ms_entra_id.go index 97a18e48..304c5a3a 100644 --- a/providers/ms_entra_id.go +++ b/providers/ms_entra_id.go @@ -27,6 +27,7 @@ type MicrosoftEntraIDProvider struct { *OIDCProvider multiTenantAllowedTenants []string federatedTokenAuth bool + redeemScope string microsoftGraphURL *url.URL } @@ -54,6 +55,7 @@ func NewMicrosoftEntraIDProvider(p *ProviderData, opts options.Provider) *Micros multiTenantAllowedTenants: opts.MicrosoftEntraIDConfig.AllowedTenants, federatedTokenAuth: ptr.Deref(opts.MicrosoftEntraIDConfig.FederatedTokenAuth, options.DefaultMicrosoftEntraIDUseFederatedToken), + redeemScope: opts.MicrosoftEntraIDConfig.RedeemScope, microsoftGraphURL: microsoftGraphURL, } } @@ -105,9 +107,42 @@ func (p *MicrosoftEntraIDProvider) Redeem(ctx context.Context, redirectURL, code return p.redeemWithFederatedToken(ctx, redirectURL, code, codeVerifier) } + if p.redeemScope != "" { + return p.redeemWithScope(ctx, redirectURL, code, codeVerifier) + } + return p.OIDCProvider.Redeem(ctx, redirectURL, code, codeVerifier) } +// redeemWithScope performs the token exchange sending an explicit scope +// parameter. Entra ID cannot issue an access token for multiple audiences, +// so a narrowed, single-audience scope can be requested at redemption while +// a broader scope list is used at authorization time. +func (p *MicrosoftEntraIDProvider) redeemWithScope(ctx context.Context, redirectURL, code, codeVerifier string) (*sessions.SessionState, error) { + clientSecret, err := p.GetClientSecret() + if err != nil { + return nil, err + } + + params := url.Values{} + if codeVerifier != "" { + params.Add("code_verifier", codeVerifier) + } + params.Add("redirect_uri", redirectURL) + params.Add("client_id", p.ClientID) + params.Add("client_secret", clientSecret) + params.Add("code", code) + params.Add("grant_type", "authorization_code") + params.Add("scope", p.redeemScope) + + token, err := p.fetchToken(ctx, params) + if err != nil { + return nil, fmt.Errorf("error fetching token: %w", err) + } + + return p.OIDCProvider.createSession(ctx, token, false) +} + // 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") @@ -130,6 +165,9 @@ func (p *MicrosoftEntraIDProvider) redeemWithFederatedToken(ctx context.Context, params.Add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") params.Add("code", code) params.Add("grant_type", "authorization_code") + if p.redeemScope != "" { + params.Add("scope", p.redeemScope) + } token, err := p.fetchToken(ctx, params) if err != nil { diff --git a/providers/ms_entra_id_test.go b/providers/ms_entra_id_test.go index b153006e..19f9faf4 100644 --- a/providers/ms_entra_id_test.go +++ b/providers/ms_entra_id_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "crypto/rsa" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -129,6 +130,46 @@ func TestAzureEntraOIDCProviderValidateSessionAllowedTenants(t *testing.T) { assert.True(t, valid) } +func TestAzureEntraOIDCProviderRedeemScope(t *testing.T) { + idToken, _ := newSignedTestIDToken(defaultIDToken) + body, _ := json.Marshal(redeemTokenResponse{ + AccessToken: accessToken, + ExpiresIn: 10, + TokenType: "Bearer", + RefreshToken: refreshToken, + IDToken: idToken, + }) + + var redeemScopes []string + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + redeemScopes = append(redeemScopes, r.Form.Get("scope")) + rw.Header().Add("content-type", "application/json") + _, _ = rw.Write(body) + })) + defer server.Close() + serverURL, _ := url.Parse(server.URL) + + provider := &MicrosoftEntraIDProvider{ + OIDCProvider: newOIDCProvider(serverURL, false), + microsoftGraphURL: microsoftGraphURL, + } + + // Without redeemScope, no scope parameter is sent + session, err := provider.Redeem(context.Background(), provider.RedeemURL.String(), "code1234", "") + assert.NoError(t, err) + assert.Equal(t, accessToken, session.AccessToken) + assert.Equal(t, "", redeemScopes[0]) + + // With redeemScope, the scope parameter is sent with the token request + provider.redeemScope = "api://my-api/.default" + session, err = provider.Redeem(context.Background(), provider.RedeemURL.String(), "code1234", "") + assert.NoError(t, err) + assert.Equal(t, accessToken, session.AccessToken) + assert.Equal(t, idToken, session.IDToken) + assert.Equal(t, "api://my-api/.default", redeemScopes[1]) +} + func mockGraphAPI(noGroupMemberPermissions bool) *httptest.Server { groupsPath := "/v1.0/me/transitiveMemberOf"