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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user