Skip to content

feat: add bearer credentials and a credential injection seam - #114

Merged
matt-edmondson merged 2 commits into
mainfrom
feat/bearer-credentials
Sep 21, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
feat/bearer-credentials

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Problem

Azure DevOps accepts an Entra ID access token only as Authorization: Bearer. Neither hosting provider could send one.

HostingCredentialKind carried Token and UsernamePassword only, and AzureDevOpsProvider.ApplyAuthentication applies Token as Basic with an empty username, which is the personal access token scheme. An Entra token in that slot does not authenticate. There is no Bearer anywhere in the library today (grep -rn Bearer GitIntegration/ returns nothing), despite HostingCredentialKind.Token's own doc comment describing it as a bearer token.

Credentials also resolved solely from ktsu.CredentialCache via PersonaGUID. That suits a long-lived secret such as a personal access token and suits nothing about a short-lived one: an Entra access token is minted per session and expires within the hour, so writing it to the host's keyring would persist a secret that is stale before it is read again.

Changes

HostingCredentialKind.BearerToken, with HostingCredential.FromBearerToken. AzureDevOpsProvider sends it as a Bearer header. GitHubProvider maps it to Octokit's AuthenticationType.Bearer, which is separately what a GitHub App installation token requires, and which the single-argument Credentials constructor cannot produce (it defaults to Oauth and sends Token <value>).

FromToken keeps its existing per-host behaviour. Its doc comment is corrected: it was described as a bearer token while being applied as neither host's bearer scheme.

GitProvider.CredentialSource, a Func<HostingCredential> consulted on every resolution rather than cached, so a caller can return a freshly refreshed token each time. It takes precedence over the credential cache when set, so a caller supplying one never has to also clear whatever the keyring holds for its persona.

Returning HostingCredential.None proceeds unauthenticated. Returning null is a caller bug rather than a way to say that, so ResolveCredential throws with a message naming the mistake — the same philosophy as the existing unrecognised-subtype throw. It reaches IsAuthenticated as false instead of throwing, since a property getter must not.

HostingCredential and HostingCredentialKind become public. They are now the vocabulary a caller supplies a credential in; they were internal while the credential cache was the only way in.

Usage

TokenCredential entra = new InteractiveBrowserCredential();
TokenRequestContext context = new(["499b84ac-1321-427f-aa17-267ca6975798/user_impersonation"]);

IGitHostingProvider azure = new AzureDevOpsProvider
{
    Owner = "my-org".As<GitProviderOwner>(),
    Project = "my-project".As<AzureDevOpsProjectName>(),
    CredentialSource = () => HostingCredential.FromBearerToken(entra.GetToken(context, default).Token),
};

Testing

603/603 pass, 8 of them new:

  • UsesABearerTokenFromTheCredentialSource
  • PrefersTheCredentialSourceOverTheCredentialCache
  • ConsultsTheCredentialSourceOnEveryResolution — proves the callback is not cached, which is what makes token refresh work
  • ProceedsUnauthenticatedWhenTheCredentialSourceSuppliesNone
  • ReportsAuthenticatedForACredentialSourceSupplyingAToken
  • ThrowsWhenTheCredentialSourceReturnsNull
  • SendsBearerAuthForABearerTokenCredentialAsync (Azure DevOps, asserts the header verbatim and un-decoded — any base64 round-trip would mean it went through the Basic path)
  • SendsBearerAuthForABearerTokenCredentialAsync (GitHub)

The Azure DevOps bearer test was mutation-checked by reverting the mapping back to Basic, which fails it.

Notes

  • README updated: the feature bullet, the GitProvider properties table, and a new "Supplying a credential directly" section.
  • VERSION.md left alone, since it is bot-maintained.
  • Pre-existing and unrelated: dotnet pack reports CP0014 net9.0/net10.0 ApiCompat errors on Polyfill's shims. Reproduced on main at 480d878, so it is not from this branch.

🤖 Generated with Claude Code

Matthew Edmondson and others added 2 commits September 21, 2026 09:58
Azure DevOps accepts an Entra ID access token only as
`Authorization: Bearer`. Both hosting providers previously had no way to
send one: HostingCredentialKind carried Token and UsernamePassword only,
and AzureDevOpsProvider applies Token as Basic with an empty username,
which is the personal access token scheme. An Entra token in that slot
does not authenticate.

Credentials also resolved solely from ktsu.CredentialCache via
PersonaGUID. That suits a long-lived secret such as a personal access
token and suits nothing about a short-lived one: an Entra access token is
minted per session and expires within the hour, so writing it to the
host's keyring would persist a secret that is stale before it is read
again.

Two additions:

- HostingCredentialKind.BearerToken, with
  HostingCredential.FromBearerToken. AzureDevOpsProvider sends it as a
  Bearer header; GitHubProvider maps it to Octokit's
  AuthenticationType.Bearer, which is separately what a GitHub App
  installation token requires. FromToken keeps its existing per-host
  behaviour, and its doc comment is corrected: it was described as a
  bearer token while being applied as neither host's bearer scheme.

- GitProvider.CredentialSource, a Func<HostingCredential> consulted on
  every resolution rather than cached, so a caller can return a freshly
  refreshed token each time. It takes precedence over the credential
  cache when set. Returning HostingCredential.None proceeds
  unauthenticated; returning null throws from ResolveCredential with a
  message naming the mistake, while reaching IsAuthenticated as false
  rather than throwing from a property getter.

HostingCredential and HostingCredentialKind become public, since they are
now the vocabulary a caller supplies a credential in. They were internal
while the credential cache was the only way in.

Verified: 603/603 tests pass, including 8 new ones. The Azure DevOps
bearer test was mutation-checked by reverting the mapping to Basic, which
fails it. Pre-existing and unrelated: `dotnet pack` reports CP0014
net9.0/net10.0 ApiCompat errors on Polyfill's shims, reproduced on main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two comments described what the code used to be rather than what it is.
The diff and the pull request already carry that, and a comment
describing a prior state goes stale as soon as anything else moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@matt-edmondson
matt-edmondson merged commit a56b12c into main Sep 21, 2026
7 checks passed
@matt-edmondson
matt-edmondson deleted the feat/bearer-credentials branch September 21, 2026 00:19
@sonarqubecloud

Copy link
Copy Markdown

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.

1 participant