diff --git a/CHANGELOG.md b/CHANGELOG.md index 050bd0c2..2c460410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ## Changes since v7.8.2 +- [#3031](https://github.com/oauth2-proxy/oauth2-proxy/pull/3031) Fixes Refresh Token bug with Entra ID and Workload Identity (#3027)[https://github.com/oauth2-proxy/oauth2-proxy/issues/3028] by using client assertion when redeeming the token (@richard87) - [#3001](https://github.com/oauth2-proxy/oauth2-proxy/pull/3001) Allow to set non-default authorization request response mode (@stieler-it) - [#3041](https://github.com/oauth2-proxy/oauth2-proxy/pull/3041) chore(deps): upgrade to latest golang v1.23.x release (@TheImplementer) diff --git a/providers/ms_entra_id.go b/providers/ms_entra_id.go index 330c7875..df1f38a4 100644 --- a/providers/ms_entra_id.go +++ b/providers/ms_entra_id.go @@ -8,7 +8,9 @@ import ( "net/url" "os" "regexp" + "time" + "github.com/coreos/go-oidc/v3/oidc" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" @@ -114,7 +116,8 @@ func (p *MicrosoftEntraIDProvider) redeemWithFederatedToken(ctx context.Context, params := url.Values{} - // create custom exchange parameters + // Exchange parameters for token federation + // https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#request-an-access-token-with-a-certificate-credential if codeVerifier != "" { params.Add("code_verifier", codeVerifier) } @@ -125,29 +128,78 @@ func (p *MicrosoftEntraIDProvider) redeemWithFederatedToken(ctx context.Context, params.Add("code", code) params.Add("grant_type", "authorization_code") - // perform exchange - resp := requests.New(p.RedeemURL.String()). - WithContext(ctx). - WithMethod("POST"). - WithBody(bytes.NewBufferString(params.Encode())). - SetHeader("Content-Type", "application/x-www-form-urlencoded"). - Do() - - // prepare token of type *oauth2.Token - var token *oauth2.Token - var rawResponse interface{} - - body := resp.Body() - if err := json.Unmarshal(body, &rawResponse); err != nil { - return nil, err + token, err := p.fetchToken(ctx, params) + if err != nil { + return nil, fmt.Errorf("error fetching token: %w", err) } - if err := json.Unmarshal(body, &token); err != nil { - return nil, err + return p.OIDCProvider.createSession(ctx, token, false) +} + +// RefreshSession uses the RefreshToken to fetch new Access and ID Tokens +func (p *MicrosoftEntraIDProvider) RefreshSession(ctx context.Context, s *sessions.SessionState) (bool, error) { + if s == nil || s.RefreshToken == "" { + return false, nil } - // create session using new token and generic OIDC provider - return p.OIDCProvider.createSession(ctx, token.WithExtra(rawResponse), false) + var err error + ctx = oidc.ClientContext(ctx, requests.DefaultHTTPClient) + if p.federatedTokenAuth { + err = p.redeemRefreshTokenWithFederatedToken(ctx, s) + } else { + err = p.redeemRefreshToken(ctx, s) + } + + if err != nil { + return false, fmt.Errorf("unable to redeem refresh token: %v", err) + } + + return true, nil +} + +// redeemRefreshTokenWithFederatedToken uses a RefreshToken and federated credentials with the RedeemURL to refresh the +// Refresh Token, Access Token and ID Token +func (p *MicrosoftEntraIDProvider) redeemRefreshTokenWithFederatedToken(ctx context.Context, s *sessions.SessionState) error { + federatedTokenPath := os.Getenv("AZURE_FEDERATED_TOKEN_FILE") + federatedToken, err := os.ReadFile(federatedTokenPath) + if err != nil { + return fmt.Errorf("error reading federated token file %s: %s", federatedTokenPath, err) + } + + params := url.Values{} + params.Add("client_id", p.ClientID) + params.Add("client_assertion", string(federatedToken)) + params.Add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") + params.Add("refresh_token", s.RefreshToken) + params.Add("grant_type", "refresh_token") + params.Add("expiry", time.Now().Add(-time.Hour).Format(time.RFC3339)) + + token, err := p.fetchToken(ctx, params) + if err != nil { + return fmt.Errorf("error fetching token: %w", err) + } + + newSession, err := p.OIDCProvider.createSession(ctx, token, true) + if err != nil { + return fmt.Errorf("unable create new session state from response: %v", err) + } + + // Update the ID Token and user details if returned as part of the refresh response + // ref. https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse + if newSession.IDToken != "" { + s.IDToken = newSession.IDToken + s.Email = newSession.Email + s.User = newSession.User + s.Groups = newSession.Groups + s.PreferredUsername = newSession.PreferredUsername + } + + s.AccessToken = newSession.AccessToken + s.RefreshToken = newSession.RefreshToken + s.CreatedAt = newSession.CreatedAt + s.ExpiresOn = newSession.ExpiresOn + + return nil } // checkGroupOverage checks ID token's group membership claims for the group overage @@ -245,3 +297,26 @@ func (p *MicrosoftEntraIDProvider) checkTenantMatchesTenantList(tenant string, a } return false } + +func (p *MicrosoftEntraIDProvider) fetchToken(ctx context.Context, params url.Values) (*oauth2.Token, error) { + resp := requests.New(p.RedeemURL.String()). + WithContext(ctx). + WithMethod("POST"). + WithBody(bytes.NewBufferString(params.Encode())). + SetHeader("Content-Type", "application/x-www-form-urlencoded"). + Do() + + var token *oauth2.Token + var rawResponse interface{} + + body := resp.Body() + if err := json.Unmarshal(body, &rawResponse); err != nil { + return nil, fmt.Errorf("unable to unmarshal raw response body: %w", err) + } + + if err := json.Unmarshal(body, &token); err != nil { + return nil, fmt.Errorf("unable to unmarshal token response body: %w", err) + } + + return token.WithExtra(rawResponse), nil +}