The Online API now requires the "user" project role (claim urn:zitadel:iam:org:project:roles) instead of an ALLOWED_USER_IDS allowlist. - IOnlineAuthProvider: add GetAccessTokenAsync(forceRefresh) overload - ZitadelAuthProvider: forceRefresh drops the cached token and re-runs the refresh-token grant to mint a fresh, role-bearing token - OnlineInboxApiClient: on 401, force-refresh and retry once; if still 401, throw a clear "missing 'user' role" error - OnlineSyncService: surface the 401 at Error level (no longer silent) - UI: ZitadelTokenInspector decodes the access token after login and warns early when the "user" role is absent (fail-open); shown in settings - docs: online-inbox-api-contract reflects role-based access (no allowlist) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
105 lines
3.9 KiB
C#
105 lines
3.9 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using ClaudeDo.Worker.Online.Interfaces;
|
|
|
|
namespace ClaudeDo.Worker.Online;
|
|
|
|
public sealed class OnlineInboxApiClient : IOnlineInboxApi
|
|
{
|
|
internal const string MissingRoleMessage =
|
|
"Account has no access (missing 'user' role in Zitadel). " +
|
|
"Grant the 'user' role for this account in the ClaudeDo project, then sign in again.";
|
|
|
|
private readonly HttpClient _http;
|
|
private readonly IOnlineAuthProvider _auth;
|
|
|
|
public OnlineInboxApiClient(HttpClient http, IOnlineAuthProvider auth)
|
|
{
|
|
_http = http;
|
|
_auth = auth;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that <paramref name="baseUrl"/> is HTTPS or a loopback address.
|
|
/// Throws <see cref="InvalidOperationException"/> for non-HTTPS non-loopback URLs.
|
|
/// </summary>
|
|
public static void ValidateBaseUrl(string baseUrl)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(baseUrl))
|
|
throw new InvalidOperationException("online_inbox.api_base_url is not configured.");
|
|
|
|
var uri = new Uri(baseUrl, UriKind.Absolute);
|
|
if (uri.Scheme != "https" && !uri.IsLoopback)
|
|
throw new InvalidOperationException(
|
|
$"online_inbox.api_base_url must be HTTPS or loopback. Got: {baseUrl}");
|
|
}
|
|
|
|
public async Task PutListsAsync(IReadOnlyList<RemoteList> lists, CancellationToken ct = default)
|
|
{
|
|
using var resp = await SendAsync(HttpMethod.Put, "lists", lists, ct);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<RemoteTask>> GetUnimportedTasksAsync(CancellationToken ct = default)
|
|
{
|
|
using var resp = await SendAsync(HttpMethod.Get, "tasks?imported=false", null, ct);
|
|
var result = await resp.Content.ReadFromJsonAsync<List<RemoteTask>>(ct);
|
|
return result ?? [];
|
|
}
|
|
|
|
public async Task MarkImportedAsync(string id, CancellationToken ct = default)
|
|
{
|
|
using var resp = await SendAsync(HttpMethod.Post, $"tasks/{Uri.EscapeDataString(id)}/imported", null, ct);
|
|
}
|
|
|
|
public async Task PutMirrorAsync(IReadOnlyList<MirrorTask> tasks, CancellationToken ct = default)
|
|
{
|
|
using var resp = await SendAsync(HttpMethod.Put, "tasks/mirror", tasks, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends an authenticated request. On a 401, forces a fresh (role-bearing) token via the
|
|
/// refresh-token grant and retries once; if a fresh token is still rejected, throws an
|
|
/// <see cref="OnlineInboxException"/> with <see cref="MissingRoleMessage"/>.
|
|
/// </summary>
|
|
private async Task<HttpResponseMessage> SendAsync(
|
|
HttpMethod method, string path, object? body, CancellationToken ct)
|
|
{
|
|
var resp = await SendOnceAsync(method, path, body, forceRefresh: false, ct);
|
|
|
|
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
|
{
|
|
resp.Dispose();
|
|
resp = await SendOnceAsync(method, path, body, forceRefresh: true, ct);
|
|
}
|
|
|
|
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
|
{
|
|
resp.Dispose();
|
|
throw new OnlineInboxException(401, MissingRoleMessage);
|
|
}
|
|
|
|
if (!resp.IsSuccessStatusCode)
|
|
{
|
|
var status = (int)resp.StatusCode;
|
|
var errBody = await resp.Content.ReadAsStringAsync(ct);
|
|
resp.Dispose();
|
|
throw new OnlineInboxException(status, $"Online Inbox API error {status}: {errBody}");
|
|
}
|
|
|
|
return resp;
|
|
}
|
|
|
|
private async Task<HttpResponseMessage> SendOnceAsync(
|
|
HttpMethod method, string path, object? body, bool forceRefresh, CancellationToken ct)
|
|
{
|
|
var token = await _auth.GetAccessTokenAsync(forceRefresh, ct);
|
|
using var req = new HttpRequestMessage(method, path);
|
|
if (token is not null)
|
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
if (body is not null)
|
|
req.Content = JsonContent.Create(body);
|
|
return await _http.SendAsync(req, ct);
|
|
}
|
|
}
|