This commit is contained in:
Mathias Neerup 2026-08-13 09:50:06 +02:00 committed by GitHub
commit 151efad9b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 101 additions and 0 deletions

View File

@ -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

View File

@ -530,6 +530,7 @@ character.
| ----- | ---- | ----------- |
| `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. |
| `redeemScope` | _string_ | RedeemScope is the OAuth scope specification used when redeeming the<br/>authorization code for tokens. Entra ID cannot issue an access token<br/>for multiple audiences; setting a narrowed, single-audience scope here<br/>works around that. If unset, no scope parameter is sent with the token<br/>request. |
### OIDCOptions

View File

@ -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.

View File

@ -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,
}
}

View File

@ -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 {

View File

@ -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 {
@ -310,6 +348,14 @@ func (p *MicrosoftEntraIDProvider) fetchToken(ctx context.Context, params url.Va
SetHeader("Content-Type", "application/x-www-form-urlencoded").
Do()
if err := resp.Error(); err != nil {
return nil, fmt.Errorf("token request failed: %v", err)
}
if resp.StatusCode() < 200 || resp.StatusCode() >= 300 {
return nil, fmt.Errorf("token request returned %d: %s", resp.StatusCode(), string(resp.Body()))
}
var token *oauth2.Token
var rawResponse interface{}

View File

@ -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"