Skip to content

Delegate provider token storage to ktsu.CredentialCache #278

Description

@matt-edmondson

What's hand-rolled

BuildProvider and Owner each hold a personal access token as a plain [JsonInclude]-annotated BuildProviderToken (a SemanticString) field:

  • BuildProvider.Token —
    [JsonInclude]
    protected BuildProviderToken Token { get; private set; } = new();
    [JsonInclude]
  • Owner.Token (per-owner override) —
    /// <summary>
    /// Optional token for this specific owner. If set, overrides the provider-level token.
    /// Useful for accessing private repositories in different organizations.
    /// </summary>
    [JsonInclude]
    public BuildProviderToken Token { get; internal set; } = new();

Both BuildProvider and Owner hang off AppData.BuildProviders (

public ConcurrentDictionary<BuildProviderName, BuildProvider> BuildProviders { get; set; } = [];
), which ktsu.AppDataStorage serializes as a whole and writes to a JSON file under the user's AppData directory on every save (BuildMonitor.QueueSaveAppData() is called right after the token is set — e.g.
ShouldShowAccountIdPopup = false;
}
else if (ShouldShowTokenPopup)
{
PopupInputString.Open(Strings.SetToken, Strings.Token, string.Empty, (result) =>
). The net effect: GitHub/Azure DevOps PATs (typically scoped to repo/workflow) end up sitting in plaintext JSON on disk rather than in an OS-native secret store.

What ktsu.CredentialCache provides

ktsu.CredentialCache.CredentialCache is an in-memory cache backed by a pluggable ICredentialStore, whose default (CredentialStoreFactory.CreateDefault()) routes to Windows Credential Manager, macOS Keychain, or Linux Secret Service (libsecret):

Why it's worth it

This isn't a line-count win — it's a genuine gap between what the app currently does (plaintext-on-disk PAT storage) and what a purpose-built OS-backed secret store gets right (encryption at rest, OS-level access control, no secret sitting in a file a backup tool or another local process can read straight off disk).

Compatibility

  • Subject (BuildMonitor) targets: net10.0
  • ktsu.CredentialCache targets: net9.0;net10.0 — compatible
  • Dependency direction: ktsu.CredentialCache's Directory.Packages.props references only ktsu.RoundTripStringJsonConverter, ktsu.Semantics.Strings, Polyfill, and System.Text.Json — no dependency on BuildMonitor, so no cycle

Sketch

Before (per provider/owner):

[JsonInclude]
protected BuildProviderToken Token { get; private set; } = new();

After:

PersonaGUID persona = /* derived/stored per provider or owner */;
CredentialCache.Instance.AddOrReplace(persona, new CredentialWithToken { Token = tokenValue.As<CredentialToken>() });
// ...
if (CredentialCache.Instance.TryGet(persona, out Credential? cred) && cred is CredentialWithToken t)
{
    // use t.Token
}

AppData would then store only the PersonaGUID (or nothing at all, if the persona ID is derived deterministically from BuildProviderName/OwnerName), not the token itself.

Caveats

  • This changes the shape of BuildProvider/Owner's persisted state: existing AppData.json files with a Token field would need a one-time migration (read the old field once, write it into CredentialCache, then stop persisting it directly) rather than a silent swap.
  • CredentialStoreFactory.CreateDefault()'s Linux backend depends on libsecret (Secret Service) being available; headless/container environments without a Secret Service provider would need a fallback story, which is only relevant if BuildMonitor is ever run outside a desktop session.
  • This is a [JsonInclude]/internal implementation detail, not part of the module's tests or any public contract, so no public API changes for consumers of this repo.

Activity

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

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions