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 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<string?> GetAccessTokenAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<string?>(null);
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
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);
|
||||
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.
|
||||
builder.Services.AddSingleton<OnlineTokenStore>();
|
||||
builder.Services.AddSingleton<IOnlineAuthProvider, ZitadelAuthProvider>();
|
||||
#pragma warning restore CA1416
|
||||
builder.Services.AddHttpClient<IOnlineInboxApi, OnlineInboxApiClient>(client =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user