refactor(worker): OnlineTokenStore zu DpapiTokenStore verallgemeinert

This commit is contained in:
mika kuns
2026-08-27 12:27:34 +02:00
parent 3171690bae
commit 41bfc242fa
21 changed files with 136 additions and 137 deletions
+57
View File
@@ -0,0 +1,57 @@
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
using ClaudeDo.Data;
namespace ClaudeDo.Worker;
/// <summary>
/// Persists a single secret encrypted with DPAPI (CurrentUser scope), one instance per file.
/// Windows-only. Used for the Online-Inbox refresh token (~/.claudeDo/online-inbox.token) and
/// the ticket-system PAT (~/.claudeDo/ticket.pat).
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class DpapiTokenStore
{
private readonly string _tokenPath;
public DpapiTokenStore(string tokenPath) => _tokenPath = tokenPath;
public static DpapiTokenStore InAppData(string fileName)
=> new(Path.Combine(Paths.AppDataRoot(), fileName));
public void Save(string secret)
{
ArgumentException.ThrowIfNullOrEmpty(secret);
var cipher = ProtectedData.Protect(Encoding.UTF8.GetBytes(secret), null, DataProtectionScope.CurrentUser);
Directory.CreateDirectory(Path.GetDirectoryName(_tokenPath)!);
File.WriteAllBytes(_tokenPath, cipher);
}
public string? Read()
{
if (!File.Exists(_tokenPath)) return null;
try
{
var plain = ProtectedData.Unprotect(File.ReadAllBytes(_tokenPath), null, DataProtectionScope.CurrentUser);
return Encoding.UTF8.GetString(plain);
}
catch
{
return null;
}
}
public bool Exists() => File.Exists(_tokenPath);
public void Clear()
{
if (File.Exists(_tokenPath)) File.Delete(_tokenPath);
}
}
/// DI-Marker: der Online-Inbox-Refresh-Token.
public sealed record OnlineRefreshTokenStore(DpapiTokenStore Store);
/// DI-Marker: der Personal Access Token des Ticketsystems.
public sealed record TicketPatStore(DpapiTokenStore Store);
+3 -3
View File
@@ -69,7 +69,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly RefineRunner _refineRunner;
private readonly WorkerConfig _cfg;
private readonly OnlineInboxConfig _onlineInboxConfig;
private readonly OnlineTokenStore _onlineTokenStore;
private readonly DpapiTokenStore _onlineTokenStore;
private readonly Runner.PendingQuestionRegistry _pendingQuestions;
private readonly LogRingBuffer? _logBuffer;
private readonly ISessionSkillRegistry _skillRegistry;
@@ -104,7 +104,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
RefineRunner refineRunner,
WorkerConfig cfg,
OnlineInboxConfig onlineInboxConfig,
OnlineTokenStore onlineTokenStore,
OnlineRefreshTokenStore onlineTokenStore,
Runner.PendingQuestionRegistry pendingQuestions,
ISessionSkillRegistry skillRegistry,
LogRingBuffer? logBuffer = null,
@@ -138,7 +138,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_refineRunner = refineRunner;
_cfg = cfg;
_onlineInboxConfig = onlineInboxConfig;
_onlineTokenStore = onlineTokenStore;
_onlineTokenStore = onlineTokenStore.Store;
_pendingQuestions = pendingQuestions;
_skillRegistry = skillRegistry;
_logBuffer = logBuffer;
@@ -1,54 +0,0 @@
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
using ClaudeDo.Data;
namespace ClaudeDo.Worker.Online;
/// <summary>
/// Persists the Zitadel refresh token encrypted with DPAPI (CurrentUser scope).
/// Windows-only; the file lives at ~/.claudeDo/online-inbox.token.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class OnlineTokenStore
{
private readonly string _tokenPath;
public OnlineTokenStore()
: this(Path.Combine(Paths.AppDataRoot(), "online-inbox.token")) { }
internal OnlineTokenStore(string tokenPath)
{
_tokenPath = tokenPath;
}
public void Save(string refreshToken)
{
ArgumentException.ThrowIfNullOrEmpty(refreshToken);
var plain = Encoding.UTF8.GetBytes(refreshToken);
var cipher = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser);
Directory.CreateDirectory(Path.GetDirectoryName(_tokenPath)!);
File.WriteAllBytes(_tokenPath, cipher);
}
public string? Read()
{
if (!File.Exists(_tokenPath)) return null;
try
{
var cipher = File.ReadAllBytes(_tokenPath);
var plain = ProtectedData.Unprotect(cipher, null, DataProtectionScope.CurrentUser);
return Encoding.UTF8.GetString(plain);
}
catch
{
return null;
}
}
public void Clear()
{
if (File.Exists(_tokenPath))
File.Delete(_tokenPath);
}
}
@@ -11,7 +11,7 @@ namespace ClaudeDo.Worker.Online;
public sealed class ZitadelAuthProvider : IOnlineAuthProvider
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly OnlineTokenStore _tokenStore;
private readonly DpapiTokenStore _tokenStore;
private readonly OnlineInboxConfig _config;
private readonly ILogger<ZitadelAuthProvider> _logger;
@@ -29,12 +29,12 @@ public sealed class ZitadelAuthProvider : IOnlineAuthProvider
public ZitadelAuthProvider(
IHttpClientFactory httpClientFactory,
OnlineTokenStore tokenStore,
OnlineRefreshTokenStore tokenStore,
OnlineInboxConfig config,
ILogger<ZitadelAuthProvider> logger)
{
_httpClientFactory = httpClientFactory;
_tokenStore = tokenStore;
_tokenStore = tokenStore.Store;
_config = config;
_logger = logger;
}
+4 -2
View File
@@ -2,6 +2,7 @@ using System.Threading;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker;
using ClaudeDo.Worker.Agents;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.External;
@@ -206,12 +207,13 @@ builder.Services.AddMcpServer()
.WithTools<TaskRunMcpService>()
.WithTools<TaskRunFindingsMcpTools>();
// OnlineInboxConfig and OnlineTokenStore are always registered so hub methods work
// OnlineInboxConfig and the refresh-token store are always registered so hub methods work
// even when sync is disabled. The sync stack (api client, auth, hosted service) is
// only registered when enabled.
builder.Services.AddSingleton(cfg.OnlineInbox);
#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here.
builder.Services.AddSingleton<OnlineTokenStore>();
builder.Services.AddSingleton(new OnlineRefreshTokenStore(DpapiTokenStore.InAppData("online-inbox.token")));
builder.Services.AddSingleton(new TicketPatStore(DpapiTokenStore.InAppData("ticket.pat")));
#pragma warning restore CA1416
if (cfg.OnlineInbox.Enabled)
@@ -20,7 +20,7 @@ public sealed class ClearMyDayHubTests : IDisposable
var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
@@ -25,7 +25,7 @@ public sealed class DeleteTaskHubTests : IDisposable
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
built.State,
null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
@@ -23,7 +23,7 @@ public sealed class ListConfigHubTests : IDisposable
var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, factory,
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
@@ -71,7 +71,7 @@ public sealed class MergeHelperTaskHubTests : IDisposable
var hub = new WorkerHub(
null!, null!, null!, null!, null!, factory, null!, null!, null!,
null!, null!, null!, null!, null!, null!, null!, built.State, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!,
logBuffer: null, interactiveLaunchSpec: interactiveLaunchSpec, worktreeManager: wtManager, git: git,
interactiveReviewSubmission: reviewSubmission);
@@ -1,3 +1,4 @@
using ClaudeDo.Worker;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Online;
@@ -21,17 +22,17 @@ public sealed class OnlineInboxHubTests : IDisposable
try { File.Delete(_tokenPath); } catch { }
}
private (WorkerHub hub, OnlineInboxConfig inboxCfg, OnlineTokenStore store) CreateHub(
OnlineInboxConfig? inboxCfg = null, OnlineTokenStore? store = null)
private (WorkerHub hub, OnlineInboxConfig inboxCfg, DpapiTokenStore store) CreateHub(
OnlineInboxConfig? inboxCfg = null, DpapiTokenStore? store = null)
{
var cfg = new WorkerConfig();
inboxCfg ??= cfg.OnlineInbox;
store ??= new OnlineTokenStore(_tokenPath);
store ??= new DpapiTokenStore(_tokenPath);
var broadcaster = new HubBroadcaster(new CapturingHubContext());
var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, null!,
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
cfg, inboxCfg, store, new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
cfg, inboxCfg, new OnlineRefreshTokenStore(store), new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
return (hub, inboxCfg, store);
@@ -95,7 +96,7 @@ public sealed class OnlineInboxHubTests : IDisposable
var cfg = new WorkerConfig();
// point SaveOnlineInbox to our temp file
var inboxCfg = cfg.OnlineInbox;
var store = new OnlineTokenStore(_tokenPath);
var store = new DpapiTokenStore(_tokenPath);
var broadcaster = new HubBroadcaster(new CapturingHubContext());
// Patch cfg to save to our temp path by writing an initial file there
@@ -56,7 +56,7 @@ public sealed class PlanningHubTests : IDisposable
var hub = new WorkerHub(
null!, null!, null!, null!, null!, _db.CreateFactory(), null!, null!, null!,
_planning, _launcher, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(_proxy);
hub.Context = new FakeHubCallerContext();
@@ -18,7 +18,7 @@ public sealed class RepoImportFoldersHubTests : IDisposable
var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
@@ -51,7 +51,7 @@ public sealed class SessionSkillsHubTests : IDisposable
var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), registry);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
@@ -25,7 +25,7 @@ public sealed class SetTaskStatusHubTests : IDisposable
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
built.State,
null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
@@ -40,7 +40,7 @@ public sealed class TaskDoneDequeueHubTests : IDisposable
var hub = new WorkerHub(
null!, null!, null!, null!, null!, factory, null!, null!, null!,
null!, null!, null!, null!, null!, null!, null!, built.State, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(_proxy);
hub.Context = new FakeHubCallerContext();
@@ -19,7 +19,7 @@ public sealed class TaskUsageHubTests : IDisposable
var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
@@ -25,7 +25,7 @@ public sealed class WorkerBuildInfoHubTests
var hub = new WorkerHub(
null!, null!, null!, null!, null!, null!,
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
// No assertion on the actual SHA value (depends on the build environment) — just that
@@ -20,7 +20,7 @@ public sealed class WorktreeStateHubTests : IDisposable
var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
@@ -1,53 +0,0 @@
using ClaudeDo.Worker.Online;
namespace ClaudeDo.Worker.Tests.Online;
public sealed class OnlineTokenStoreTests : IDisposable
{
private readonly string _tokenPath = Path.Combine(Path.GetTempPath(), $"online_token_{Guid.NewGuid():N}.bin");
public void Dispose()
{
try { File.Delete(_tokenPath); } catch { }
}
[Fact]
public void Save_Read_RoundTrips()
{
if (!OperatingSystem.IsWindows()) return; // DPAPI is Windows-only
var store = new OnlineTokenStore(_tokenPath);
store.Save("my-refresh-token");
var result = store.Read();
Assert.Equal("my-refresh-token", result);
}
[Fact]
public void Clear_Removes_Token()
{
if (!OperatingSystem.IsWindows()) return;
var store = new OnlineTokenStore(_tokenPath);
store.Save("token");
store.Clear();
Assert.Null(store.Read());
}
[Fact]
public void Read_WhenFileAbsent_Returns_Null()
{
if (!OperatingSystem.IsWindows()) return;
var store = new OnlineTokenStore(_tokenPath);
Assert.Null(store.Read());
}
[Fact]
public void Clear_WhenFileAbsent_DoesNotThrow()
{
if (!OperatingSystem.IsWindows()) return;
var store = new OnlineTokenStore(_tokenPath);
store.Clear(); // no exception expected
}
}
@@ -1,6 +1,7 @@
using System.Net;
using System.Text;
using System.Text.Json;
using ClaudeDo.Worker;
using ClaudeDo.Worker.Online;
using Microsoft.Extensions.Logging.Abstractions;
@@ -8,8 +9,8 @@ namespace ClaudeDo.Worker.Tests.Online;
/// <summary>
/// Tests for <see cref="ZitadelAuthProvider"/> using a stub <see cref="HttpMessageHandler"/>.
/// Token-store tests use a real temp-dir <see cref="OnlineTokenStore"/> (DPAPI) and are
/// Windows-only, consistent with <see cref="OnlineTokenStoreTests"/>.
/// Token-store tests use a real temp-dir <see cref="DpapiTokenStore"/> (DPAPI) and are
/// Windows-only.
/// </summary>
public sealed class ZitadelAuthProviderTests : IDisposable
{
@@ -86,14 +87,14 @@ public sealed class ZitadelAuthProviderTests : IDisposable
},
};
private (ZitadelAuthProvider Provider, StubHandler Handler, OnlineTokenStore Store) Build()
private (ZitadelAuthProvider Provider, StubHandler Handler, DpapiTokenStore Store) Build()
{
var handler = new StubHandler();
var factory = new StubHttpClientFactory(handler);
var store = new OnlineTokenStore(_tokenPath);
var store = new DpapiTokenStore(_tokenPath);
var config = MakeConfig();
var provider = new ZitadelAuthProvider(
factory, store, config, NullLogger<ZitadelAuthProvider>.Instance);
factory, new OnlineRefreshTokenStore(store), config, NullLogger<ZitadelAuthProvider>.Instance);
return (provider, handler, store);
}
@@ -0,0 +1,45 @@
using ClaudeDo.Worker;
namespace ClaudeDo.Worker.Tests.Tickets;
public sealed class DpapiTokenStoreTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "cdo-tok-" + Guid.NewGuid().ToString("N"));
[Fact]
public void Save_and_read_round_trip()
{
var store = new DpapiTokenStore(Path.Combine(_dir, "ticket.pat"));
store.Save("tsp_secret");
Assert.Equal("tsp_secret", store.Read());
}
[Fact]
public void Read_returns_null_when_missing()
=> Assert.Null(new DpapiTokenStore(Path.Combine(_dir, "nope.pat")).Read());
[Fact]
public void Two_stores_with_different_files_do_not_share_state()
{
var a = new DpapiTokenStore(Path.Combine(_dir, "a.token"));
var b = new DpapiTokenStore(Path.Combine(_dir, "b.token"));
a.Save("aaa");
b.Save("bbb");
Assert.Equal("aaa", a.Read());
Assert.Equal("bbb", b.Read());
}
[Fact]
public void Clear_removes_the_file()
{
var store = new DpapiTokenStore(Path.Combine(_dir, "c.token"));
store.Save("x");
store.Clear();
Assert.Null(store.Read());
}
public void Dispose()
{
if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true);
}
}