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