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:
@@ -447,7 +447,7 @@
|
||||
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
||||
"conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
|
||||
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
|
||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt." },
|
||||
|
||||
@@ -447,7 +447,7 @@
|
||||
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
||||
"conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
|
||||
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
|
||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s)." },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public sealed record OnlineLoginResult(bool Success, string? RefreshToken, string? Error);
|
||||
public sealed record OnlineLoginResult(bool Success, string? RefreshToken, string? Error, string? Warning = null);
|
||||
|
||||
public interface IOnlineLoginService
|
||||
{
|
||||
|
||||
@@ -34,7 +34,13 @@ public sealed class OnlineLoginService : IOnlineLoginService
|
||||
return new OnlineLoginResult(false, null,
|
||||
"No refresh token returned. Ensure 'offline_access' is in scope and the client allows it.");
|
||||
|
||||
return new OnlineLoginResult(true, result.RefreshToken, null);
|
||||
// Early heads-up: if the access token lacks the "user" project role the server will
|
||||
// reject sync with a 401. Login still succeeds; surface this as a warning, not an error.
|
||||
var warning = ZitadelTokenInspector.HasUserRole(result.AccessToken)
|
||||
? null
|
||||
: "missing-user-role";
|
||||
|
||||
return new OnlineLoginResult(true, result.RefreshToken, null, warning);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
|
||||
64
src/ClaudeDo.Ui/Services/ZitadelTokenInspector.cs
Normal file
64
src/ClaudeDo.Ui/Services/ZitadelTokenInspector.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal, dependency-free inspection of a Zitadel JWT access token. Used to warn early when
|
||||
/// a freshly issued token lacks the "user" project role (the server otherwise rejects sync
|
||||
/// with a 401). The server remains the source of truth — this check fails open.
|
||||
/// </summary>
|
||||
public static class ZitadelTokenInspector
|
||||
{
|
||||
private const string ProjectRolesClaim = "urn:zitadel:iam:org:project:roles";
|
||||
private const string ProjectRolesClaimPrefix = "urn:zitadel:iam:org:project:";
|
||||
private const string ProjectRolesClaimSuffix = ":roles";
|
||||
private const string UserRole = "user";
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the access token carries the "user" role in either the generic or
|
||||
/// project-scoped Zitadel roles claim. Returns true (fail-open) if the token is absent or
|
||||
/// cannot be parsed — never block login on a decode hiccup.
|
||||
/// </summary>
|
||||
public static bool HasUserRole(string? accessToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(accessToken))
|
||||
return true;
|
||||
|
||||
var parts = accessToken.Split('.');
|
||||
if (parts.Length < 2)
|
||||
return true;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(Base64UrlDecode(parts[1]));
|
||||
foreach (var claim in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (claim.Name != ProjectRolesClaim &&
|
||||
!(claim.Name.StartsWith(ProjectRolesClaimPrefix, StringComparison.Ordinal) &&
|
||||
claim.Name.EndsWith(ProjectRolesClaimSuffix, StringComparison.Ordinal)))
|
||||
continue;
|
||||
|
||||
if (claim.Value.ValueKind == JsonValueKind.Object &&
|
||||
claim.Value.TryGetProperty(UserRole, out _))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Base64UrlDecode(string input)
|
||||
{
|
||||
var s = input.Replace('-', '+').Replace('_', '/');
|
||||
switch (s.Length % 4)
|
||||
{
|
||||
case 2: s += "=="; break;
|
||||
case 3: s += "="; break;
|
||||
}
|
||||
return Convert.FromBase64String(s);
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,9 @@ public sealed partial class OnlineInboxSettingsViewModel : ViewModelBase
|
||||
|
||||
await _worker.SetOnlineInboxAuthAsync(result.RefreshToken!);
|
||||
SignedIn = true;
|
||||
StatusMessage = Loc.T("vm.onlineInbox.signedIn");
|
||||
StatusMessage = result.Warning == "missing-user-role"
|
||||
? Loc.T("vm.onlineInbox.signedInNoRole")
|
||||
: Loc.T("vm.onlineInbox.signedIn");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -3,4 +3,11 @@ namespace ClaudeDo.Worker.Online.Interfaces;
|
||||
public interface IOnlineAuthProvider
|
||||
{
|
||||
Task<string?> GetAccessTokenAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an access token, optionally dropping any cached token first so a fresh
|
||||
/// (role-bearing) token is minted via the refresh-token grant. Used to recover from a
|
||||
/// 401 caused by a stale token issued before role assertion was enabled.
|
||||
/// </summary>
|
||||
Task<string?> GetAccessTokenAsync(bool forceRefresh, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ public sealed class OnlineSyncService : BackgroundService
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (OnlineInboxException ex) when (ex.StatusCode == 401)
|
||||
{
|
||||
_logger.LogError(
|
||||
"OnlineSyncService: {Message} Sync is paused until you sign in again with an authorized account.",
|
||||
ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "OnlineSyncService cycle failed; backing off to next interval");
|
||||
|
||||
@@ -18,4 +18,7 @@ public sealed class StaticTokenAuthProvider : IOnlineAuthProvider
|
||||
|
||||
public Task<string?> GetAccessTokenAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(_token);
|
||||
|
||||
public Task<string?> GetAccessTokenAsync(bool forceRefresh, CancellationToken ct = default)
|
||||
=> Task.FromResult(_token);
|
||||
}
|
||||
|
||||
@@ -36,19 +36,29 @@ public sealed class ZitadelAuthProvider : IOnlineAuthProvider
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string?> GetAccessTokenAsync(CancellationToken ct = default)
|
||||
public Task<string?> GetAccessTokenAsync(CancellationToken ct = default)
|
||||
=> GetAccessTokenAsync(false, ct);
|
||||
|
||||
public async Task<string?> GetAccessTokenAsync(bool forceRefresh, CancellationToken ct = default)
|
||||
{
|
||||
// Fast path: check cache without acquiring the lock.
|
||||
if (_cachedAccessToken is not null && DateTimeOffset.UtcNow < _cacheExpiry)
|
||||
if (!forceRefresh && _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)
|
||||
if (!forceRefresh && _cachedAccessToken is not null && DateTimeOffset.UtcNow < _cacheExpiry)
|
||||
return _cachedAccessToken;
|
||||
|
||||
if (forceRefresh)
|
||||
{
|
||||
// Drop the stale access token so the refresh-token grant mints a fresh one.
|
||||
_cachedAccessToken = null;
|
||||
_cacheExpiry = default;
|
||||
}
|
||||
|
||||
var refreshToken = _tokenStore.Read();
|
||||
if (refreshToken is null)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user