feat: add --entra-id-redeem-scope option for token redemption scope
Microsoft Entra ID cannot issue an access token for multiple audiences. The new --entra-id-redeem-scope option (alpha config: microsoftEntraIDConfig.redeemScope) allows requesting a broad scope list at authorization time while sending a narrowed, single-audience scope when exchanging the authorization code for tokens, as permitted by RFC 6749 section 3.3. If unset, behaviour is unchanged. Applies to both the client-secret and federated-token redemption paths of the entra-id provider. Fixes #2751 Signed-off-by: Mathias Mikkel Neerup <mane@tv2.dk>
This commit is contained in:
parent
10b68716e5
commit
10726f83cc
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue