From 619bc0c38dbda0a9bd0cdcccc4c00c227bfd8ecf Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 10 Jun 2026 10:08:33 +0200 Subject: [PATCH] feat(worker): real ZitadelAuthProvider (refresh-token grant, auth-code+PKCE) Headless refresh-token -> access-token exchange via OIDC discovery + token endpoint. Cached to expiry (60s margin), thread-safe, persists rotated refresh tokens, graceful null on invalid_grant/network errors. Wired into DI when online_inbox is enabled. Interactive PKCE login (UI) still pending the registered redirect URI. 7 tests, stubbed HttpMessageHandler. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Online/ZitadelAuthProvider.cs | 170 +++++++++++- src/ClaudeDo.Worker/Program.cs | 3 +- .../Online/ZitadelAuthProviderTests.cs | 241 ++++++++++++++++++ 3 files changed, 407 insertions(+), 7 deletions(-) create mode 100644 tests/ClaudeDo.Worker.Tests/Online/ZitadelAuthProviderTests.cs diff --git a/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs b/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs index 4587b4d..f17ec49 100644 --- a/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs +++ b/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs @@ -1,13 +1,171 @@ +using System.Net.Http.Json; +using System.Runtime.Versioning; +using System.Text.Json; +using System.Text.Json.Serialization; using ClaudeDo.Worker.Online.Interfaces; +using Microsoft.Extensions.Logging; namespace ClaudeDo.Worker.Online; -// TODO(online-inbox): wire the Zitadel package once client config is known (Phase 2). -// Replace this stub with a real implementation that uses OnlineTokenStore to read the -// refresh token and exchanges it for an access token via the Zitadel OIDC endpoint, -// caching the access token until near expiry. +[SupportedOSPlatform("windows")] public sealed class ZitadelAuthProvider : IOnlineAuthProvider { - public Task GetAccessTokenAsync(CancellationToken ct = default) - => Task.FromResult(null); + private readonly IHttpClientFactory _httpClientFactory; + private readonly OnlineTokenStore _tokenStore; + private readonly OnlineInboxConfig _config; + private readonly ILogger _logger; + + private readonly SemaphoreSlim _lock = new(1, 1); + + // Cached access token state. + private string? _cachedAccessToken; + private DateTimeOffset _cacheExpiry; + + // Cached token endpoint URL (discovered once). + private string? _tokenEndpoint; + + public ZitadelAuthProvider( + IHttpClientFactory httpClientFactory, + OnlineTokenStore tokenStore, + OnlineInboxConfig config, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _tokenStore = tokenStore; + _config = config; + _logger = logger; + } + + public async Task GetAccessTokenAsync(CancellationToken ct = default) + { + // Fast path: check cache without acquiring the lock. + if (_cachedAccessToken is not null && DateTimeOffset.UtcNow < _cacheExpiry) + return _cachedAccessToken; + + await _lock.WaitAsync(ct); + try + { + // Re-check inside the lock (double-checked locking). + if (_cachedAccessToken is not null && DateTimeOffset.UtcNow < _cacheExpiry) + return _cachedAccessToken; + + var refreshToken = _tokenStore.Read(); + if (refreshToken is null) + { + _logger.LogDebug("No refresh token stored; skipping token refresh."); + return null; + } + + return await RefreshAsync(refreshToken, ct); + } + finally + { + _lock.Release(); + } + } + + private async Task RefreshAsync(string refreshToken, CancellationToken ct) + { + var tokenEndpoint = await GetTokenEndpointAsync(ct); + if (tokenEndpoint is null) + return null; + + using var http = _httpClientFactory.CreateClient(nameof(ZitadelAuthProvider)); + + var form = new Dictionary + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = refreshToken, + ["client_id"] = _config.Zitadel.ClientId, + ["scope"] = _config.Zitadel.Scopes, + }; + + HttpResponseMessage response; + try + { + response = await http.PostAsync(tokenEndpoint, new FormUrlEncodedContent(form), ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Token refresh request failed."); + return null; + } + + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(ct); + if ((int)response.StatusCode == 400 && body.Contains("invalid_grant")) + { + _logger.LogWarning("Refresh token rejected (invalid_grant). Will retry once a new token is stored."); + } + else + { + _logger.LogWarning("Token refresh returned {Status}: {Body}", (int)response.StatusCode, body); + } + return null; + } + + var tokenResponse = await response.Content.ReadFromJsonAsync(ct); + if (tokenResponse?.AccessToken is null) + { + _logger.LogWarning("Token refresh response missing access_token."); + return null; + } + + // If Zitadel rotated the refresh token, persist the new one. + if (tokenResponse.RefreshToken is not null && tokenResponse.RefreshToken != refreshToken) + { + _logger.LogDebug("Refresh token rotated; persisting new token."); + _tokenStore.Save(tokenResponse.RefreshToken); + } + + // Cache the access token (subtract 60 s safety margin; minimum 0 to avoid far-future expiry on zero). + _cachedAccessToken = tokenResponse.AccessToken; + _cacheExpiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn - 60); + + return _cachedAccessToken; + } + + private async Task GetTokenEndpointAsync(CancellationToken ct) + { + if (_tokenEndpoint is not null) + return _tokenEndpoint; + + var discoveryUrl = _config.Zitadel.Authority.TrimEnd('/') + "/.well-known/openid-configuration"; + + using var http = _httpClientFactory.CreateClient(nameof(ZitadelAuthProvider)); + try + { + var doc = await http.GetFromJsonAsync(discoveryUrl, ct); + _tokenEndpoint = doc?.TokenEndpoint; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to discover OIDC configuration from {Url}.", discoveryUrl); + return null; + } + + if (_tokenEndpoint is null) + _logger.LogWarning("OIDC discovery at {Url} did not return a token_endpoint.", discoveryUrl); + + return _tokenEndpoint; + } + + private sealed class OidcDiscovery + { + [JsonPropertyName("token_endpoint")] + public string? TokenEndpoint { get; init; } + } + + private sealed class TokenResponse + { + [JsonPropertyName("access_token")] + public string? AccessToken { get; init; } + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; init; } + + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; init; } + } } diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 2b61241..1778286 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -156,9 +156,10 @@ if (cfg.OnlineInbox.Enabled) { OnlineInboxApiClient.ValidateBaseUrl(cfg.OnlineInbox.ApiBaseUrl); builder.Services.AddSingleton(cfg.OnlineInbox); - builder.Services.AddSingleton(); + builder.Services.AddHttpClient(); #pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here. builder.Services.AddSingleton(); + builder.Services.AddSingleton(); #pragma warning restore CA1416 builder.Services.AddHttpClient(client => { diff --git a/tests/ClaudeDo.Worker.Tests/Online/ZitadelAuthProviderTests.cs b/tests/ClaudeDo.Worker.Tests/Online/ZitadelAuthProviderTests.cs new file mode 100644 index 0000000..6f8987c --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Online/ZitadelAuthProviderTests.cs @@ -0,0 +1,241 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using ClaudeDo.Worker.Online; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ClaudeDo.Worker.Tests.Online; + +/// +/// Tests for using a stub . +/// Token-store tests use a real temp-dir (DPAPI) and are +/// Windows-only, consistent with . +/// +public sealed class ZitadelAuthProviderTests : IDisposable +{ + // ── Stubs ─────────────────────────────────────────────────────────────── + + private sealed class StubHandler : HttpMessageHandler + { + public record Expectation(string UrlContains, HttpStatusCode Status, string Body); + + private readonly Queue _queue = new(); + public List Requests { get; } = new(); + + public void Enqueue(string urlContains, HttpStatusCode status, string body) + => _queue.Enqueue(new Expectation(urlContains, status, body)); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Requests.Add(request); + + if (_queue.Count == 0) + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("Unexpected request", Encoding.UTF8, "text/plain"), + }); + + var exp = _queue.Dequeue(); + return Task.FromResult(new HttpResponseMessage(exp.Status) + { + Content = new StringContent(exp.Body, Encoding.UTF8, "application/json"), + }); + } + } + + private sealed class StubHttpClientFactory : IHttpClientFactory + { + private readonly StubHandler _handler; + public StubHttpClientFactory(StubHandler handler) => _handler = handler; + public HttpClient CreateClient(string name) => new(_handler) { BaseAddress = null }; + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private readonly string _tokenPath = Path.Combine( + Path.GetTempPath(), $"zitadel_token_{Guid.NewGuid():N}.bin"); + + public void Dispose() + { + try { File.Delete(_tokenPath); } catch { } + } + + private static string DiscoveryJson(string tokenEndpoint) => + JsonSerializer.Serialize(new { token_endpoint = tokenEndpoint }); + + private static string TokenJson(string accessToken, int expiresIn = 3600, string? refreshToken = null) + { + var obj = new Dictionary + { + ["access_token"] = accessToken, + ["expires_in"] = expiresIn, + }; + if (refreshToken is not null) obj["refresh_token"] = refreshToken; + return JsonSerializer.Serialize(obj); + } + + private static OnlineInboxConfig MakeConfig() => new() + { + Enabled = true, + ApiBaseUrl = "https://api.example.com", + Zitadel = new ZitadelClientConfig + { + Authority = "https://auth.example.com", + ClientId = "client-abc", + Scopes = "openid offline_access", + }, + }; + + private (ZitadelAuthProvider Provider, StubHandler Handler, OnlineTokenStore Store) Build() + { + var handler = new StubHandler(); + var factory = new StubHttpClientFactory(handler); + var store = new OnlineTokenStore(_tokenPath); + var config = MakeConfig(); + var provider = new ZitadelAuthProvider( + factory, store, config, NullLogger.Instance); + return (provider, handler, store); + } + + // ── Tests ──────────────────────────────────────────────────────────────── + + [Fact] + public async Task NoStoredToken_ReturnsNull_WithNoHttpCall() + { + if (!OperatingSystem.IsWindows()) return; + + var (provider, handler, _) = Build(); + + var result = await provider.GetAccessTokenAsync(); + + Assert.Null(result); + Assert.Empty(handler.Requests); + } + + [Fact] + public async Task SuccessfulRefresh_ReturnsAccessToken() + { + if (!OperatingSystem.IsWindows()) return; + + var (provider, handler, store) = Build(); + store.Save("initial-refresh-token"); + + handler.Enqueue(".well-known/openid-configuration", HttpStatusCode.OK, + DiscoveryJson("https://auth.example.com/oauth/token")); + handler.Enqueue("oauth/token", HttpStatusCode.OK, + TokenJson("access-token-xyz", expiresIn: 3600)); + + var result = await provider.GetAccessTokenAsync(); + + Assert.Equal("access-token-xyz", result); + } + + [Fact] + public async Task SuccessfulRefresh_CachesToken_SecondCallMakesNoRequest() + { + if (!OperatingSystem.IsWindows()) return; + + var (provider, handler, store) = Build(); + store.Save("initial-refresh-token"); + + handler.Enqueue(".well-known", HttpStatusCode.OK, + DiscoveryJson("https://auth.example.com/oauth/token")); + handler.Enqueue("oauth/token", HttpStatusCode.OK, + TokenJson("access-token-xyz", expiresIn: 3600)); + + var first = await provider.GetAccessTokenAsync(); + var second = await provider.GetAccessTokenAsync(); // should use cache + + Assert.Equal("access-token-xyz", first); + Assert.Equal("access-token-xyz", second); + // Only two HTTP requests: discovery + token; no third request. + Assert.Equal(2, handler.Requests.Count); + } + + [Fact] + public async Task RotatedRefreshToken_IsPersisted() + { + if (!OperatingSystem.IsWindows()) return; + + var (provider, handler, store) = Build(); + store.Save("original-refresh"); + + handler.Enqueue(".well-known", HttpStatusCode.OK, + DiscoveryJson("https://auth.example.com/oauth/token")); + handler.Enqueue("oauth/token", HttpStatusCode.OK, + TokenJson("access-token", expiresIn: 3600, refreshToken: "rotated-refresh")); + + await provider.GetAccessTokenAsync(); + + var persisted = store.Read(); + Assert.Equal("rotated-refresh", persisted); + } + + [Fact] + public async Task InvalidGrant_ReturnsNull_DoesNotCrash() + { + if (!OperatingSystem.IsWindows()) return; + + var (provider, handler, store) = Build(); + store.Save("bad-refresh-token"); + + handler.Enqueue(".well-known", HttpStatusCode.OK, + DiscoveryJson("https://auth.example.com/oauth/token")); + handler.Enqueue("oauth/token", HttpStatusCode.BadRequest, + JsonSerializer.Serialize(new { error = "invalid_grant", error_description = "token expired" })); + + var result = await provider.GetAccessTokenAsync(); + + Assert.Null(result); + } + + [Fact] + public async Task TokenEndpoint_ReadFromDiscovery() + { + if (!OperatingSystem.IsWindows()) return; + + var (provider, handler, store) = Build(); + store.Save("refresh-token"); + + const string expectedEndpoint = "https://auth.example.com/custom/token"; + handler.Enqueue(".well-known", HttpStatusCode.OK, + DiscoveryJson(expectedEndpoint)); + handler.Enqueue("custom/token", HttpStatusCode.OK, + TokenJson("access-token")); + + await provider.GetAccessTokenAsync(); + + // Second request (token POST) should go to the discovered endpoint. + Assert.Equal(2, handler.Requests.Count); + Assert.Contains("custom/token", handler.Requests[1].RequestUri!.AbsoluteUri); + } + + [Fact] + public async Task DiscoveryCached_SecondRefreshSkipsDiscovery() + { + if (!OperatingSystem.IsWindows()) return; + + var (provider, handler, store) = Build(); + store.Save("refresh-token"); + + // First call: discovery + token (access token expires immediately — use short expiry). + handler.Enqueue(".well-known", HttpStatusCode.OK, + DiscoveryJson("https://auth.example.com/oauth/token")); + handler.Enqueue("oauth/token", HttpStatusCode.OK, + TokenJson("access-1", expiresIn: -100)); // already expired + + await provider.GetAccessTokenAsync(); + + // Second call: cache is stale, but discovery must NOT be re-fetched. + handler.Enqueue("oauth/token", HttpStatusCode.OK, + TokenJson("access-2", expiresIn: 3600)); + + var second = await provider.GetAccessTokenAsync(); + + Assert.Equal("access-2", second); + // Exactly 3 requests: discovery, token(1), token(2) — no second discovery. + Assert.Equal(3, handler.Requests.Count); + Assert.DoesNotContain(handler.Requests.Skip(1), r => + r.RequestUri!.AbsoluteUri.Contains(".well-known")); + } +}