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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 ClaudeDo.Worker.Online.Interfaces;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Online;
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
// TODO(online-inbox): wire the Zitadel package once client config is known (Phase 2).
|
[SupportedOSPlatform("windows")]
|
||||||
// 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.
|
|
||||||
public sealed class ZitadelAuthProvider : IOnlineAuthProvider
|
public sealed class ZitadelAuthProvider : IOnlineAuthProvider
|
||||||
{
|
{
|
||||||
public Task<string?> GetAccessTokenAsync(CancellationToken ct = default)
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
=> Task.FromResult<string?>(null);
|
private readonly OnlineTokenStore _tokenStore;
|
||||||
|
private readonly OnlineInboxConfig _config;
|
||||||
|
private readonly ILogger<ZitadelAuthProvider> _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<ZitadelAuthProvider> logger)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_tokenStore = tokenStore;
|
||||||
|
_config = config;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> 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<string?> 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<string, string>
|
||||||
|
{
|
||||||
|
["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<TokenResponse>(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<string?> 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<OidcDiscovery>(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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,9 +156,10 @@ if (cfg.OnlineInbox.Enabled)
|
|||||||
{
|
{
|
||||||
OnlineInboxApiClient.ValidateBaseUrl(cfg.OnlineInbox.ApiBaseUrl);
|
OnlineInboxApiClient.ValidateBaseUrl(cfg.OnlineInbox.ApiBaseUrl);
|
||||||
builder.Services.AddSingleton(cfg.OnlineInbox);
|
builder.Services.AddSingleton(cfg.OnlineInbox);
|
||||||
builder.Services.AddSingleton<IOnlineAuthProvider, StaticTokenAuthProvider>();
|
builder.Services.AddHttpClient();
|
||||||
#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here.
|
#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here.
|
||||||
builder.Services.AddSingleton<OnlineTokenStore>();
|
builder.Services.AddSingleton<OnlineTokenStore>();
|
||||||
|
builder.Services.AddSingleton<IOnlineAuthProvider, ZitadelAuthProvider>();
|
||||||
#pragma warning restore CA1416
|
#pragma warning restore CA1416
|
||||||
builder.Services.AddHttpClient<IOnlineInboxApi, OnlineInboxApiClient>(client =>
|
builder.Services.AddHttpClient<IOnlineInboxApi, OnlineInboxApiClient>(client =>
|
||||||
{
|
{
|
||||||
|
|||||||
241
tests/ClaudeDo.Worker.Tests/Online/ZitadelAuthProviderTests.cs
Normal file
241
tests/ClaudeDo.Worker.Tests/Online/ZitadelAuthProviderTests.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <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"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ZitadelAuthProviderTests : IDisposable
|
||||||
|
{
|
||||||
|
// ── Stubs ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class StubHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
public record Expectation(string UrlContains, HttpStatusCode Status, string Body);
|
||||||
|
|
||||||
|
private readonly Queue<Expectation> _queue = new();
|
||||||
|
public List<HttpRequestMessage> Requests { get; } = new();
|
||||||
|
|
||||||
|
public void Enqueue(string urlContains, HttpStatusCode status, string body)
|
||||||
|
=> _queue.Enqueue(new Expectation(urlContains, status, body));
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> 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<string, object>
|
||||||
|
{
|
||||||
|
["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<ZitadelAuthProvider>.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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user