yavsc/src/PostIt/PostIt/Services/LoopbackBrowser.cs
Paul Schneider d62e59ba30 postit: trailing-slash regression + loopback browser cleanup; identityserver: BC cert loader + SecurityKey
PostIt
- LoginPage renders StatusMessage as a read-only TextBox so the
  text is mouse-selectable and copyable (no copy button).
- LoginPageViewModel exposes ExternalUrl (Authentication.Authority
  with trailing slash stripped) and DiscoveryUrl
  (ExternalUrl + '/.well-known/openid-configuration'). LoginAsync
  surfaces the discovery URL before the call and suffixes it onto
  every error message, so reachability issues are diagnosable by
  pasting the URL into a browser.
- LoopbackBrowser (used for the OIDC redirect listener on desktop)
  now bounds the GetContextAsync wait at 5 minutes and calls both
  Stop() and Close() in the finally, so the listener is always
  released even if the user abandons the flow. Without this, the
  next PostIt launch fails with 'Failed to listen on prefix
  http://127.0.0.1:7890/ because it conflicts with an existing
  registration on the machine.'
- Add LoginPageViewModelTests.LoginAsync_works_when_authority_has_trailing_slash
  to lock in the discovery URL contract.
- Add LoopbackBrowserTests covering both timeout and happy-path
  listener cleanup.

Yavsc.Org
- Drop the CustomEntries['jwks_uri'] override from commit 84160f07.
  IdentityServer8 reserves that key and rejects the override with
  'Discovery custom entry jwks_uri cannot be added, because it
  already exists.' The default /.well-known/openid-configuration/jwks
  endpoint is what DiscoveryKeyEndpoint actually serves.
- Replace X509Certificate2.CreateFromPemFile + the 3-arg
  X509Certificate2(path, key, X509KeyStorageFlags) ctor with a
  BouncyCastle-backed loader. The BCL path raised
  InvalidOperationException during AddSigningCredential and aborted
  the runtime with SIGABRT (code=6/ABRT, libcoreclr.so stack) on
  the production EC Let's Encrypt cert. BouncyCastle 2.6.2
  PemReader accepts PKCS#1 + PKCS#8 EC/RSA PEMs uniformly; RSA
  path uses DotNetUtilities.ToRSA, EC path rebuilds ECDsa from
  ECParameters with the curve dispatched by NIST order bit length
  (256/384/521).
- Switch the signing credential handed to IdentityServer8 from
  X509Certificate2 to a SigningCredentials built from a SecurityKey
  (RsaSecurityKey / ECDsaSecurityKey). The cert loaded fine but
  IdentityServer8's key material service reads cert.PrivateKey at
  runtime — on Linux that handle is not retained across the
  X509Certificate2 / runtime boundary, so CreateJwkDocumentAsync
  raised NullReferenceException on the first GET /jwks. The
  SecurityKey is a pure managed object whose Key is a live
  AsymmetricAlgorithm, which survives every read IdentityServer
  does.
- Add BouncyCastle.Cryptography 2.6.2 to src/Yavsc.Org/Yavsc.Org.csproj
  and the matching PackageVersion in Directory.Packages props.
- Wrap the loader in a try/catch that prints the full managed
  stack to stderr on failure, so future PEM-format issues surface
  in journalctl instead of being hidden behind SIGABRT.
2026-06-21 07:36:22 +01:00

80 lines
3.5 KiB
C#

using System;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using IdentityModel.OidcClient.Browser;
namespace PostIt.Services;
public class LoopbackBrowser : IBrowser
{
public async Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default)
{
if (!Uri.TryCreate(options.EndUrl, UriKind.Absolute, out var endUri))
{
return new BrowserResult { ResultType = BrowserResultType.UnknownError, Error = "Invalid end URL" };
}
var prefix = endUri.GetLeftPart(UriPartial.Path);
if (!prefix.EndsWith("/")) prefix += "/";
using var listener = new HttpListener();
listener.Prefixes.Add(prefix);
listener.Start();
try
{
Process.Start(new ProcessStartInfo(options.StartUrl) { UseShellExecute = true });
// Bound the wait so the port is released even if the user
// closes the browser without completing the flow. Without
// this, a crashed/abandoned login keeps the HttpListener
// bound and the next PostIt launch fails with
// "Failed to listen on prefix … because it conflicts with
// an existing registration".
using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
waitCts.CancelAfter(TimeSpan.FromMinutes(5));
var context = await listener.GetContextAsync().WaitAsync(waitCts.Token).ConfigureAwait(false);
var response = context.Response;
var responseString = "<html><body>Authentication complete. You can close this window.</body></html>";
var buffer = Encoding.UTF8.GetBytes(responseString);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
response.OutputStream.Close();
var raw = context.Request.Url!.ToString();
return new BrowserResult
{
ResultType = BrowserResultType.Success,
Response = raw
};
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Bound hit: user did not complete the flow within 5 minutes.
return new BrowserResult
{
ResultType = BrowserResultType.Timeout,
Error = "Timed out waiting for the browser to return the authorization code.",
};
}
catch (Exception ex)
{
return new BrowserResult { ResultType = BrowserResultType.UnknownError, Error = ex.Message };
}
finally
{
// Stop() aborts GetContextAsync (releases the bound port);
// Close() disposes the underlying socket. Both are idempotent
// and safe to call after Stop() already succeeded, so calling
// both covers cases where one path throws before the other
// gets a chance (e.g. process-level socket cleanup on Linux).
try { listener.Stop(); } catch { }
try { listener.Close(); } catch { }
}
}
}