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

View File

@@ -130,6 +130,52 @@ public sealed class OnlineInboxApiClientTests
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]
public async Task ServerError_Throws_OnlineInboxException_WithStatusCode()
{