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"