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>
144 lines
4.9 KiB
C#
144 lines
4.9 KiB
C#
using System.Diagnostics;
|
|
using System.Net;
|
|
using System.Text;
|
|
using Duende.IdentityModel.OidcClient;
|
|
using Duende.IdentityModel.OidcClient.Browser;
|
|
|
|
namespace ClaudeDo.Ui.Services;
|
|
|
|
public sealed class OnlineLoginService : IOnlineLoginService
|
|
{
|
|
public async Task<OnlineLoginResult> LoginAsync(
|
|
string authority, string clientId, string scope, string redirectUri,
|
|
CancellationToken ct = default)
|
|
{
|
|
try
|
|
{
|
|
var browser = new LoopbackBrowser(redirectUri);
|
|
var options = new OidcClientOptions
|
|
{
|
|
Authority = authority,
|
|
ClientId = clientId,
|
|
Scope = scope,
|
|
RedirectUri = redirectUri,
|
|
Browser = browser,
|
|
};
|
|
|
|
var client = new OidcClient(options);
|
|
var result = await client.LoginAsync(new LoginRequest(), ct);
|
|
|
|
if (result.IsError)
|
|
return new OnlineLoginResult(false, null, result.Error);
|
|
|
|
if (string.IsNullOrEmpty(result.RefreshToken))
|
|
return new OnlineLoginResult(false, null,
|
|
"No refresh token returned. Ensure 'offline_access' is in scope and the client allows it.");
|
|
|
|
// Early heads-up: if the access token lacks the "user" project role the server will
|
|
// reject sync with a 401. Login still succeeds; surface this as a warning, not an error.
|
|
var warning = ZitadelTokenInspector.HasUserRole(result.AccessToken)
|
|
? null
|
|
: "missing-user-role";
|
|
|
|
return new OnlineLoginResult(true, result.RefreshToken, null, warning);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return new OnlineLoginResult(false, null, "Login cancelled.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new OnlineLoginResult(false, null, ex.Message);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// IBrowser implementation: opens the system browser and captures the authorization
|
|
/// response via a loopback HttpListener on the redirect URI's host/port.
|
|
/// </summary>
|
|
sealed class LoopbackBrowser : IBrowser
|
|
{
|
|
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(3);
|
|
private readonly string _redirectUri;
|
|
|
|
public LoopbackBrowser(string redirectUri) => _redirectUri = redirectUri;
|
|
|
|
public async Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken ct = default)
|
|
{
|
|
// Derive the listener prefix from the redirect URI
|
|
var uri = new Uri(_redirectUri);
|
|
var prefix = $"{uri.Scheme}://{uri.Host}:{uri.Port}/";
|
|
|
|
using var listener = new HttpListener();
|
|
listener.Prefixes.Add(prefix);
|
|
|
|
try
|
|
{
|
|
listener.Start();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new BrowserResult
|
|
{
|
|
ResultType = BrowserResultType.UnknownError,
|
|
Error = $"Could not start loopback listener on {prefix}: {ex.Message}"
|
|
};
|
|
}
|
|
|
|
try
|
|
{
|
|
Process.Start(new ProcessStartInfo(options.StartUrl) { UseShellExecute = true });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new BrowserResult
|
|
{
|
|
ResultType = BrowserResultType.UnknownError,
|
|
Error = $"Could not open browser: {ex.Message}"
|
|
};
|
|
}
|
|
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
cts.CancelAfter(Timeout);
|
|
|
|
try
|
|
{
|
|
var context = await listener.GetContextAsync().WaitAsync(cts.Token);
|
|
|
|
var responseBody = Encoding.UTF8.GetBytes(
|
|
"<html><body style=\"font-family:sans-serif;background:#0D1311;color:#E4EBE4;padding:40px\">" +
|
|
"<h2>Login successful</h2><p>You may close this tab.</p></body></html>");
|
|
|
|
context.Response.ContentLength64 = responseBody.Length;
|
|
context.Response.ContentType = "text/html; charset=utf-8";
|
|
await context.Response.OutputStream.WriteAsync(responseBody, cts.Token);
|
|
context.Response.OutputStream.Close();
|
|
|
|
// rawUrl already includes the redirect path (e.g. "/callback?code=..."),
|
|
// so build the full URL from the scheme://host:port base — NOT the full
|
|
// redirect URI, or the path would be doubled (".../callback/callback").
|
|
var rawUrl = context.Request.RawUrl ?? "";
|
|
var fullUri = prefix.TrimEnd('/') + rawUrl;
|
|
|
|
return new BrowserResult
|
|
{
|
|
ResultType = BrowserResultType.Success,
|
|
Response = fullUri
|
|
};
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return new BrowserResult
|
|
{
|
|
ResultType = BrowserResultType.Timeout,
|
|
Error = "Login timed out waiting for browser callback."
|
|
};
|
|
}
|
|
finally
|
|
{
|
|
listener.Stop();
|
|
}
|
|
}
|
|
}
|