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