feat(online-inbox): gate access on Zitadel "user" project role

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>
This commit is contained in:
mika kuns
2026-06-10 13:46:17 +02:00
parent 80a2de6c74
commit 23c3065f20
14 changed files with 280 additions and 40 deletions

View File

@@ -1,3 +1,5 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using ClaudeDo.Worker.Online.Interfaces;
@@ -5,6 +7,10 @@ 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;
@@ -31,54 +37,68 @@ public sealed class OnlineInboxApiClient : IOnlineInboxApi
public async Task PutListsAsync(IReadOnlyList<RemoteList> lists, CancellationToken ct = default)
{
using var req = await BuildAsync(HttpMethod.Put, "lists", lists, ct);
using var resp = await _http.SendAsync(req, ct);
await EnsureSuccessAsync(resp, ct);
using var resp = await SendAsync(HttpMethod.Put, "lists", lists, ct);
}
public async Task<IReadOnlyList<RemoteTask>> GetUnimportedTasksAsync(CancellationToken ct = default)
{
using var req = await BuildAsync(HttpMethod.Get, "tasks?imported=false", null, ct);
using var resp = await _http.SendAsync(req, ct);
await EnsureSuccessAsync(resp, ct);
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 req = await BuildAsync(HttpMethod.Post, $"tasks/{Uri.EscapeDataString(id)}/imported", null, ct);
using var resp = await _http.SendAsync(req, ct);
await EnsureSuccessAsync(resp, ct);
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 req = await BuildAsync(HttpMethod.Put, "tasks/mirror", tasks, ct);
using var resp = await _http.SendAsync(req, ct);
await EnsureSuccessAsync(resp, ct);
using var resp = await SendAsync(HttpMethod.Put, "tasks/mirror", tasks, ct);
}
private async Task<HttpRequestMessage> BuildAsync(
HttpMethod method,
string path,
object? body,
CancellationToken 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 token = await _auth.GetAccessTokenAsync(ct);
var req = new HttpRequestMessage(method, path);
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 System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
if (body is not null)
req.Content = JsonContent.Create(body);
return req;
}
private static async Task EnsureSuccessAsync(HttpResponseMessage resp, CancellationToken ct)
{
if (resp.IsSuccessStatusCode) return;
var body = await resp.Content.ReadAsStringAsync(ct);
throw new OnlineInboxException((int)resp.StatusCode,
$"Online Inbox API error {(int)resp.StatusCode}: {body}");
return await _http.SendAsync(req, ct);
}
}