Merge 509a6708f0 into 1f049e5ebd
This commit is contained in:
commit
151efad9b4
|
|
@ -8,6 +8,8 @@
|
||||||
|
|
||||||
## Changes since v7.15.3
|
## 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
|
# V7.15.3
|
||||||
|
|
||||||
## Release Highlights
|
## 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). |
|
| `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. |
|
| `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
|
### 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-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-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
|
## 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.
|
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"`
|
AzureGraphGroupField string `flag:"azure-graph-group-field" cfg:"azure_graph_group_field"`
|
||||||
EntraIDAllowedTenants []string `flag:"entra-id-allowed-tenant" cfg:"entra_id_allowed_tenants"`
|
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"`
|
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"`
|
BitbucketTeam string `flag:"bitbucket-team" cfg:"bitbucket_team"`
|
||||||
BitbucketRepository string `flag:"bitbucket-repository" cfg:"bitbucket_repository"`
|
BitbucketRepository string `flag:"bitbucket-repository" cfg:"bitbucket_repository"`
|
||||||
GitHubOrg string `flag:"github-org" cfg:"github_org"`
|
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.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.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.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-team", "", "restrict logins to members of this team")
|
||||||
flagSet.String("bitbucket-repository", "", "restrict logins to user with access to this repository")
|
flagSet.String("bitbucket-repository", "", "restrict logins to user with access to this repository")
|
||||||
flagSet.String("github-org", "", "restrict logins to members of this organisation")
|
flagSet.String("github-org", "", "restrict logins to members of this organisation")
|
||||||
|
|
@ -800,6 +802,7 @@ func (l *LegacyProvider) convert() (Providers, error) {
|
||||||
provider.MicrosoftEntraIDConfig = MicrosoftEntraIDOptions{
|
provider.MicrosoftEntraIDConfig = MicrosoftEntraIDOptions{
|
||||||
AllowedTenants: l.EntraIDAllowedTenants,
|
AllowedTenants: l.EntraIDAllowedTenants,
|
||||||
FederatedTokenAuth: &l.EntraIDFederatedTokenAuth,
|
FederatedTokenAuth: &l.EntraIDFederatedTokenAuth,
|
||||||
|
RedeemScope: l.EntraIDRedeemScope,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,13 @@ type MicrosoftEntraIDOptions struct {
|
||||||
// FederatedTokenAuth enable oAuth2 client authentication with federated token projected
|
// FederatedTokenAuth enable oAuth2 client authentication with federated token projected
|
||||||
// by Entra Workload Identity plugin, instead of client secret.
|
// by Entra Workload Identity plugin, instead of client secret.
|
||||||
FederatedTokenAuth *bool `yaml:"federatedTokenAuth,omitempty"`
|
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 {
|
type ADFSOptions struct {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ type MicrosoftEntraIDProvider struct {
|
||||||
*OIDCProvider
|
*OIDCProvider
|
||||||
multiTenantAllowedTenants []string
|
multiTenantAllowedTenants []string
|
||||||
federatedTokenAuth bool
|
federatedTokenAuth bool
|
||||||
|
redeemScope string
|
||||||
|
|
||||||
microsoftGraphURL *url.URL
|
microsoftGraphURL *url.URL
|
||||||
}
|
}
|
||||||
|
|
@ -54,6 +55,7 @@ func NewMicrosoftEntraIDProvider(p *ProviderData, opts options.Provider) *Micros
|
||||||
|
|
||||||
multiTenantAllowedTenants: opts.MicrosoftEntraIDConfig.AllowedTenants,
|
multiTenantAllowedTenants: opts.MicrosoftEntraIDConfig.AllowedTenants,
|
||||||
federatedTokenAuth: ptr.Deref(opts.MicrosoftEntraIDConfig.FederatedTokenAuth, options.DefaultMicrosoftEntraIDUseFederatedToken),
|
federatedTokenAuth: ptr.Deref(opts.MicrosoftEntraIDConfig.FederatedTokenAuth, options.DefaultMicrosoftEntraIDUseFederatedToken),
|
||||||
|
redeemScope: opts.MicrosoftEntraIDConfig.RedeemScope,
|
||||||
microsoftGraphURL: microsoftGraphURL,
|
microsoftGraphURL: microsoftGraphURL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -105,9 +107,42 @@ func (p *MicrosoftEntraIDProvider) Redeem(ctx context.Context, redirectURL, code
|
||||||
return p.redeemWithFederatedToken(ctx, redirectURL, code, codeVerifier)
|
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)
|
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
|
// 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) {
|
func (p *MicrosoftEntraIDProvider) redeemWithFederatedToken(ctx context.Context, redirectURL, code, codeVerifier string) (*sessions.SessionState, error) {
|
||||||
federatedTokenPath := os.Getenv("AZURE_FEDERATED_TOKEN_FILE")
|
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("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
|
||||||
params.Add("code", code)
|
params.Add("code", code)
|
||||||
params.Add("grant_type", "authorization_code")
|
params.Add("grant_type", "authorization_code")
|
||||||
|
if p.redeemScope != "" {
|
||||||
|
params.Add("scope", p.redeemScope)
|
||||||
|
}
|
||||||
|
|
||||||
token, err := p.fetchToken(ctx, params)
|
token, err := p.fetchToken(ctx, params)
|
||||||
if err != nil {
|
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").
|
SetHeader("Content-Type", "application/x-www-form-urlencoded").
|
||||||
Do()
|
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 token *oauth2.Token
|
||||||
var rawResponse interface{}
|
var rawResponse interface{}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
|
@ -129,6 +130,46 @@ func TestAzureEntraOIDCProviderValidateSessionAllowedTenants(t *testing.T) {
|
||||||
assert.True(t, valid)
|
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 {
|
func mockGraphAPI(noGroupMemberPermissions bool) *httptest.Server {
|
||||||
groupsPath := "/v1.0/me/transitiveMemberOf"
|
groupsPath := "/v1.0/me/transitiveMemberOf"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue