fix(bitbucket): auth failure due to Bitbucket API changes (#3477)

* fix: #3428 bybitbucket auth failure by changing token n validation flow to use Authentication header instead of query param

Co-authored-by: aviralgarg05 <gargaviral99@gmail.com>
Signed-off-by: Mohammad Hassan <m8fouad@gmail.com>
Signed-off-by: Jan Larwig <jan.larwig@digits.schwarz>

* fix: added changelog entry and added tests to verify bitbucket auth header fix

Signed-off-by: Mohammad Hassan <m8fouad@gmail.com>

* fix(bitbucket): aded support for limiting login for workspace members and handled deprecated team api and added test cases

Signed-off-by: Mohammad Hassan <m8fouad@gmail.com>

* docs(bitbucket): applied missing docs changes

Signed-off-by: Jan Larwig <jan.larwig@digits.schwarz>

---------

Signed-off-by: Mohammad Hassan <m8fouad@gmail.com>
Signed-off-by: Jan Larwig <jan.larwig@digits.schwarz>
Co-authored-by: aviralgarg05 <gargaviral99@gmail.com>
Co-authored-by: Jan Larwig <jan.larwig@digits.schwarz>
This commit is contained in:
Mohammad Fouad
2026-10-01 09:00:53 +02:00
committed by GitHub
co-authored by aviralgarg05 Jan Larwig
parent b0d87093a8
commit bde7f9eee1
10 changed files with 197 additions and 36 deletions
+4
View File
@@ -3,6 +3,9 @@
## Release Highlights
## Important Notes
The Bitbucket provider `--bitbucket-team` flag got deprecated and we added `--bitbucket-workspace` flag to restrict logins to members of a specific workspace instead of a team. The `--bitbucket-team` flag is still supported and will act like workspace but will be removed in a future release. Please update your configuration to use the new `--bitbucket-workspace` flag. For more information, refer to [Bitbucket teams API deprecation](https://developer.atlassian.com/cloud/bitbucket/bitbucket-api-teams-deprecation/).
Additionally refer to OAuth client configuration for Bitbucket provider in the [documentation](https://oauth2-proxy.github.io/oauth2-proxy/configuration/providers/bitbucket/). for changes in the scopes (Account>Read) is now required if you restrict by workspace.
## Breaking Changes
@@ -10,6 +13,7 @@
- [#3546](https://github.com/oauth2-proxy/oauth2-proxy/pull/3546) fix: strip the port from the request host when matching cookie domains @kirilju
- [#3547](https://github.com/oauth2-proxy/oauth2-proxy/pull/3547) fix: refresh additional claims for OIDC and MS Entra ID providers and properly populate additional claims during login (@Apollo3zehn)
- [#3477](https://github.com/oauth2-proxy/oauth2-proxy/pull/3477) fix(bitbucket): auth failure due to Bitbucket OAuth 2.0 [changes on May 4th 2026](https://developer.atlassian.com/cloud/bitbucket/changelog/#CHANGE-3052) @mfouad
# V7.15.4
+1 -1
View File
@@ -24,7 +24,7 @@ _oauth2_proxy() {
COMPREPLY=( $(compgen -W 'X-Real-IP X-Forwarded-For X-ProxyUser-IP' -- ${cur}) )
return 0
;;
--@(http-address|https-address|redirect-url|upstream|basic-auth-password|skip-auth-regex|flush-interval|extra-jwt-issuers|email-domain|whitelist-domain|trusted-ip|keycloak-group|azure-tenant|bitbucket-team|bitbucket-repository|github-org|github-team|github-repo|github-token|gitlab-group|github-user|google-group|google-admin-email|google-service-account-json|client-id|client_secret|banner|footer|proxy-prefix|ping-path|ready-path|cookie-name|cookie-secret|cookie-domain|cookie-path|cookie-expire|cookie-refresh|cookie-samesite|redist-sentinel-master-name|redist-sentinel-connection-urls|redist-cluster-connection-urls|logging-max-size|logging-max-age|logging-max-backups|standard-logging-format|request-logging-format|exclude-logging-paths|auth-logging-format|oidc-issuer-url|oidc-jwks-url|login-url|redeem-url|profile-url|resource|validate-url|scope|approval-prompt|signature-key|acr-values|jwt-key|pubjwk-url|force-json-errors))
--@(http-address|https-address|redirect-url|upstream|basic-auth-password|skip-auth-regex|flush-interval|extra-jwt-issuers|email-domain|whitelist-domain|trusted-ip|keycloak-group|azure-tenant|bitbucket-workspace|bitbucket-repository|github-org|github-team|github-repo|github-token|gitlab-group|github-user|google-group|google-admin-email|google-service-account-json|client-id|client_secret|banner|footer|proxy-prefix|ping-path|ready-path|cookie-name|cookie-secret|cookie-domain|cookie-path|cookie-expire|cookie-refresh|cookie-samesite|redist-sentinel-master-name|redist-sentinel-connection-urls|redist-cluster-connection-urls|logging-max-size|logging-max-age|logging-max-backups|standard-logging-format|request-logging-format|exclude-logging-paths|auth-logging-format|oidc-issuer-url|oidc-jwks-url|login-url|redeem-url|profile-url|resource|validate-url|scope|approval-prompt|signature-key|acr-values|jwt-key|pubjwk-url|force-json-errors))
return 0
;;
esac
+2 -1
View File
@@ -342,7 +342,8 @@ They may change between releases without notice.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `team` | _string_ | Team sets restrict logins to members of this team |
| `team` | _string_ | Team sets restrict logins to members of this team - Bitbucket has deprecated teams, it will act as workspace instead |
| `workspace` | _string_ | Workspace sets restrict logins to members of this workspace, use workspace slug |
| `repository` | _string_ | Repository sets restrict logins to user with access to this repository |
### ClaimSource
@@ -8,7 +8,7 @@ title: BitBucket
hostname that oauth2-proxy is running on.
* In Permissions section select:
* Account -> Email
* Team membership -> Read
* Account -> Read [Required for workspace membership check]
* Repositories -> Read
2. Note the Client ID and Client Secret.
@@ -20,6 +20,11 @@ To use the provider, pass the following options:
--client-secret=<Client Secret>
```
The default configuration allows everyone with Bitbucket account to authenticate. To restrict the access to the team
members use additional configuration option: `--bitbucket-team=<Team name>`. To restrict the access to only these users
who have access to one selected repository use `--bitbucket-repository=<Repository name>`.
The default configuration allows everyone with Bitbucket account to authenticate.
To restrict the access to members of a specific workspace, use the additional configuration option: `--bitbucket-workspace=<Workspace name>`.
To restrict the access to users who have write access to one selected repository (contributors) use `--bitbucket-repository=<Repository name>`. Note that repository full name format `owner/repo` is required, for example `--bitbucket-repository=myworkspace/myrepo`.
**Deprecated**: To restrict the access to members of a specific team, use the additional configuration option: `--bitbucket-team=<Team name>`. Note that this option is deprecated and will be removed in a future release. Please use `--bitbucket-workspace` instead. For more info, see [Bitbucket teams API deprecation](https://developer.atlassian.com/cloud/bitbucket/bitbucket-api-teams-deprecation/).
@@ -331,7 +331,8 @@ They may change between releases without notice.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `team` | _string_ | Team sets restrict logins to members of this team |
| `team` | _string_ | Team sets restrict logins to members of this team - Bitbucket has deprecated the teams, it will act as workspace instead |
| `workspace` | _string_ | Workspace sets restrict logins to members of this workspace, use workspace slug |
| `repository` | _string_ | Repository sets restrict logins to user with access to this repository |
### ClaimSource
@@ -8,7 +8,7 @@ title: BitBucket
hostname that oauth2-proxy is running on.
* In Permissions section select:
* Account -> Email
* Team membership -> Read
* Account -> Read [Required for workspace membership check]
* Repositories -> Read
2. Note the Client ID and Client Secret.
@@ -20,6 +20,11 @@ To use the provider, pass the following options:
--client-secret=<Client Secret>
```
The default configuration allows everyone with Bitbucket account to authenticate. To restrict the access to the team
members use additional configuration option: `--bitbucket-team=<Team name>`. To restrict the access to only these users
who have access to one selected repository use `--bitbucket-repository=<Repository name>`.
The default configuration allows everyone with Bitbucket account to authenticate.
To restrict the access to members of a specific workspace, use the additional configuration option: `--bitbucket-workspace=<Workspace name>`.
To restrict the access to users who have write access to one selected repository (contributors) use `--bitbucket-repository=<Repository name>`. Note that repository full name format `owner/repo` is required, for example `--bitbucket-repository=myworkspace/myrepo`.
**Deprecated**: To restrict the access to members of a specific team, use the additional configuration option: `--bitbucket-team=<Team name>`. Note that this option is deprecated and will be removed in a future release. Please use `--bitbucket-workspace` instead. For more info, see [Bitbucket teams API deprecation](https://developer.atlassian.com/cloud/bitbucket/bitbucket-api-teams-deprecation/).
+4 -1
View File
@@ -512,6 +512,7 @@ type LegacyProvider struct {
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"`
BitbucketTeam string `flag:"bitbucket-team" cfg:"bitbucket_team"`
BitbucketWorkspace string `flag:"bitbucket-workspace" cfg:"bitbucket_workspace"`
BitbucketRepository string `flag:"bitbucket-repository" cfg:"bitbucket_repository"`
GitHubOrg string `flag:"github-org" cfg:"github_org"`
GitHubTeam string `flag:"github-team" cfg:"github_team"`
@@ -580,7 +581,8 @@ 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("bitbucket-team", "", "restrict logins to members of this team")
flagSet.String("bitbucket-team", "", "[deprecated, use bitbucket-workspace instead] restrict logins to members of this team")
flagSet.String("bitbucket-workspace", "", "restrict logins to members of this workspace, use workspace slug (eg. `myworkspace`) instead of workspace name (eg. `My Workspace`)")
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-team", "", "restrict logins to members of this team")
@@ -777,6 +779,7 @@ func (l *LegacyProvider) convert() (Providers, error) {
case "bitbucket":
provider.BitbucketConfig = BitbucketOptions{
Team: l.BitbucketTeam,
Workspace: l.BitbucketWorkspace,
Repository: l.BitbucketRepository,
}
case "google":
+3 -1
View File
@@ -236,8 +236,10 @@ type ADFSOptions struct {
}
type BitbucketOptions struct {
// Team sets restrict logins to members of this team
// Team sets restrict logins to members of this team - Bitbucket has deprecated teams, it will act as workspace instead
Team string `yaml:"team,omitempty"`
// Workspace sets restrict logins to members of this workspace, use workspace slug
Workspace string `yaml:"workspace,omitempty"`
// Repository sets restrict logins to user with access to this repository
Repository string `yaml:"repository,omitempty"`
}
+37 -19
View File
@@ -14,7 +14,7 @@ import (
// BitbucketProvider represents an Bitbucket based Identity Provider
type BitbucketProvider struct {
*ProviderData
Team string
Workspace string
Repository string
}
@@ -67,7 +67,10 @@ func NewBitbucketProvider(p *ProviderData, opts options.BitbucketOptions) *Bitbu
provider := &BitbucketProvider{ProviderData: p}
if opts.Team != "" {
provider.setTeam(opts.Team)
provider.setWorkspace(opts.Team)
}
if opts.Workspace != "" {
provider.setWorkspace(opts.Workspace)
}
if opts.Repository != "" {
provider.setRepository(opts.Repository)
@@ -75,11 +78,11 @@ func NewBitbucketProvider(p *ProviderData, opts options.BitbucketOptions) *Bitbu
return provider
}
// setTeam defines the Bitbucket team the user must be part of
func (p *BitbucketProvider) setTeam(team string) {
p.Team = team
if !strings.Contains(p.Scope, "team") {
p.Scope += " team"
// setWorkspace defines the Bitbucket workspace the user must be part of
func (p *BitbucketProvider) setWorkspace(workspace string) {
p.Workspace = workspace
if !strings.Contains(p.Scope, "account") {
p.Scope += " account"
}
}
@@ -91,6 +94,11 @@ func (p *BitbucketProvider) setRepository(repository string) {
}
}
// ValidateSession validates the AccessToken using a Bearer token header
func (p *BitbucketProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool {
return validateToken(ctx, p, s.AccessToken, makeOIDCHeader(s.AccessToken))
}
// GetEmailAddress returns the email of the authenticated user
func (p *BitbucketProvider) GetEmailAddress(ctx context.Context, s *sessions.SessionState) (string, error) {
@@ -100,9 +108,11 @@ func (p *BitbucketProvider) GetEmailAddress(ctx context.Context, s *sessions.Ses
Primary bool `json:"is_primary"`
}
}
var teams struct {
var workspaces struct {
Values []struct {
Name string `json:"username"`
Workspace struct {
Slug string `json:"slug"`
} `json:"workspace"`
}
}
var repositories struct {
@@ -111,9 +121,10 @@ func (p *BitbucketProvider) GetEmailAddress(ctx context.Context, s *sessions.Ses
}
}
requestURL := p.ValidateURL.String() + "?access_token=" + s.AccessToken
requestURL := p.ValidateURL.String()
err := requests.New(requestURL).
WithContext(ctx).
WithHeaders(makeOIDCHeader(s.AccessToken)).
Do().
UnmarshalInto(&emails)
if err != nil {
@@ -121,24 +132,29 @@ func (p *BitbucketProvider) GetEmailAddress(ctx context.Context, s *sessions.Ses
return "", err
}
if p.Team != "" {
if p.Workspace != "" {
teamURL := &url.URL{}
*teamURL = *p.ValidateURL
teamURL.Path = "/2.0/teams"
// /teams api was deprecated in Oct 20, use workspaces instead
// https://developer.atlassian.com/cloud/bitbucket/bitbucket-api-teams-deprecation/
// https://developer.atlassian.com/cloud/bitbucket/rest/api-group-workspaces/#api-workspaces-get
teamURL.Path = "2.0/user/workspaces"
requestURL := teamURL.String() + "?role=member&access_token=" + s.AccessToken
requestURL := teamURL.String()
err := requests.New(requestURL).
WithContext(ctx).
WithHeaders(makeOIDCHeader(s.AccessToken)).
Do().
UnmarshalInto(&teams)
UnmarshalInto(&workspaces)
logger.Printf("workspaces: %+v", workspaces)
if err != nil {
logger.Errorf("failed requesting teams membership: %v", err)
return "", err
}
var found = false
for _, team := range teams.Values {
if p.Team == team.Name {
for _, workspace := range workspaces.Values {
if p.Workspace == workspace.Workspace.Slug {
found = true
break
}
@@ -152,14 +168,16 @@ func (p *BitbucketProvider) GetEmailAddress(ctx context.Context, s *sessions.Ses
if p.Repository != "" {
repositoriesURL := &url.URL{}
*repositoriesURL = *p.ValidateURL
repositoriesURL.Path = "/2.0/repositories/" + strings.Split(p.Repository, "/")[0]
// split the repository name to get the workspace name, which is the first part of the repository name
var repoWorkspace = strings.Split(p.Repository, "/")[0]
repositoriesURL.Path = "/2.0/repositories/" + repoWorkspace
requestURL := repositoriesURL.String() + "?role=contributor" +
"&q=full_name=" + url.QueryEscape("\""+p.Repository+"\"") +
"&access_token=" + s.AccessToken
"&q=full_name=" + url.QueryEscape("\""+p.Repository+"\"")
err := requests.New(requestURL).
WithContext(ctx).
WithHeaders(makeOIDCHeader(s.AccessToken)).
Do().
UnmarshalInto(&repositories)
if err != nil {
+126 -4
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
@@ -25,6 +26,7 @@ func testBitbucketProvider(hostname, team string, repository string) *BitbucketP
Scope: ""},
options.BitbucketOptions{
Team: team,
Workspace: team,
Repository: repository,
},
)
@@ -41,7 +43,6 @@ func testBitbucketProvider(hostname, team string, repository string) *BitbucketP
func testBitbucketBackend(payload string) *httptest.Server {
paths := map[string]bool{
"/2.0/user/emails": true,
"/2.0/teams": true,
}
return httptest.NewServer(http.HandlerFunc(
@@ -50,7 +51,7 @@ func testBitbucketBackend(payload string) *httptest.Server {
if !paths[url.Path] {
log.Printf("%s not in %+v\n", url.Path, paths)
w.WriteHeader(404)
} else if !IsAuthorizedInURL(r.URL) {
} else if !IsAuthorizedInHeader(r.Header) {
w.WriteHeader(403)
} else {
w.WriteHeader(200)
@@ -59,6 +60,34 @@ func testBitbucketBackend(payload string) *httptest.Server {
}))
}
func testBitbucketBackendWithWorkspace(emailPayload, workspacePayload string) *httptest.Server {
return testBitbucketBackendFull(emailPayload, workspacePayload, "")
}
func testBitbucketBackendFull(emailPayload, workspacePayload, repoPayload string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if !IsAuthorizedInHeader(r.Header) {
w.WriteHeader(403)
return
}
switch {
case r.URL.Path == "/2.0/user/emails":
w.WriteHeader(200)
w.Write([]byte(emailPayload))
case r.URL.Path == "/2.0/user/workspaces":
w.WriteHeader(200)
w.Write([]byte(workspacePayload))
case strings.HasPrefix(r.URL.Path, "/2.0/repositories/"):
w.WriteHeader(200)
w.Write([]byte(repoPayload))
default:
log.Printf("%s not handled\n", r.URL.Path)
w.WriteHeader(404)
}
}))
}
func TestNewBitbucketProvider(t *testing.T) {
g := NewWithT(t)
@@ -75,7 +104,7 @@ func TestNewBitbucketProvider(t *testing.T) {
func TestBitbucketProviderScopeAdjustForTeam(t *testing.T) {
p := testBitbucketProvider("", "test-team", "")
assert.NotEqual(t, nil, p)
assert.Equal(t, "email team", p.Data().Scope)
assert.Equal(t, "email account", p.Data().Scope)
}
func TestBitbucketProviderScopeAdjustForRepository(t *testing.T) {
@@ -126,7 +155,9 @@ func TestBitbucketProviderGetEmailAddress(t *testing.T) {
}
func TestBitbucketProviderGetEmailAddressAndGroup(t *testing.T) {
b := testBitbucketBackend("{\"values\": [ { \"email\": \"michael.bland@gsa.gov\", \"is_primary\": true, \"username\": \"bioinformatics\" } ] }")
emailPayload := `{"values": [ { "email": "michael.bland@gsa.gov", "is_primary": true } ] }`
workspacePayload := `{"values": [ { "type": "workspace_access", "workspace": { "slug": "bioinformatics" } } ] }`
b := testBitbucketBackendWithWorkspace(emailPayload, workspacePayload)
defer b.Close()
bURL, _ := url.Parse(b.URL)
@@ -138,6 +169,97 @@ func TestBitbucketProviderGetEmailAddressAndGroup(t *testing.T) {
assert.Equal(t, "michael.bland@gsa.gov", email)
}
func TestBitbucketProviderGetEmailAddressMultipleWorkspaces(t *testing.T) {
emailPayload := `{"values": [ { "email": "michael.bland@gsa.gov", "is_primary": true } ] }`
// target workspace is the last entry to exercise the full loop
workspacePayload := `{"values": [
{ "type": "workspace_access", "workspace": { "slug": "other-team-1" } },
{ "type": "workspace_access", "workspace": { "slug": "other-team-2" } },
{ "type": "workspace_access", "workspace": { "slug": "bioinformatics" } }
]}`
b := testBitbucketBackendWithWorkspace(emailPayload, workspacePayload)
defer b.Close()
bURL, _ := url.Parse(b.URL)
p := testBitbucketProvider(bURL.Host, "bioinformatics", "")
session := CreateAuthorizedSession()
email, err := p.GetEmailAddress(context.Background(), session)
assert.Equal(t, nil, err)
assert.Equal(t, "michael.bland@gsa.gov", email)
}
func TestBitbucketProviderWorkspaceNotInList(t *testing.T) {
emailPayload := `{"values": [ { "email": "michael.bland@gsa.gov", "is_primary": true } ] }`
workspacePayload := `{"values": [
{ "type": "workspace_access", "workspace": { "slug": "other-team-1" } },
{ "type": "workspace_access", "workspace": { "slug": "other-team-2" } }
]}`
b := testBitbucketBackendWithWorkspace(emailPayload, workspacePayload)
defer b.Close()
bURL, _ := url.Parse(b.URL)
p := testBitbucketProvider(bURL.Host, "bioinformatics", "")
session := CreateAuthorizedSession()
email, err := p.GetEmailAddress(context.Background(), session)
assert.Equal(t, nil, err)
assert.Equal(t, "", email)
}
func TestBitbucketProviderGetEmailAddressAndRepository(t *testing.T) {
emailPayload := `{"values": [ { "email": "michael.bland@gsa.gov", "is_primary": true } ] }`
repoPayload := `{"values": [ { "full_name": "bioinformatics/myrepo" } ] }`
b := testBitbucketBackendFull(emailPayload, "", repoPayload)
defer b.Close()
bURL, _ := url.Parse(b.URL)
p := testBitbucketProvider(bURL.Host, "", "bioinformatics/myrepo")
session := CreateAuthorizedSession()
email, err := p.GetEmailAddress(context.Background(), session)
assert.Equal(t, nil, err)
assert.Equal(t, "michael.bland@gsa.gov", email)
}
func TestBitbucketProviderGetEmailAddressMultipleRepositories(t *testing.T) {
emailPayload := `{"values": [ { "email": "michael.bland@gsa.gov", "is_primary": true } ] }`
// target repo is the last entry to exercise the full loop
repoPayload := `{"values": [
{ "full_name": "bioinformatics/other-repo-1" },
{ "full_name": "bioinformatics/other-repo-2" },
{ "full_name": "bioinformatics/myrepo" }
]}`
b := testBitbucketBackendFull(emailPayload, "", repoPayload)
defer b.Close()
bURL, _ := url.Parse(b.URL)
p := testBitbucketProvider(bURL.Host, "", "bioinformatics/myrepo")
session := CreateAuthorizedSession()
email, err := p.GetEmailAddress(context.Background(), session)
assert.Equal(t, nil, err)
assert.Equal(t, "michael.bland@gsa.gov", email)
}
func TestBitbucketProviderRepositoryNotInList(t *testing.T) {
emailPayload := `{"values": [ { "email": "michael.bland@gsa.gov", "is_primary": true } ] }`
repoPayload := `{"values": [
{ "full_name": "bioinformatics/other-repo-1" },
{ "full_name": "bioinformatics/other-repo-2" }
]}`
b := testBitbucketBackendFull(emailPayload, "", repoPayload)
defer b.Close()
bURL, _ := url.Parse(b.URL)
p := testBitbucketProvider(bURL.Host, "", "bioinformatics/myrepo")
session := CreateAuthorizedSession()
email, err := p.GetEmailAddress(context.Background(), session)
assert.Equal(t, nil, err)
assert.Equal(t, "", email)
}
// Note that trying to trigger the "failed building request" case is not
// practical, since the only way it can fail is if the URL fails to parse.
func TestBitbucketProviderGetEmailAddressFailedRequest(t *testing.T) {