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
co-authored by Claude Opus 4.8
parent 80a2de6c74
commit 23c3065f20
14 changed files with 280 additions and 40 deletions
@@ -36,19 +36,29 @@ public sealed class ZitadelAuthProvider : IOnlineAuthProvider
_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.
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)
{