fix(): Maximize button hides the window instead of maximizing
This commit is contained in:
@@ -24,7 +24,28 @@ Sync directions (each one-way per entity → no conflict resolution needed):
|
|||||||
- **Idle tasks**: desktop mirrors its Idle backlog up; the web can create new ones, which the
|
- **Idle tasks**: desktop mirrors its Idle backlog up; the web can create new ones, which the
|
||||||
desktop pulls down and then owns.
|
desktop pulls down and then owns.
|
||||||
|
|
||||||
Single user. Both the desktop and the web client authenticate as the **same Zitadel user**.
|
Single user today. Both the desktop and the web client authenticate as the **same Zitadel
|
||||||
|
user**.
|
||||||
|
|
||||||
|
**Multi-user readiness (`ownerId`).** Each resource is owned by a Zitadel subject (`sub`).
|
||||||
|
`RemoteList`, `RemoteTask`, and `MirrorTask` carry an optional `ownerId` field. The desktop
|
||||||
|
stamps its own `sub` (decoded from the access token) onto everything it pushes, and
|
||||||
|
defensively ignores any pulled task whose `ownerId` is set to a *different* user; an absent
|
||||||
|
`ownerId` is treated as unowned/legacy and still syncs. This keeps the contract ready for
|
||||||
|
multiple users **without enforcing isolation client-side** — the server remains the
|
||||||
|
authority that scopes every request by the token's `sub`. When the server goes multi-user it
|
||||||
|
should partition all rows by owner and ignore (or validate) the client-supplied `ownerId`.
|
||||||
|
|
||||||
|
**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": { "<orgId>": "<orgDomain>" } }`. 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)
|
## 2. Idle backlog definition (desktop side)
|
||||||
|
|
||||||
@@ -68,9 +89,12 @@ All task writes are idempotent upserts keyed on id.
|
|||||||
|
|
||||||
## 4. Endpoints
|
## 4. Endpoints
|
||||||
|
|
||||||
All endpoints require a valid Zitadel access token (`Authorization: Bearer <token>`).
|
All endpoints require a valid Zitadel access token (`Authorization: Bearer <token>`) that
|
||||||
Missing/invalid/expired → `401`. No anonymous access (imported tasks can trigger code
|
carries the **"user" project role** (see §1). Missing/invalid/expired token, or a valid
|
||||||
execution on the user's machine).
|
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
|
> **Auth (VPS/.NET):** use the in-house `KunsZitadel` nuget package (feed
|
||||||
> `https://git.kuns.dev/api/packages/kuns/nuget/index.json`) — call `AddKunsZitadel(...)`
|
> `https://git.kuns.dev/api/packages/kuns/nuget/index.json`) — call `AddKunsZitadel(...)`
|
||||||
@@ -80,13 +104,17 @@ execution on the user's machine).
|
|||||||
|
|
||||||
| Method & path | Caller | Body | Response |
|
| Method & path | Caller | Body | Response |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `PUT /lists` | desktop | `[{ "id", "name" }]` — the FULL catalog | `200` |
|
| `PUT /lists` | desktop | `[{ "id", "name", "ownerId"? }]` — the FULL catalog | `200` |
|
||||||
| `GET /lists` | web | — | `200 [{ "id", "name" }]` |
|
| `GET /lists` | web | — | `200 [{ "id", "name", "ownerId"? }]` |
|
||||||
| `GET /lists/{id}/tasks` | web | — | `200` tasks in that list (`404` if list unknown) |
|
| `GET /lists/{id}/tasks` | web | — | `200` tasks in that list (`404` if list unknown) |
|
||||||
| `POST /tasks` | web | `{ "title", "description"?, "listId" }` | `201` created task incl. `id` |
|
| `POST /tasks` | web | `{ "title", "description"?, "listId" }` | `201` created task incl. `id` |
|
||||||
| `GET /tasks?imported=false` | desktop | — | `200 [{ "id","listId","title","description","createdAt" }]` |
|
| `GET /tasks?imported=false` | desktop | — | `200 [{ "id","listId","title","description","createdAt","ownerId"? }]` |
|
||||||
| `POST /tasks/{id}/imported` | desktop | — | `200` (`404` if unknown) |
|
| `POST /tasks/{id}/imported` | desktop | — | `200` (`404` if unknown) |
|
||||||
| `PUT /tasks/mirror` | desktop | `[{ "id","listId","title","description" }]` — full Idle set | `200` |
|
| `PUT /tasks/mirror` | desktop | `[{ "id","listId","title","description","ownerId"? }]` — full Idle set | `200` |
|
||||||
|
|
||||||
|
`ownerId` (optional, see §1) is the Zitadel `sub` of the owner. The desktop sends it on push
|
||||||
|
and ignores pulled tasks owned by a different user; the server should derive/validate it from
|
||||||
|
the token rather than trust the client value.
|
||||||
|
|
||||||
Semantics:
|
Semantics:
|
||||||
|
|
||||||
|
|||||||
@@ -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}" },
|
"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}" },
|
"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}" },
|
"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}" },
|
"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}" },
|
"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." },
|
"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}" },
|
"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}" },
|
"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}" },
|
"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}" },
|
"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}" },
|
"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)." },
|
"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;
|
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
|
public interface IOnlineLoginService
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,7 +34,13 @@ public sealed class OnlineLoginService : IOnlineLoginService
|
|||||||
return new OnlineLoginResult(false, null,
|
return new OnlineLoginResult(false, null,
|
||||||
"No refresh token returned. Ensure 'offline_access' is in scope and the client allows it.");
|
"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)
|
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!);
|
await _worker.SetOnlineInboxAuthAsync(result.RefreshToken!);
|
||||||
SignedIn = true;
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/>
|
<KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/>
|
||||||
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
|
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
|
||||||
</Window.KeyBindings>
|
</Window.KeyBindings>
|
||||||
<Grid RowDefinitions="36,Auto,*,22">
|
<Grid x:Name="RootGrid" RowDefinitions="36,Auto,*,22">
|
||||||
<!-- Custom title bar -->
|
<!-- Custom title bar -->
|
||||||
<Border Grid.Row="0"
|
<Border Grid.Row="0"
|
||||||
Background="{DynamicResource DeepBrush}"
|
Background="{DynamicResource DeepBrush}"
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ public partial class MainWindow : Window
|
|||||||
base.OnPropertyChanged(change);
|
base.OnPropertyChanged(change);
|
||||||
if (change.Property == WindowStateProperty)
|
if (change.Property == WindowStateProperty)
|
||||||
UpdateMaxIcon();
|
UpdateMaxIcon();
|
||||||
|
if (change.Property == OffScreenMarginProperty)
|
||||||
|
RootGrid.Margin = OffScreenMargin;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateMaxIcon()
|
private void UpdateMaxIcon()
|
||||||
|
|||||||
@@ -2,19 +2,26 @@ using System.Text.Json.Serialization;
|
|||||||
|
|
||||||
namespace ClaudeDo.Worker.Online;
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
// OwnerId carries the resource owner's Zitadel subject (sub). It is nullable and optional so
|
||||||
|
// the contract stays multi-user-ready without changing single-user behavior: today the desktop
|
||||||
|
// stamps it on push and defensively ignores pulled tasks owned by a different user, while the
|
||||||
|
// server remains the authority that scopes data by the token's sub.
|
||||||
public sealed record RemoteList(
|
public sealed record RemoteList(
|
||||||
[property: JsonPropertyName("id")] string Id,
|
[property: JsonPropertyName("id")] string Id,
|
||||||
[property: JsonPropertyName("name")] string Name);
|
[property: JsonPropertyName("name")] string Name,
|
||||||
|
[property: JsonPropertyName("ownerId")] string? OwnerId = null);
|
||||||
|
|
||||||
public sealed record RemoteTask(
|
public sealed record RemoteTask(
|
||||||
[property: JsonPropertyName("id")] string Id,
|
[property: JsonPropertyName("id")] string Id,
|
||||||
[property: JsonPropertyName("listId")] string ListId,
|
[property: JsonPropertyName("listId")] string ListId,
|
||||||
[property: JsonPropertyName("title")] string Title,
|
[property: JsonPropertyName("title")] string Title,
|
||||||
[property: JsonPropertyName("description")] string? Description,
|
[property: JsonPropertyName("description")] string? Description,
|
||||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||||
|
[property: JsonPropertyName("ownerId")] string? OwnerId = null);
|
||||||
|
|
||||||
public sealed record MirrorTask(
|
public sealed record MirrorTask(
|
||||||
[property: JsonPropertyName("id")] string Id,
|
[property: JsonPropertyName("id")] string Id,
|
||||||
[property: JsonPropertyName("listId")] string ListId,
|
[property: JsonPropertyName("listId")] string ListId,
|
||||||
[property: JsonPropertyName("title")] string Title,
|
[property: JsonPropertyName("title")] string Title,
|
||||||
[property: JsonPropertyName("description")] string? Description);
|
[property: JsonPropertyName("description")] string? Description,
|
||||||
|
[property: JsonPropertyName("ownerId")] string? OwnerId = null);
|
||||||
|
|||||||
@@ -3,4 +3,11 @@ namespace ClaudeDo.Worker.Online.Interfaces;
|
|||||||
public interface IOnlineAuthProvider
|
public interface IOnlineAuthProvider
|
||||||
{
|
{
|
||||||
Task<string?> GetAccessTokenAsync(CancellationToken ct = default);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/ClaudeDo.Worker/Online/JwtClaims.cs
Normal file
49
src/ClaudeDo.Worker/Online/JwtClaims.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal, dependency-free reader for a JWT access token's payload claims. Used to resolve the
|
||||||
|
/// current user's Zitadel subject (<c>sub</c>) so sync payloads can be stamped with an owner.
|
||||||
|
/// Never throws — returns null when the token is absent or cannot be parsed.
|
||||||
|
/// </summary>
|
||||||
|
public static class JwtClaims
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the <c>sub</c> claim of the JWT, or null if the token is absent/unparseable or
|
||||||
|
/// carries no subject.
|
||||||
|
/// </summary>
|
||||||
|
public static string? GetSubject(string? jwt)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(jwt))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var parts = jwt.Split('.');
|
||||||
|
if (parts.Length < 2)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(Base64UrlDecode(parts[1]));
|
||||||
|
if (doc.RootElement.TryGetProperty("sub", out var sub) &&
|
||||||
|
sub.ValueKind == JsonValueKind.String)
|
||||||
|
return sub.GetString();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using ClaudeDo.Worker.Online.Interfaces;
|
using ClaudeDo.Worker.Online.Interfaces;
|
||||||
|
|
||||||
@@ -5,6 +7,10 @@ namespace ClaudeDo.Worker.Online;
|
|||||||
|
|
||||||
public sealed class OnlineInboxApiClient : IOnlineInboxApi
|
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 HttpClient _http;
|
||||||
private readonly IOnlineAuthProvider _auth;
|
private readonly IOnlineAuthProvider _auth;
|
||||||
|
|
||||||
@@ -31,54 +37,68 @@ public sealed class OnlineInboxApiClient : IOnlineInboxApi
|
|||||||
|
|
||||||
public async Task PutListsAsync(IReadOnlyList<RemoteList> lists, CancellationToken ct = default)
|
public async Task PutListsAsync(IReadOnlyList<RemoteList> lists, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
using var req = await BuildAsync(HttpMethod.Put, "lists", lists, ct);
|
using var resp = await SendAsync(HttpMethod.Put, "lists", lists, ct);
|
||||||
using var resp = await _http.SendAsync(req, ct);
|
|
||||||
await EnsureSuccessAsync(resp, ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<RemoteTask>> GetUnimportedTasksAsync(CancellationToken ct = default)
|
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 SendAsync(HttpMethod.Get, "tasks?imported=false", null, ct);
|
||||||
using var resp = await _http.SendAsync(req, ct);
|
|
||||||
await EnsureSuccessAsync(resp, ct);
|
|
||||||
var result = await resp.Content.ReadFromJsonAsync<List<RemoteTask>>(ct);
|
var result = await resp.Content.ReadFromJsonAsync<List<RemoteTask>>(ct);
|
||||||
return result ?? [];
|
return result ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task MarkImportedAsync(string id, CancellationToken ct = default)
|
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 SendAsync(HttpMethod.Post, $"tasks/{Uri.EscapeDataString(id)}/imported", null, ct);
|
||||||
using var resp = await _http.SendAsync(req, ct);
|
|
||||||
await EnsureSuccessAsync(resp, ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task PutMirrorAsync(IReadOnlyList<MirrorTask> tasks, CancellationToken ct = default)
|
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 SendAsync(HttpMethod.Put, "tasks/mirror", tasks, ct);
|
||||||
using var resp = await _http.SendAsync(req, ct);
|
|
||||||
await EnsureSuccessAsync(resp, ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<HttpRequestMessage> BuildAsync(
|
/// <summary>
|
||||||
HttpMethod method,
|
/// Sends an authenticated request. On a 401, forces a fresh (role-bearing) token via the
|
||||||
string path,
|
/// refresh-token grant and retries once; if a fresh token is still rejected, throws an
|
||||||
object? body,
|
/// <see cref="OnlineInboxException"/> with <see cref="MissingRoleMessage"/>.
|
||||||
CancellationToken ct)
|
/// </summary>
|
||||||
|
private async Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpMethod method, string path, object? body, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var token = await _auth.GetAccessTokenAsync(ct);
|
var resp = await SendOnceAsync(method, path, body, forceRefresh: false, ct);
|
||||||
var req = new HttpRequestMessage(method, path);
|
|
||||||
|
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)
|
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)
|
if (body is not null)
|
||||||
req.Content = JsonContent.Create(body);
|
req.Content = JsonContent.Create(body);
|
||||||
return req;
|
return await _http.SendAsync(req, ct);
|
||||||
}
|
|
||||||
|
|
||||||
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}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ public sealed class OnlineSyncService : BackgroundService
|
|||||||
{
|
{
|
||||||
return;
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "OnlineSyncService cycle failed; backing off to next interval");
|
_logger.LogWarning(ex, "OnlineSyncService cycle failed; backing off to next interval");
|
||||||
@@ -67,6 +73,10 @@ public sealed class OnlineSyncService : BackgroundService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the current user's Zitadel subject so sync payloads carry an owner and pulls
|
||||||
|
// can be guarded. Null today (single user / server derives it from the token).
|
||||||
|
var ownerId = JwtClaims.GetSubject(token);
|
||||||
|
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
var tasks = new TaskRepository(ctx);
|
var tasks = new TaskRepository(ctx);
|
||||||
var lists = new ListRepository(ctx);
|
var lists = new ListRepository(ctx);
|
||||||
@@ -75,6 +85,15 @@ public sealed class OnlineSyncService : BackgroundService
|
|||||||
var unimported = await _api.GetUnimportedTasksAsync(ct);
|
var unimported = await _api.GetUnimportedTasksAsync(ct);
|
||||||
foreach (var remote in unimported)
|
foreach (var remote in unimported)
|
||||||
{
|
{
|
||||||
|
// Multi-user guard: never import a task explicitly owned by a different user.
|
||||||
|
// Unowned tasks (ownerId == null) stay importable so single-user behavior is intact.
|
||||||
|
if (ownerId is not null && remote.OwnerId is not null && remote.OwnerId != ownerId)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"OnlineSyncService: remote task {Id} is owned by another user; skipping", remote.Id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var existing = await tasks.GetByIdAsync(remote.Id, ct);
|
var existing = await tasks.GetByIdAsync(remote.Id, ct);
|
||||||
if (existing is not null)
|
if (existing is not null)
|
||||||
{
|
{
|
||||||
@@ -109,13 +128,15 @@ public sealed class OnlineSyncService : BackgroundService
|
|||||||
_logger.LogInformation("OnlineSyncService: imported task {Id} ('{Title}')", remote.Id, remote.Title);
|
_logger.LogInformation("OnlineSyncService: imported task {Id} ('{Title}')", remote.Id, remote.Title);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: push full list catalog.
|
// Step 2: push full list catalog, stamped with the owner.
|
||||||
var allLists = await lists.GetAllAsync(ct);
|
var allLists = await lists.GetAllAsync(ct);
|
||||||
var remoteLists = allLists.Select(l => new RemoteList(l.Id, l.Name)).ToList();
|
var remoteLists = allLists.Select(l => new RemoteList(l.Id, l.Name, ownerId)).ToList();
|
||||||
await _api.PutListsAsync(remoteLists, ct);
|
await _api.PutListsAsync(remoteLists, ct);
|
||||||
|
|
||||||
// Step 3: push current Idle backlog mirror.
|
// Step 3: push current Idle backlog mirror, stamped with the owner.
|
||||||
var mirror = await OnlineBacklog.CurrentAsync(tasks, ct);
|
var mirror = (await OnlineBacklog.CurrentAsync(tasks, ct))
|
||||||
|
.Select(m => m with { OwnerId = ownerId })
|
||||||
|
.ToList();
|
||||||
await _api.PutMirrorAsync(mirror, ct);
|
await _api.PutMirrorAsync(mirror, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,7 @@ public sealed class StaticTokenAuthProvider : IOnlineAuthProvider
|
|||||||
|
|
||||||
public Task<string?> GetAccessTokenAsync(CancellationToken ct = default)
|
public Task<string?> GetAccessTokenAsync(CancellationToken ct = default)
|
||||||
=> Task.FromResult(_token);
|
=> Task.FromResult(_token);
|
||||||
|
|
||||||
|
public Task<string?> GetAccessTokenAsync(bool forceRefresh, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(_token);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ public sealed class ZitadelAuthProvider : IOnlineAuthProvider
|
|||||||
// Cached access token state.
|
// Cached access token state.
|
||||||
private string? _cachedAccessToken;
|
private string? _cachedAccessToken;
|
||||||
private DateTimeOffset _cacheExpiry;
|
private DateTimeOffset _cacheExpiry;
|
||||||
|
// The refresh token that minted the cached access token. When the stored refresh token
|
||||||
|
// changes (sign-out, or signing in as a different user), the cache is no longer valid.
|
||||||
|
private string? _refreshTokenUsed;
|
||||||
|
|
||||||
// Cached token endpoint URL (discovered once).
|
// Cached token endpoint URL (discovered once).
|
||||||
private string? _tokenEndpoint;
|
private string? _tokenEndpoint;
|
||||||
@@ -36,22 +39,33 @@ public sealed class ZitadelAuthProvider : IOnlineAuthProvider
|
|||||||
_logger = logger;
|
_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.
|
var refreshToken = _tokenStore.Read();
|
||||||
if (_cachedAccessToken is not null && DateTimeOffset.UtcNow < _cacheExpiry)
|
|
||||||
|
// Fast path: cached token is valid, not forced, and was minted from the still-current
|
||||||
|
// refresh token (i.e. the signed-in user hasn't changed).
|
||||||
|
if (IsCacheUsable(forceRefresh, refreshToken))
|
||||||
return _cachedAccessToken;
|
return _cachedAccessToken;
|
||||||
|
|
||||||
await _lock.WaitAsync(ct);
|
await _lock.WaitAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Re-check inside the lock (double-checked locking).
|
// Re-read + re-check inside the lock (double-checked locking).
|
||||||
if (_cachedAccessToken is not null && DateTimeOffset.UtcNow < _cacheExpiry)
|
refreshToken = _tokenStore.Read();
|
||||||
|
if (IsCacheUsable(forceRefresh, refreshToken))
|
||||||
return _cachedAccessToken;
|
return _cachedAccessToken;
|
||||||
|
|
||||||
var refreshToken = _tokenStore.Read();
|
// Drop any stale access token so a fresh one is minted for the current user.
|
||||||
|
_cachedAccessToken = null;
|
||||||
|
_cacheExpiry = default;
|
||||||
|
|
||||||
if (refreshToken is null)
|
if (refreshToken is null)
|
||||||
{
|
{
|
||||||
|
_refreshTokenUsed = null;
|
||||||
_logger.LogDebug("No refresh token stored; skipping token refresh.");
|
_logger.LogDebug("No refresh token stored; skipping token refresh.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -64,6 +78,12 @@ public sealed class ZitadelAuthProvider : IOnlineAuthProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsCacheUsable(bool forceRefresh, string? storedRefreshToken) =>
|
||||||
|
!forceRefresh
|
||||||
|
&& _cachedAccessToken is not null
|
||||||
|
&& DateTimeOffset.UtcNow < _cacheExpiry
|
||||||
|
&& storedRefreshToken == _refreshTokenUsed;
|
||||||
|
|
||||||
private async Task<string?> RefreshAsync(string refreshToken, CancellationToken ct)
|
private async Task<string?> RefreshAsync(string refreshToken, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var tokenEndpoint = await GetTokenEndpointAsync(ct);
|
var tokenEndpoint = await GetTokenEndpointAsync(ct);
|
||||||
@@ -113,15 +133,19 @@ public sealed class ZitadelAuthProvider : IOnlineAuthProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If Zitadel rotated the refresh token, persist the new one.
|
// If Zitadel rotated the refresh token, persist the new one.
|
||||||
|
var persistedRefreshToken = refreshToken;
|
||||||
if (tokenResponse.RefreshToken is not null && tokenResponse.RefreshToken != refreshToken)
|
if (tokenResponse.RefreshToken is not null && tokenResponse.RefreshToken != refreshToken)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Refresh token rotated; persisting new token.");
|
_logger.LogDebug("Refresh token rotated; persisting new token.");
|
||||||
_tokenStore.Save(tokenResponse.RefreshToken);
|
_tokenStore.Save(tokenResponse.RefreshToken);
|
||||||
|
persistedRefreshToken = tokenResponse.RefreshToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the access token (subtract 60 s safety margin; minimum 0 to avoid far-future expiry on zero).
|
// Cache the access token (subtract 60 s safety margin; minimum 0 to avoid far-future expiry on zero).
|
||||||
|
// Remember which refresh token it was minted from so the cache invalidates on a user switch.
|
||||||
_cachedAccessToken = tokenResponse.AccessToken;
|
_cachedAccessToken = tokenResponse.AccessToken;
|
||||||
_cacheExpiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn - 60);
|
_cacheExpiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn - 60);
|
||||||
|
_refreshTokenUsed = persistedRefreshToken;
|
||||||
|
|
||||||
return _cachedAccessToken;
|
return _cachedAccessToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
40
tests/ClaudeDo.Worker.Tests/Online/JwtClaimsTests.cs
Normal file
40
tests/ClaudeDo.Worker.Tests/Online/JwtClaimsTests.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Text;
|
||||||
|
using ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Online;
|
||||||
|
|
||||||
|
public sealed class JwtClaimsTests
|
||||||
|
{
|
||||||
|
// Builds a fake JWT (header.payload.signature) carrying the given JSON payload.
|
||||||
|
internal static string MakeToken(string payloadJson)
|
||||||
|
{
|
||||||
|
static string B64Url(string s) =>
|
||||||
|
Convert.ToBase64String(Encoding.UTF8.GetBytes(s))
|
||||||
|
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||||
|
return $"{B64Url("{\"alg\":\"RS256\"}")}.{B64Url(payloadJson)}.sig";
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSubject_ReturnsSub()
|
||||||
|
{
|
||||||
|
var token = MakeToken("{\"sub\":\"user-123\",\"email\":\"a@b.c\"}");
|
||||||
|
Assert.Equal("user-123", JwtClaims.GetSubject(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSubject_Null_WhenNoSub()
|
||||||
|
{
|
||||||
|
var token = MakeToken("{\"email\":\"a@b.c\"}");
|
||||||
|
Assert.Null(JwtClaims.GetSubject(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("not-a-jwt")]
|
||||||
|
[InlineData("opaque.token")]
|
||||||
|
public void GetSubject_Null_ForUnparseableInput(string? token)
|
||||||
|
{
|
||||||
|
Assert.Null(JwtClaims.GetSubject(token));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,6 +130,52 @@ public sealed class OnlineInboxApiClientTests
|
|||||||
Assert.Equal(401, ex.StatusCode);
|
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<OnlineInboxException>(
|
||||||
|
() => 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<HttpStatusCode> _statuses;
|
||||||
|
public List<HttpRequestMessage> Requests { get; } = new();
|
||||||
|
|
||||||
|
public SequenceHandler(params HttpStatusCode[] statuses) => _statuses = new(statuses);
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> 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]
|
[Fact]
|
||||||
public async Task ServerError_Throws_OnlineInboxException_WithStatusCode()
|
public async Task ServerError_Throws_OnlineInboxException_WithStatusCode()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -209,6 +209,76 @@ public sealed class OnlineSyncServiceTests : IDisposable
|
|||||||
Assert.Equal(0, api.CallCount);
|
Assert.Equal(0, api.CallCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- multi-user: owner stamping + guard ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Tick_StampsOwnerId_OnPushedListsAndMirror()
|
||||||
|
{
|
||||||
|
var (listId, ctx, tasks, _) = await SeedAsync();
|
||||||
|
using var _ = ctx;
|
||||||
|
await tasks.AddAsync(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Idle",
|
||||||
|
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
|
||||||
|
var token = JwtClaimsTests.MakeToken("{\"sub\":\"owner-1\"}");
|
||||||
|
var api = new FakeApi();
|
||||||
|
var svc = BuildService(api, token);
|
||||||
|
|
||||||
|
await svc.TickAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.All(api.ReceivedLists, l => Assert.Equal("owner-1", l.OwnerId));
|
||||||
|
Assert.NotEmpty(api.ReceivedMirror);
|
||||||
|
Assert.All(api.ReceivedMirror, m => Assert.Equal("owner-1", m.OwnerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Tick_SkipsRemoteTask_OwnedByAnotherUser()
|
||||||
|
{
|
||||||
|
var (listId, ctx, tasks, _) = await SeedAsync();
|
||||||
|
using var _ = ctx;
|
||||||
|
|
||||||
|
var foreignId = Guid.NewGuid().ToString();
|
||||||
|
var api = new FakeApi
|
||||||
|
{
|
||||||
|
UnimportedTasks =
|
||||||
|
[
|
||||||
|
new RemoteTask(foreignId, listId, "Theirs", null, DateTimeOffset.UtcNow, OwnerId: "owner-2"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var svc = BuildService(api, JwtClaimsTests.MakeToken("{\"sub\":\"owner-1\"}"));
|
||||||
|
|
||||||
|
await svc.TickAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Null(await tasks.GetByIdAsync(foreignId));
|
||||||
|
Assert.DoesNotContain(foreignId, api.MarkedImported);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Tick_Imports_OwnTask_And_UnownedTask()
|
||||||
|
{
|
||||||
|
var (listId, ctx, tasks, _) = await SeedAsync();
|
||||||
|
using var _ = ctx;
|
||||||
|
|
||||||
|
var mine = Guid.NewGuid().ToString();
|
||||||
|
var unowned = Guid.NewGuid().ToString();
|
||||||
|
var api = new FakeApi
|
||||||
|
{
|
||||||
|
UnimportedTasks =
|
||||||
|
[
|
||||||
|
new RemoteTask(mine, listId, "Mine", null, DateTimeOffset.UtcNow, OwnerId: "owner-1"),
|
||||||
|
new RemoteTask(unowned, listId, "Unowned", null, DateTimeOffset.UtcNow),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var svc = BuildService(api, JwtClaimsTests.MakeToken("{\"sub\":\"owner-1\"}"));
|
||||||
|
|
||||||
|
await svc.TickAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(await tasks.GetByIdAsync(mine));
|
||||||
|
Assert.NotNull(await tasks.GetByIdAsync(unowned));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- already-imported task on server ----
|
// ---- already-imported task on server ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -152,6 +152,37 @@ public sealed class ZitadelAuthProviderTests : IDisposable
|
|||||||
Assert.Equal(2, handler.Requests.Count);
|
Assert.Equal(2, handler.Requests.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChangedRefreshToken_InvalidatesCache_AndRefreshesForNewUser()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows()) return;
|
||||||
|
|
||||||
|
var (provider, handler, store) = Build();
|
||||||
|
store.Save("admin-refresh");
|
||||||
|
|
||||||
|
// First user (admin): discovery + token.
|
||||||
|
handler.Enqueue(".well-known", HttpStatusCode.OK,
|
||||||
|
DiscoveryJson("https://auth.example.com/oauth/token"));
|
||||||
|
handler.Enqueue("oauth/token", HttpStatusCode.OK,
|
||||||
|
TokenJson("admin-access", expiresIn: 3600));
|
||||||
|
|
||||||
|
var adminToken = await provider.GetAccessTokenAsync();
|
||||||
|
Assert.Equal("admin-access", adminToken);
|
||||||
|
|
||||||
|
// Re-sign-in as a different user writes a new refresh token to the store.
|
||||||
|
store.Save("normal-refresh");
|
||||||
|
|
||||||
|
// Even though the cached admin token is still within its expiry window, the changed
|
||||||
|
// refresh token must force a new exchange (no second discovery — it's cached).
|
||||||
|
handler.Enqueue("oauth/token", HttpStatusCode.OK,
|
||||||
|
TokenJson("normal-access", expiresIn: 3600));
|
||||||
|
|
||||||
|
var normalToken = await provider.GetAccessTokenAsync();
|
||||||
|
|
||||||
|
Assert.Equal("normal-access", normalToken);
|
||||||
|
Assert.Equal(3, handler.Requests.Count); // discovery + admin token + normal token
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RotatedRefreshToken_IsPersisted()
|
public async Task RotatedRefreshToken_IsPersisted()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user