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

The Online API now requires the "user" project role (claim
urn:zitadel:iam:org:project:roles) instead of an ALLOWED_USER_IDS allowlist.

- IOnlineAuthProvider: add GetAccessTokenAsync(forceRefresh) overload
- ZitadelAuthProvider: forceRefresh drops the cached token and re-runs the
  refresh-token grant to mint a fresh, role-bearing token
- OnlineInboxApiClient: on 401, force-refresh and retry once; if still 401,
  throw a clear "missing 'user' role" error
- OnlineSyncService: surface the 401 at Error level (no longer silent)
- UI: ZitadelTokenInspector decodes the access token after login and warns
  early when the "user" role is absent (fail-open); shown in settings
- docs: online-inbox-api-contract reflects role-based access (no allowlist)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-06-10 13:46:17 +02:00
parent 80a2de6c74
commit 23c3065f20
14 changed files with 280 additions and 40 deletions

View File

@@ -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);
}
}