diff --git a/docs/online-inbox-api-contract.md b/docs/online-inbox-api-contract.md index 7f5ff1c..377ee06 100644 --- a/docs/online-inbox-api-contract.md +++ b/docs/online-inbox-api-contract.md @@ -26,6 +26,17 @@ Sync directions (each one-way per entity → no conflict resolution needed): Single user. Both the desktop and the web client authenticate as the **same Zitadel user**. +**Access control (as of 2026-06-10).** Access is granted by assigning the **"user" project +role** in the Zitadel project "ClaudeDo" (id `376787351902355727`, issuer +`https://auth.kuns.dev`) — there is no app-side allowlist (the former `ALLOWED_USER_IDS` +env var is gone). The access token carries the role in the claim +`urn:zitadel:iam:org:project:roles` (or the project-scoped variant +`urn:zitadel:iam:org:project:376787351902355727:roles`), an object keyed by role key, e.g. +`{ "user": { "": "" } }`. The desktop OIDC client +(id `376787352137302287`) has `accessTokenRoleAssertion` enabled, so any token issued +after login/refresh includes the claim automatically — no extra scopes are needed. +Granting/revoking access is purely a Zitadel role grant, nothing app-side. + ## 2. Idle backlog definition (desktop side) The desktop mirrors only "real" backlog items, not planning internals: @@ -68,9 +79,12 @@ All task writes are idempotent upserts keyed on id. ## 4. Endpoints -All endpoints require a valid Zitadel access token (`Authorization: Bearer `). -Missing/invalid/expired → `401`. No anonymous access (imported tasks can trigger code -execution on the user's machine). +All endpoints require a valid Zitadel access token (`Authorization: Bearer `) that +carries the **"user" project role** (see §1). Missing/invalid/expired token, or a valid +token without the role → `401`. No anonymous access (imported tasks can trigger code +execution on the user's machine). The desktop client treats a `401` as: force a +refresh-token exchange and retry once; if a freshly issued token is still rejected, it +surfaces "missing 'user' role in Zitadel" and pauses sync until the user signs in again. > **Auth (VPS/.NET):** use the in-house `KunsZitadel` nuget package (feed > `https://git.kuns.dev/api/packages/kuns/nuget/index.json`) — call `AddKunsZitadel(...)` diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 1cc0161..9db1802 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -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." }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index cb6e1ea..b732dc2 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -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)." }, diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IOnlineLoginService.cs b/src/ClaudeDo.Ui/Services/Interfaces/IOnlineLoginService.cs index ac620a1..4658847 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IOnlineLoginService.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IOnlineLoginService.cs @@ -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 { diff --git a/src/ClaudeDo.Ui/Services/OnlineLoginService.cs b/src/ClaudeDo.Ui/Services/OnlineLoginService.cs index 8943e3b..51fe90f 100644 --- a/src/ClaudeDo.Ui/Services/OnlineLoginService.cs +++ b/src/ClaudeDo.Ui/Services/OnlineLoginService.cs @@ -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) { diff --git a/src/ClaudeDo.Ui/Services/ZitadelTokenInspector.cs b/src/ClaudeDo.Ui/Services/ZitadelTokenInspector.cs new file mode 100644 index 0000000..0fd4d27 --- /dev/null +++ b/src/ClaudeDo.Ui/Services/ZitadelTokenInspector.cs @@ -0,0 +1,64 @@ +using System; +using System.Text.Json; + +namespace ClaudeDo.Ui.Services; + +/// +/// 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. +/// +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"; + + /// + /// 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. + /// + 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); + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/OnlineInboxSettingsViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/OnlineInboxSettingsViewModel.cs index a3b54c7..abb2e97 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/OnlineInboxSettingsViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/OnlineInboxSettingsViewModel.cs @@ -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) { diff --git a/src/ClaudeDo.Worker/Online/Interfaces/IOnlineAuthProvider.cs b/src/ClaudeDo.Worker/Online/Interfaces/IOnlineAuthProvider.cs index 3e250cb..9c6fe07 100644 --- a/src/ClaudeDo.Worker/Online/Interfaces/IOnlineAuthProvider.cs +++ b/src/ClaudeDo.Worker/Online/Interfaces/IOnlineAuthProvider.cs @@ -3,4 +3,11 @@ namespace ClaudeDo.Worker.Online.Interfaces; public interface IOnlineAuthProvider { Task GetAccessTokenAsync(CancellationToken ct = default); + + /// + /// 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. + /// + Task GetAccessTokenAsync(bool forceRefresh, CancellationToken ct = default); } diff --git a/src/ClaudeDo.Worker/Online/OnlineInboxApiClient.cs b/src/ClaudeDo.Worker/Online/OnlineInboxApiClient.cs index 1444288..cb62681 100644 --- a/src/ClaudeDo.Worker/Online/OnlineInboxApiClient.cs +++ b/src/ClaudeDo.Worker/Online/OnlineInboxApiClient.cs @@ -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 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> 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>(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 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 BuildAsync( - HttpMethod method, - string path, - object? body, - CancellationToken ct) + /// + /// 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 + /// with . + /// + private async Task 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 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); } } diff --git a/src/ClaudeDo.Worker/Online/OnlineSyncService.cs b/src/ClaudeDo.Worker/Online/OnlineSyncService.cs index 92ac8e6..383b0c0 100644 --- a/src/ClaudeDo.Worker/Online/OnlineSyncService.cs +++ b/src/ClaudeDo.Worker/Online/OnlineSyncService.cs @@ -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"); diff --git a/src/ClaudeDo.Worker/Online/StaticTokenAuthProvider.cs b/src/ClaudeDo.Worker/Online/StaticTokenAuthProvider.cs index bbb7906..6d41a38 100644 --- a/src/ClaudeDo.Worker/Online/StaticTokenAuthProvider.cs +++ b/src/ClaudeDo.Worker/Online/StaticTokenAuthProvider.cs @@ -18,4 +18,7 @@ public sealed class StaticTokenAuthProvider : IOnlineAuthProvider public Task GetAccessTokenAsync(CancellationToken ct = default) => Task.FromResult(_token); + + public Task GetAccessTokenAsync(bool forceRefresh, CancellationToken ct = default) + => Task.FromResult(_token); } diff --git a/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs b/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs index f17ec49..ddf2836 100644 --- a/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs +++ b/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs @@ -36,19 +36,29 @@ public sealed class ZitadelAuthProvider : IOnlineAuthProvider _logger = logger; } - public async Task GetAccessTokenAsync(CancellationToken ct = default) + public Task GetAccessTokenAsync(CancellationToken ct = default) + => GetAccessTokenAsync(false, ct); + + public async Task 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) { diff --git a/tests/ClaudeDo.Ui.Tests/Services/ZitadelTokenInspectorTests.cs b/tests/ClaudeDo.Ui.Tests/Services/ZitadelTokenInspectorTests.cs new file mode 100644 index 0000000..c48e54d --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/Services/ZitadelTokenInspectorTests.cs @@ -0,0 +1,62 @@ +using System; +using System.Text; +using ClaudeDo.Ui.Services; +using Xunit; + +namespace ClaudeDo.Ui.Tests.Services; + +public class ZitadelTokenInspectorTests +{ + // Builds a fake JWT (header.payload.signature) carrying the given JSON payload. + private static string MakeToken(string payloadJson) + { + static string B64Url(string s) + { + var bytes = Encoding.UTF8.GetBytes(s); + return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + return $"{B64Url("{\"alg\":\"RS256\"}")}.{B64Url(payloadJson)}.sig"; + } + + [Fact] + public void HasUserRole_True_ForGenericRolesClaim() + { + var token = MakeToken( + "{\"urn:zitadel:iam:org:project:roles\":{\"user\":{\"org1\":\"example.com\"}}}"); + Assert.True(ZitadelTokenInspector.HasUserRole(token)); + } + + [Fact] + public void HasUserRole_True_ForProjectScopedRolesClaim() + { + var token = MakeToken( + "{\"urn:zitadel:iam:org:project:376787351902355727:roles\":{\"user\":{\"org1\":\"example.com\"}}}"); + Assert.True(ZitadelTokenInspector.HasUserRole(token)); + } + + [Fact] + public void HasUserRole_False_WhenRoleMissing() + { + var token = MakeToken( + "{\"urn:zitadel:iam:org:project:roles\":{\"admin\":{\"org1\":\"example.com\"}}}"); + Assert.False(ZitadelTokenInspector.HasUserRole(token)); + } + + [Fact] + public void HasUserRole_False_WhenNoRolesClaim() + { + var token = MakeToken("{\"sub\":\"123\",\"email\":\"a@b.c\"}"); + Assert.False(ZitadelTokenInspector.HasUserRole(token)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not-a-jwt")] + [InlineData("only.two")] + public void HasUserRole_FailsOpen_ForUnparseableInput(string? token) + { + // Cannot decide -> fail open (server remains the source of truth). + Assert.True(ZitadelTokenInspector.HasUserRole(token)); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Online/OnlineInboxApiClientTests.cs b/tests/ClaudeDo.Worker.Tests/Online/OnlineInboxApiClientTests.cs index b53cb05..b6efbb6 100644 --- a/tests/ClaudeDo.Worker.Tests/Online/OnlineInboxApiClientTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Online/OnlineInboxApiClientTests.cs @@ -130,6 +130,52 @@ public sealed class OnlineInboxApiClientTests Assert.Equal(401, ex.StatusCode); } + [Fact] + public async Task Unauthorized_RetriesOnceWithForcedRefresh_ThenThrowsMissingRole() + { + var (client, handler) = Build(); + handler.ResponseStatus = HttpStatusCode.Unauthorized; + handler.ResponseBody = "Unauthorized"; + + var ex = await Assert.ThrowsAsync( + () => client.PutListsAsync([])); + + Assert.Equal(401, ex.StatusCode); + Assert.Equal(OnlineInboxApiClient.MissingRoleMessage, ex.Message); + // Original attempt + one forced-refresh retry. + Assert.Equal(2, handler.Requests.Count); + } + + [Fact] + public async Task Unauthorized_ThenSuccessOnRetry_Succeeds() + { + var handler = new SequenceHandler(HttpStatusCode.Unauthorized, HttpStatusCode.OK); + var http = new HttpClient(handler) { BaseAddress = new Uri("https://inbox.example.com/api/") }; + var client = new OnlineInboxApiClient(http, new StaticTokenAuthProvider("test-token")); + + await client.PutListsAsync([]); + + Assert.Equal(2, handler.Requests.Count); + } + + private sealed class SequenceHandler : HttpMessageHandler + { + private readonly Queue _statuses; + public List Requests { get; } = new(); + + public SequenceHandler(params HttpStatusCode[] statuses) => _statuses = new(statuses); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Requests.Add(request); + var status = _statuses.Count > 0 ? _statuses.Dequeue() : HttpStatusCode.OK; + return Task.FromResult(new HttpResponseMessage(status) + { + Content = new StringContent("[]", Encoding.UTF8, "application/json"), + }); + } + } + [Fact] public async Task ServerError_Throws_OnlineInboxException_WithStatusCode() {