From 89d2c2cb39f25ceb56ed5f4785f299a15650720f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 17:38:19 +0000 Subject: [PATCH] feat: keep provider and owner access tokens in the OS secret store [minor] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub and Azure DevOps personal access tokens, usually scoped to repo and workflow, were plain [JsonInclude] fields on BuildProvider and Owner. Every QueueSaveAppData serialized the whole object graph to an unencrypted file under the user's app data directory, readable by anything running as the same user and picked up by any backup or file-sync tool covering it. TokenStorage now holds them in the platform-native secret store through ktsu.CredentialCache, under a service name scoped to BuildMonitor. Personas are derived rather than stored: a versioned namespace plus the provider name, and the owner name as well for an override. AppData is left with no credential-shaped state at all, and the owner-shadows-provider semantics stay a lookup order rather than extra persisted fields. The namespace carries a version because changing the derivation would orphan every token already in the store. LegacyToken keeps the old Token JSON name so OnStart can migrate: written to the secret store first, so a store that throws cannot lose it, and only then blanked in the file. A token already in the store wins over a stale copy, but the stale copy is still cleared — ceasing to write a secret does not remove the one already on disk. With no usable secret store, tokens read as empty and the reason is logged once rather than on every request path. A throw out of a token read would take down the render loop of a desktop app. There is no plaintext fallback. Fixes #278 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018AnVpPWKjVtXnAvd4bnzUL --- BuildMonitor.Test/TokenStorageTests.cs | 357 +++++++++++++++++++++++++ BuildMonitor/BuildMonitor.cs | 7 + BuildMonitor/BuildMonitor.csproj | 1 + BuildMonitor/BuildProvider.cs | 30 ++- BuildMonitor/Owner.cs | 30 ++- BuildMonitor/TokenStorage.cs | 212 +++++++++++++++ CLAUDE.md | 18 +- Directory.Packages.props | 1 + README.md | 4 + 9 files changed, 655 insertions(+), 5 deletions(-) create mode 100644 BuildMonitor.Test/TokenStorageTests.cs create mode 100644 BuildMonitor/TokenStorage.cs diff --git a/BuildMonitor.Test/TokenStorageTests.cs b/BuildMonitor.Test/TokenStorageTests.cs new file mode 100644 index 0000000..1bf342a --- /dev/null +++ b/BuildMonitor.Test/TokenStorageTests.cs @@ -0,0 +1,357 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.BuildMonitor.Test; + +using System.Text.Json; +using System.Text.Json.Serialization; + +using ktsu.CredentialCache; +using ktsu.CredentialCache.Storage; +using ktsu.RoundTripStringJsonConverter; +using ktsu.Semantics.Strings; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using CredentialCache = ktsu.CredentialCache.CredentialCache; + +/// +/// Covers where provider and owner access tokens live. They are GitHub and Azure DevOps personal +/// access tokens, so the contract under test is that they reach the OS secret store and do not +/// survive in the app data file earlier versions serialized them into. +/// +[TestClass] +public sealed class TokenStorageTests +{ + private CredentialCache Cache { get; set; } = null!; + + [TestInitialize] + public void SetUp() + { + Cache = new CredentialCache(new InMemoryCredentialStore()); + TokenStorage.UseCache(Cache); + } + + [TestCleanup] + public void TearDown() + { + TokenStorage.UseCache(null); + Cache.Dispose(); + } + + /// + /// A provider with no network behaviour, so the storage contract can be driven without a real + /// GitHub or Azure DevOps client. + /// + private sealed class TestProvider(string name) : BuildProvider + { + internal override BuildProviderName Name { get; } = name.As(); + + /// + /// Writes the provider token the same way the base class's setter does. The setter itself is + /// private, so this reaches the storage directly rather than widening it for a test. + /// + internal void SetToken(string token) => + Assert.IsTrue(TokenStorage.Write(TokenPersona, token.As())); + + internal BuildProviderToken ReadToken() => Token; + + internal override Task UpdateRepositoriesAsync(Owner owner) => Task.CompletedTask; + internal override Task UpdateBuildsAsync(Repository repository) => Task.CompletedTask; + internal override Task UpdateBuildAsync(Build build) => Task.CompletedTask; + internal override Task UpdateRunAsync(Run run) => Task.CompletedTask; + } + + /// + /// A store standing in for a machine whose native secret library will not load — a Linux host + /// with no Secret Service provider, which is what a container or an SSH session usually is. + /// + private sealed class UnavailableCredentialStore : ICredentialStore + { + public string Name => "Unavailable"; + + public bool TryLoad(PersonaGUID persona, out Credential? credential) => + throw new DllNotFoundException("libsecret-1.so.0"); + + public void Save(PersonaGUID persona, Credential credential) => + throw new DllNotFoundException("libsecret-1.so.0"); + + public bool Remove(PersonaGUID persona) => throw new DllNotFoundException("libsecret-1.so.0"); + } + + /// + /// Mirrors how writes the app data file: references preserved, + /// and semantic strings round-tripped as plain strings rather than as char arrays. + /// + private static readonly JsonSerializerOptions AppDataLike = BuildAppDataLikeOptions(); + + private static JsonSerializerOptions BuildAppDataLikeOptions() + { + JsonSerializerOptions options = new() + { + ReferenceHandler = ReferenceHandler.Preserve, + }; + options.Converters.Add(new RoundTripStringJsonConverterFactory()); + return options; + } + + private static TestProvider NewProvider(string name = "TestHub") => new(name); + + private static Owner AddOwner(TestProvider provider, string name) + { + OwnerName ownerName = name.As(); + Owner owner = provider.CreateOwner(ownerName); + Assert.IsTrue(provider.Owners.TryAdd(ownerName, owner)); + return owner; + } + + /// + /// Two providers never share a persona, so setting one's token cannot disturb the other's. + /// + [TestMethod] + public void ProviderPersonasAreDistinct() + { + PersonaGUID gitHub = TokenStorage.ProviderPersona("GitHub".As()); + PersonaGUID azure = TokenStorage.ProviderPersona("AzureDevOps".As()); + + Assert.AreNotEqual(gitHub, azure); + } + + /// + /// The same organization name can exist on more than one provider, so the owner persona is scoped + /// by provider too. + /// + [TestMethod] + public void OwnerPersonasAreScopedByProvider() + { + OwnerName owner = "ktsu-dev".As(); + PersonaGUID onGitHub = TokenStorage.OwnerPersona("GitHub".As(), owner); + PersonaGUID onAzure = TokenStorage.OwnerPersona("AzureDevOps".As(), owner); + + Assert.AreNotEqual(onGitHub, onAzure); + } + + /// + /// An owner's persona is never the provider's, so an owner override cannot overwrite the + /// provider-level token. + /// + [TestMethod] + public void OwnerPersonaIsNotTheProviderPersona() + { + BuildProviderName provider = "GitHub".As(); + + Assert.AreNotEqual( + TokenStorage.ProviderPersona(provider), + TokenStorage.OwnerPersona(provider, "ktsu-dev".As())); + } + + /// + /// Persona derivation is deterministic, and pinned. Changing it would orphan every token already + /// in the store, so it must not drift silently. + /// + [TestMethod] + public void PersonaDerivationIsStable() + { + BuildProviderName provider = "GitHub".As(); + + Assert.AreEqual( + TokenStorage.ProviderPersona(provider).ToString(), + TokenStorage.ProviderPersona(provider).ToString()); + Assert.IsTrue(Guid.TryParse(TokenStorage.ProviderPersona(provider).ToString(), out _)); + } + + /// + /// A provider token set through the normal path is readable again, and came from the store. + /// + [TestMethod] + public void ProviderTokenRoundTripsThroughTheStore() + { + TestProvider provider = NewProvider(); + + provider.SetToken("ghp_provider"); + + Assert.AreEqual("ghp_provider", provider.ReadToken().ToString()); + Assert.IsTrue(Cache.TryGet(provider.TokenPersona, out Credential? credential)); + Assert.IsInstanceOfType(credential); + } + + /// + /// Setting a token leaves nothing behind in the field the app data file is built from. This is + /// the whole point of the change. + /// + [TestMethod] + public void SettingAProviderTokenWritesNothingToAppData() + { + TestProvider provider = NewProvider(); + + provider.SetToken("ghp_provider"); + + Assert.IsTrue(provider.LegacyToken.IsEmpty()); + } + + /// + /// The serialized form of a provider carries no token. A reader of the app data file learns + /// nothing. + /// + [TestMethod] + public void SerializedProviderCarriesNoToken() + { + TestProvider provider = NewProvider(); + provider.SetToken("ghp_secret_value"); + + string json = JsonSerializer.Serialize(provider, AppDataLike); + + Assert.DoesNotContain("ghp_secret_value", json, StringComparison.Ordinal); + Assert.Contains("\"Token\":\"\"", json, StringComparison.Ordinal); + } + + /// + /// An owner override is stored separately from the provider token, and both survive. + /// + [TestMethod] + public void OwnerTokenIsStoredSeparatelyFromTheProviderToken() + { + TestProvider provider = NewProvider(); + Owner owner = AddOwner(provider, "ktsu-dev"); + + provider.SetToken("ghp_provider"); + owner.Token = "ghp_owner".As(); + + Assert.AreEqual("ghp_provider", provider.ReadToken().ToString()); + Assert.AreEqual("ghp_owner", owner.Token.ToString()); + Assert.IsTrue(owner.HasToken); + Assert.IsTrue(owner.LegacyToken.IsEmpty()); + } + + /// + /// Clearing a token removes it from the store rather than leaving an empty entry behind. + /// + [TestMethod] + public void ClearingATokenRemovesItFromTheStore() + { + TestProvider provider = NewProvider(); + Owner owner = AddOwner(provider, "ktsu-dev"); + owner.Token = "ghp_owner".As(); + + owner.Token = new(); + + Assert.IsFalse(owner.HasToken); + Assert.IsFalse(Cache.TryGet(owner.TokenPersona, out _)); + } + + /// + /// A provider token and an owner token left by an earlier version are both moved into the store. + /// + [TestMethod] + public void MigrationMovesProviderAndOwnerTokens() + { + TestProvider provider = NewProvider(); + Owner owner = AddOwner(provider, "ktsu-dev"); + provider.LegacyToken = "ghp_old_provider".As(); + owner.LegacyToken = "ghp_old_owner".As(); + + int migrated = TokenStorage.MigrateLegacyTokens([provider]); + + Assert.AreEqual(2, migrated); + Assert.AreEqual("ghp_old_provider", provider.ReadToken().ToString()); + Assert.AreEqual("ghp_old_owner", owner.Token.ToString()); + } + + /// + /// Migration also blanks the old copies. Ceasing to write a secret does not remove the one + /// already on disk, so this is the half that does the security work. + /// + [TestMethod] + public void MigrationBlanksThePlaintextCopies() + { + TestProvider provider = NewProvider(); + Owner owner = AddOwner(provider, "ktsu-dev"); + provider.LegacyToken = "ghp_old_provider".As(); + owner.LegacyToken = "ghp_old_owner".As(); + + _ = TokenStorage.MigrateLegacyTokens([provider]); + + Assert.IsTrue(provider.LegacyToken.IsEmpty()); + Assert.IsTrue(owner.LegacyToken.IsEmpty()); + } + + /// + /// With nothing left over there is nothing to migrate, so a second start is a no-op. + /// + [TestMethod] + public void MigrationIsIdempotent() + { + TestProvider provider = NewProvider(); + provider.LegacyToken = "ghp_old_provider".As(); + + _ = TokenStorage.MigrateLegacyTokens([provider]); + + Assert.AreEqual(0, TokenStorage.MigrateLegacyTokens([provider])); + Assert.AreEqual("ghp_old_provider", provider.ReadToken().ToString()); + } + + /// + /// A stale token in the old file must not overwrite the one currently in the store — but it is + /// still cleared. + /// + [TestMethod] + public void MigrationKeepsTheStoredTokenAndStillClearsTheStaleOne() + { + TestProvider provider = NewProvider(); + provider.SetToken("ghp_current"); + provider.LegacyToken = "ghp_stale".As(); + + Assert.AreEqual(1, TokenStorage.MigrateLegacyTokens([provider])); + Assert.AreEqual("ghp_current", provider.ReadToken().ToString()); + Assert.IsTrue(provider.LegacyToken.IsEmpty()); + } + + /// + /// If the secret store will not take the token, the old copy stays where it is. Clearing it would + /// destroy the only copy the user has. + /// + [TestMethod] + public void MigrationKeepsTheLegacyTokenWhenTheStoreRefuses() + { + using CredentialCache unavailable = new(new UnavailableCredentialStore()); + TokenStorage.UseCache(unavailable); + + TestProvider provider = NewProvider(); + provider.LegacyToken = "ghp_old_provider".As(); + + Assert.AreEqual(0, TokenStorage.MigrateLegacyTokens([provider])); + Assert.AreEqual("ghp_old_provider", provider.LegacyToken.ToString()); + } + + /// + /// A machine with no secret store reads as "no token" rather than throwing. BuildMonitor is a + /// desktop application, and an exception out of a token read would take down the render loop; it + /// reports the problem in the log instead. What it must never do is fall back to a plain file. + /// + [TestMethod] + public void ReadingWithoutASecretStoreIsEmptyRatherThanFatal() + { + using CredentialCache unavailable = new(new UnavailableCredentialStore()); + TokenStorage.UseCache(unavailable); + + TestProvider provider = NewProvider(); + + Assert.IsTrue(provider.ReadToken().IsEmpty()); + Assert.IsFalse(TokenStorage.Write(provider.TokenPersona, "ghp_x".As())); + } + + /// + /// Two owners under one provider do not share a token. + /// + [TestMethod] + public void OwnersDoNotShareTokens() + { + TestProvider provider = NewProvider(); + Owner first = AddOwner(provider, "ktsu-dev"); + Owner second = AddOwner(provider, "ktsu-io"); + + first.Token = "ghp_first".As(); + + Assert.AreEqual("ghp_first", first.Token.ToString()); + Assert.IsFalse(second.HasToken); + } +} diff --git a/BuildMonitor/BuildMonitor.cs b/BuildMonitor/BuildMonitor.cs index 3672999..04eb97b 100644 --- a/BuildMonitor/BuildMonitor.cs +++ b/BuildMonitor/BuildMonitor.cs @@ -66,6 +66,13 @@ private static void OnStart() // add more providers here as needed + int migratedTokens = TokenStorage.MigrateLegacyTokens(AppData.BuildProviders.Values); + if (migratedTokens > 0) + { + Log.Info($"Moved {migratedTokens} access token(s) out of the app data file into the OS secret store"); + needsSave = true; + } + if (needsSave) { QueueSaveAppData(); diff --git a/BuildMonitor/BuildMonitor.csproj b/BuildMonitor/BuildMonitor.csproj index d4631a9..b83ec39 100644 --- a/BuildMonitor/BuildMonitor.csproj +++ b/BuildMonitor/BuildMonitor.csproj @@ -12,6 +12,7 @@ + diff --git a/BuildMonitor/BuildProvider.cs b/BuildMonitor/BuildProvider.cs index 83bd695..b922fdc 100644 --- a/BuildMonitor/BuildProvider.cs +++ b/BuildMonitor/BuildProvider.cs @@ -7,6 +7,7 @@ namespace ktsu.BuildMonitor; using Hexa.NET.ImGui; +using ktsu.CredentialCache; using ktsu.ImGui.Popups; using ktsu.Semantics.Strings; @@ -46,8 +47,35 @@ internal abstract class BuildProvider internal abstract BuildProviderName Name { get; } [JsonInclude] internal BuildProviderAccountId AccountId { get; private set; } = new(); + + /// + /// The provider token as earlier versions persisted it: plaintext, in the app data file. + /// + /// + /// Retained under its original JSON name only so + /// can move an existing token into the OS secret store and blank it here. Nothing writes a token + /// here any more. + /// [JsonInclude] - protected BuildProviderToken Token { get; private set; } = new(); + [JsonPropertyName("Token")] + internal BuildProviderToken LegacyToken { get; set; } = new(); + + /// + /// The persona this provider's token is stored under. + /// + [JsonIgnore] + internal PersonaGUID TokenPersona => TokenStorage.ProviderPersona(Name); + + /// + /// The provider-level access token, held in the OS secret store rather than the app data file. + /// + [JsonIgnore] + protected BuildProviderToken Token + { + get => TokenStorage.Read(TokenPersona); + private set => _ = TokenStorage.Write(TokenPersona, value); + } + [JsonInclude] internal ConcurrentDictionary Owners { get; init; } = []; private bool ShouldShowAccountIdPopup { get; set; } diff --git a/BuildMonitor/Owner.cs b/BuildMonitor/Owner.cs index 01dee0b..f821598 100644 --- a/BuildMonitor/Owner.cs +++ b/BuildMonitor/Owner.cs @@ -5,6 +5,7 @@ namespace ktsu.BuildMonitor; using System.Collections.Concurrent; using System.Text.Json.Serialization; +using ktsu.CredentialCache; using ktsu.Semantics.Strings; internal sealed record class OwnerName : SemanticString { } @@ -18,12 +19,37 @@ internal sealed class Owner public bool Enabled { get; set; } public ConcurrentDictionary Repositories { get; init; } = []; + /// + /// The owner token as earlier versions persisted it: plaintext, in the app data file. + /// + /// + /// Retained under its original JSON name only so + /// can move an existing token into the OS secret + /// store and blank it here. Nothing writes a token here any more. + /// + [JsonInclude] + [JsonPropertyName("Token")] + internal BuildProviderToken LegacyToken { get; set; } = new(); + + /// + /// The persona this owner's token is stored under. + /// + [JsonIgnore] + internal PersonaGUID TokenPersona => TokenStorage.OwnerPersona(BuildProvider.Name, Name); + /// /// Optional token for this specific owner. If set, overrides the provider-level token. /// Useful for accessing private repositories in different organizations. /// - [JsonInclude] - public BuildProviderToken Token { get; internal set; } = new(); + /// + /// Kept in the OS secret store, not in the app data file. + /// + [JsonIgnore] + public BuildProviderToken Token + { + get => TokenStorage.Read(TokenPersona); + internal set => _ = TokenStorage.Write(TokenPersona, value); + } /// /// Returns true if this owner has a specific token configured. diff --git a/BuildMonitor/TokenStorage.cs b/BuildMonitor/TokenStorage.cs new file mode 100644 index 0000000..f38363b --- /dev/null +++ b/BuildMonitor/TokenStorage.cs @@ -0,0 +1,212 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.BuildMonitor; + +using System.Security.Cryptography; +using System.Text; + +using ktsu.CredentialCache; +using ktsu.CredentialCache.Storage; +using ktsu.Semantics.Strings; + +using CredentialCache = ktsu.CredentialCache.CredentialCache; + +/// +/// Holds provider and owner access tokens in the operating system's secret store — Windows +/// Credential Manager, macOS Keychain, or libsecret (Secret Service) on Linux — rather than in +/// 's JSON file. +/// +/// +/// The tokens are GitHub and Azure DevOps personal access tokens, usually scoped to +/// repo/workflow. serializes the whole app data +/// object to an unencrypted file on every save, which is not somewhere a PAT belongs. +/// +internal static class TokenStorage +{ + /// + /// Scopes BuildMonitor's entries within the OS secret store so they cannot collide with another + /// ktsu tool's credentials on a shared host. + /// + internal const string CredentialServiceName = "ktsu.BuildMonitor"; + + /// + /// Prefixed to every persona seed. Versioned because changing the derivation would orphan every + /// token already in the store, so a future change has to be a deliberate, visible one. + /// + private const string PersonaNamespace = "ktsu.BuildMonitor/v1/"; + + private const string UnavailableMessage = + "No usable OS secret store was found, so access tokens cannot be read or saved. On Linux this " + + "usually means no Secret Service provider (libsecret with GNOME Keyring or KWallet) is installed " + + "and unlocked. BuildMonitor will not fall back to writing tokens to a plain file."; + + private static readonly Lazy LazyDefaultCache = + new(() => new CredentialCache(CredentialStoreFactory.CreateDefault(CredentialServiceName))); + + private static int _unavailableReported; + + /// + /// The cache substituted by , or for the default. + /// + private static CredentialCache? InjectedCache { get; set; } + + /// + /// Gets the cache backing this storage. + /// + /// + /// Built directly rather than taken from so the store + /// carries BuildMonitor's own service name; the singleton can only ever use the library default. + /// + internal static CredentialCache Cache => InjectedCache ?? LazyDefaultCache.Value; + + /// + /// Substitutes the backing cache. Test seam; pass to restore the default. + /// + internal static void UseCache(CredentialCache? cache) + { + InjectedCache = cache; + _ = Interlocked.Exchange(ref _unavailableReported, 0); + } + + /// + /// Derives the persona holding a provider-level token. + /// + internal static PersonaGUID ProviderPersona(BuildProviderName provider) => + DerivePersona($"provider/{provider}"); + + /// + /// Derives the persona holding an owner's override token. Scoped by provider as well as owner, + /// because the same organization name can exist on more than one provider. + /// + internal static PersonaGUID OwnerPersona(BuildProviderName provider, OwnerName owner) => + DerivePersona($"provider/{provider}/owner/{owner}"); + + /// + /// Reads the token stored under , or an empty token when there is none. + /// + internal static BuildProviderToken Read(PersonaGUID persona) + { + try + { + return Cache.TryGet(persona, out Credential? credential) && credential is CredentialWithToken token + ? token.Token.ToString().As() + : new(); + } + catch (Exception ex) when (IsUnavailable(ex)) + { + ReportUnavailable(ex); + return new(); + } + } + + /// + /// Stores under , removing the entry when the + /// token is empty. + /// + /// when the secret store refused the write. + internal static bool Write(PersonaGUID persona, BuildProviderToken token) + { + try + { + if (token.IsEmpty()) + { + _ = Cache.Remove(persona); + return true; + } + + Cache.AddOrReplace(persona, new CredentialWithToken + { + Token = SemanticString.Create(token.ToString()), + }); + return true; + } + catch (Exception ex) when (IsUnavailable(ex)) + { + ReportUnavailable(ex); + return false; + } + } + + /// + /// Moves tokens left in by earlier versions into the secret store and + /// blanks them where they were. + /// + /// How many tokens were moved. + internal static int MigrateLegacyTokens(IEnumerable providers) + { + Ensure.NotNull(providers); + + int migrated = 0; + + foreach (BuildProvider provider in providers) + { + if (MigrateToken(provider.TokenPersona, provider.LegacyToken, () => provider.LegacyToken = new())) + { + migrated++; + } + + foreach (Owner owner in provider.Owners.Values) + { + PersonaGUID persona = OwnerPersona(provider.Name, owner.Name); + if (MigrateToken(persona, owner.LegacyToken, () => owner.LegacyToken = new())) + { + migrated++; + } + } + } + + return migrated; + } + + /// + /// Moves one token, leaving the old copy in place if the secret store will not take it. + /// + /// + /// A token already in the store wins over a legacy one, so a stale copy in the old file cannot + /// overwrite a current credential — but the stale copy is still cleared, because ceasing to write + /// a secret does not remove the one already on disk. + /// + private static bool MigrateToken(PersonaGUID persona, BuildProviderToken legacy, Action clearLegacy) + { + if (legacy.IsEmpty()) + { + return false; + } + + if (Read(persona).IsEmpty() && !Write(persona, legacy)) + { + return false; + } + + clearLegacy(); + return true; + } + + private static PersonaGUID DerivePersona(string seed) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(PersonaNamespace + seed)); + return SemanticString.Create(new Guid(hash.AsSpan(0, 16)).ToString()); + } + + /// + /// Recognises a machine with no usable secret store: the factory refusing the platform, the + /// native library failing to resolve, or the store itself reporting a failure. + /// + private static bool IsUnavailable(Exception exception) => + exception is PlatformNotSupportedException + or DllNotFoundException + or EntryPointNotFoundException + or CredentialStoreException; + + /// + /// Logs the unavailable store once per process. Tokens are read on request paths that run every + /// few seconds, so reporting each failure would bury the log. + /// + private static void ReportUnavailable(Exception exception) + { + if (Interlocked.Exchange(ref _unavailableReported, 1) == 0) + { + Log.Error($"{UnavailableMessage} ({exception.Message})"); + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index ab17a78..42aa1f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,13 +170,27 @@ The application implements sophisticated rate limit management to avoid hitting ### Authentication and Credentials +**Where tokens live:** +- Access tokens are held in the OS secret store (Windows Credential Manager, macOS Keychain, + libsecret on Linux) through `TokenStorage`, not in the app data file +- `TokenStorage` derives a persona per token from a versioned namespace plus + `BuildProviderName` (and `OwnerName` for an override), so `AppData` holds no credential-shaped + state at all. Changing the derivation orphans every token already stored, which is why the + namespace carries a version +- `BuildProvider.LegacyToken` and `Owner.LegacyToken` map to the old `Token` JSON field and exist + only for the one-time migration `OnStart` runs: the token is written to the secret store first, + then blanked in the app data file +- With no usable secret store, tokens read as empty and the reason is logged once. BuildMonitor is + a desktop app, so a throw out of a token read would take down the render loop. It never falls + back to writing tokens to a plain file + **Provider-Level Authentication:** -- Each provider has an `AccountId` and `Token` (stored in AppData, persisted) +- Each provider has an `AccountId` (stored in AppData) and a `Token` (stored in the OS secret store) - Set via "Set Credentials" menu item (two-step popup: AccountId, then Token) - Cleared automatically on `AuthorizationException` or 403 Forbidden responses **Owner-Level Authentication (GitHub only):** -- Each owner can have an optional `Token` property (overrides provider token) +- Each owner can have an optional `Token` property (overrides provider token), also held in the OS secret store - Enables access to private repositories in different organizations - Set via "Providers → GitHub → Set Owner Token" submenu - Clear via "Providers → GitHub → Set Owner Token → Clear Owner Token" submenu diff --git a/Directory.Packages.props b/Directory.Packages.props index 53dcbcc..ce8d580 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/README.md b/README.md index 72463db..32d66cb 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,10 @@ Full support for GitHub Actions including: 1. Go to **Providers > GitHub > Set Credentials** 2. Enter your GitHub username 3. Enter a Personal Access Token (PAT) with `repo` and `workflow` scopes + +Tokens are kept in the operating system's secret store — Windows Credential Manager, macOS Keychain, +or libsecret (Secret Service) on Linux — not in the app data file. A token saved by an earlier +version is moved there on the next start and blanked where it was, so nothing needs re-entering. 4. Add owners via **Providers > GitHub > Add Owner** Additional providers are planned for future releases.