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 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); } } } /// /// IBrowser implementation: opens the system browser and captures the authorization /// response via a loopback HttpListener on the redirect URI's host/port. /// 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 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( "" + "

Login successful

You may close this tab.

"); 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(); } } }