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.