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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user