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