Skip to content

NONE: Refactor Bitbucket Cloud's OAuth2 host provider to support rotating refresh tokens - #2433

Open
danielrfraser-atlassian wants to merge 1 commit into
git-ecosystem:mainfrom
danielrfraser-atlassian:dfraser/refactor-bitbucket-cloud-oauth-behaviour
Open

NONE: Refactor Bitbucket Cloud's OAuth2 host provider to support rotating refresh tokens#2433
danielrfraser-atlassian wants to merge 1 commit into
git-ecosystem:mainfrom
danielrfraser-atlassian:dfraser/refactor-bitbucket-cloud-oauth-behaviour

Conversation

@danielrfraser-atlassian

@danielrfraser-atlassian danielrfraser-atlassian commented Sep 2, 2026

Copy link
Copy Markdown

Updates the BitbucketHostProvider to always store the refresh credentials using the resolved username from bitbucket.

Background

Bitbucket Cloud's OAuth implementation is moving to rotating refresh tokens. These refresh tokens have a TTL of 3 months, but are immediately expired (~10 minutes) after their first usage. This means after each refresh_token grant flow it's expected that clients store the newly issued refresh_token from the OAuth token set.

Another change is that the access_token and refresh_token are far larger than the previous tokens. Regularly exceeding 2500 characters. This will cause issues with CredentialStore which have limits on the credential length. The default for windows wincredman is one such store which has issues. We will be asking customers using GCM on windows (likely via git output and a deprecation notice) that they'll need to change credential store that git credential manager is configured to use to dpapi which doesn't have the size constraints.

Why does the Bitbucket OAuth code in GCM need changing?

When the initial authorization_code grant flow is run, the access_token and refresh_token returned are stored in the configured CredentialStore against the validated Bitbucket username (resolved by calling api.bitbucket.org/2.0/user with the access_token).

When the access_token stored expires, the refresh_token grant flow automatically runs to generate fresh credentials. This flow looks for a refresh_token in the Credential Store stored under the account provided by the request.UserName property.

In some cases this property is null (if the git remote URI configured specifies no username). When the account is null, the Credential Store will return back the first Credential that matches the git host. This will find the refresh_token stored by the original authorization flow. For git remotes configured with a username the CredentialManager will look for a secret belonging exactly to that account.

When, the refresh_token grant flow has concluded and the access_token has been validated the new refresh_token is stored in CredentialStore against the account provided by the remoteUri.GetUserName() function. However, because remoteUri is built with includeUser=False (which is the default) the refresh_token credential will always be stored against a null account in the credential store.

Now on some credentialStores this will find the first credential configured (as the account is null and that's the behaviour documented by AddOrUpdate) however on file system based backends (Which we will be requiring windows users to move to with dpapi) it appears as though it will create a new file for the credential:
image
Notice from this image above the "latest" refresh token is store in a file called .credential. However, when the next git operation needs to auth, the CredentialStore.get appears to take the <username>.credential file over the account-less .credential file. This behaviour appears consistent between both Windows and MacOSX when using a file system based CredentialStore (tried both dpapi and plaintext). Note that if the user has specified a username in their remote git URI the backend will always return back the <username>.credential file and will not ever fall back to the .credential file - in this case a reauthorization flow would be triggered.

Because of the discrepency between the locations where we store the refresh_token and retrieve it, The refresh_token will only works the first time the refresh_token grant flow is executed. Subsequent refresh_token grant flows will retrieve the original (and now expired) refresh_token - which will be blocked. This causes the auth code will fall into a new authorization grant flow. The broken behaviour would then loop in this manner indefinitely requiring constant reauthorization.

The change

This fix brings the refresh_token grant flow inline with the implementation of the authorization_code grant flow. This stores the refresh_token against the account which is the resolved Bitbucket username. This should mean that regardless of whether you've set your username in the git remote URI, the latest refresh_token will be used to initiate the refresh_token flow.

Rollout

Once the version containing this release is shipped / bundled into Git for Windows, we will be announcing a deprecation and publicly documenting the steps required to continue authing using the OAuth credentials. We will likely run a series of brownouts against the older versions of GCM prior to the complete disablement of the non-rotating refresh tokens.

We will also highlight in our deprecation / announcement (In official Atlassian channels) that any Windows users need to move from using the default CredentialStore of wincredman onto dpapi.

I sparred with the problem and fix with my colleague @mminns who worked on some of the original code some 8+ years ago.

Fixes: #2428

…ting refresh tokens by always storing refresh tokens and access tokens against the resolved Bitbucket username
@danielrfraser-atlassian
danielrfraser-atlassian requested a review from a team as a code owner September 2, 2026 07:46
@mminns mminns self-assigned this Sep 2, 2026

@mminns mminns left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A small change with limited impact, looks good to me.

@becm

becm commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

The support for authtype in Credential is not present yet.
All requests will use a Basic header combined of username:token.
Is there a transition period to drop the mangled variant and only accept Bearer?

DPAPI (as an implementation of PlaintextCredentialStore) has no convention of storing additional properties.
So the Provider will have to augment/replace the returned Credential to represent the correct token type.
→ general issue if token type is part of Credential (not supported on primitive stores) or the Provider!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An upcoming Bitbucket OAuth2 change will break the GCM on Windows when using the default wincredman credentialStore

3 participants