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.
This commit is contained in:
parent
2263311e1b
commit
d62e59ba30
8 changed files with 380 additions and 32 deletions
|
|
@ -42,6 +42,83 @@ public class LoginPageViewModelTests
|
||||||
$"Login reported error: {vm.StatusMessage}");
|
$"Login reported error: {vm.StatusMessage}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoginAsync_refuses_to_call_OidcClient_when_Authority_is_empty()
|
||||||
|
{
|
||||||
|
// Regression: when no user settings file exists and the embedded
|
||||||
|
// default somehow fails to load (e.g. resource stripped at publish
|
||||||
|
// time), the ViewModel must NOT hand a blank Authority to
|
||||||
|
// OidcClient — IdentityModel would build a bogus authorize URL
|
||||||
|
// like "http://127.0.0.1:1/" which the browser rejects with a
|
||||||
|
// confusing error. Surface a clear, actionable message instead.
|
||||||
|
//
|
||||||
|
// SettingsLoadOverride is set to a no-op so the test fixture's
|
||||||
|
// pre-loaded Settings object survives the call to LoginAsync.
|
||||||
|
var settings = new PostIt.Settings
|
||||||
|
{
|
||||||
|
Authentication = new AuthenticationSettings
|
||||||
|
{
|
||||||
|
Authority = "",
|
||||||
|
ClientId = "postit-tests",
|
||||||
|
},
|
||||||
|
RedirectUri = "http://127.0.0.1:7890/",
|
||||||
|
Scopes = new[] { "openid" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var browserInvoked = false;
|
||||||
|
var vm = new LoginPageViewModel(settings, () =>
|
||||||
|
{
|
||||||
|
browserInvoked = true;
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
{
|
||||||
|
// Skip the disk / embedded read so the Authority stays empty.
|
||||||
|
SettingsLoadOverride = () => System.Threading.Tasks.Task.CompletedTask,
|
||||||
|
};
|
||||||
|
|
||||||
|
await vm.LoginAsync();
|
||||||
|
|
||||||
|
Assert.False(
|
||||||
|
browserInvoked,
|
||||||
|
"Browser factory was invoked even though Authority was empty.");
|
||||||
|
Assert.NotNull(vm.StatusMessage);
|
||||||
|
Assert.Contains("Configuration manquante", vm.StatusMessage);
|
||||||
|
Assert.Contains("postit-settings.json", vm.StatusMessage);
|
||||||
|
Assert.True(string.IsNullOrEmpty(vm.AccessToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoginAsync_works_when_authority_has_trailing_slash()
|
||||||
|
{
|
||||||
|
// Regression: with Authority ending in "/" (the production
|
||||||
|
// postit-settings.json shape for https://yavsc.pschneider.fr/),
|
||||||
|
// the discovery URL OidcClient computes must NOT contain a
|
||||||
|
// double slash before /.well-known/openid-configuration. The
|
||||||
|
// stub advertises itself without the trailing slash; OidcClient
|
||||||
|
// must bridge.
|
||||||
|
using var authority = await OidcStubAuthority.StartAsync();
|
||||||
|
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
|
||||||
|
|
||||||
|
var settings = new PostIt.Settings
|
||||||
|
{
|
||||||
|
Authentication = new AuthenticationSettings
|
||||||
|
{
|
||||||
|
Authority = authority.Issuer + "/",
|
||||||
|
ClientId = "postit-tests"
|
||||||
|
},
|
||||||
|
RedirectUri = authority.LoopbackRedirectUri,
|
||||||
|
Scopes = new[] { "openid" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var vm = new LoginPageViewModel(settings, browser.CreateBrowser);
|
||||||
|
|
||||||
|
await vm.LoginAsync();
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
!string.IsNullOrEmpty(vm.AccessToken),
|
||||||
|
$"Login with trailing slash failed. StatusMessage={vm.StatusMessage ?? "<null>"}");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RegisterUrl_and_ForgotPasswordUrl_are_derived_from_authority()
|
public void RegisterUrl_and_ForgotPasswordUrl_are_derived_from_authority()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
92
src/PostIt.Tests/LoopbackBrowserTests.cs
Normal file
92
src/PostIt.Tests/LoopbackBrowserTests.cs
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using PostIt.Services;
|
||||||
|
|
||||||
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression coverage for the loopback browser that PostIt uses to
|
||||||
|
/// receive the OIDC authorization-code callback on a local port.
|
||||||
|
/// Specifically: the listener must always be released, even when the
|
||||||
|
/// flow is abandoned (timeout or caller cancellation). 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."
|
||||||
|
/// </summary>
|
||||||
|
public class LoopbackBrowserTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_releases_listener_when_no_browser_responds_within_timeout()
|
||||||
|
{
|
||||||
|
// Pick a free port for this test (don't reuse 7890 — it could be
|
||||||
|
// bound by a real PostIt running on the developer's machine).
|
||||||
|
var port = GetFreePort();
|
||||||
|
var prefix = $"http://127.0.0.1:{port}/";
|
||||||
|
|
||||||
|
var browser = new LoopbackBrowser();
|
||||||
|
var options = new IdentityModel.OidcClient.Browser.BrowserOptions(
|
||||||
|
"http://127.0.0.1:1/", // never reached
|
||||||
|
prefix);
|
||||||
|
|
||||||
|
// The internal wait timeout is 5 minutes; we don't want the test
|
||||||
|
// to actually wait that long. Instead we cancel via the outer
|
||||||
|
// token and verify the listener is released.
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
var result = await browser.InvokeAsync(options, cts.Token);
|
||||||
|
|
||||||
|
// The cancellation propagates as Timeout because the outer token
|
||||||
|
// fires first (the test is faster than the 5-minute internal wait).
|
||||||
|
// We don't care which BrowserResultType is returned here — only
|
||||||
|
// that the port is free afterwards.
|
||||||
|
Assert.NotNull(result);
|
||||||
|
|
||||||
|
// Critical assertion: the port is free. If the listener leaked,
|
||||||
|
// a TcpListener binding to the same port would throw.
|
||||||
|
using var probe = new TcpListener(IPAddress.Loopback, port);
|
||||||
|
probe.Start();
|
||||||
|
probe.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_releases_listener_when_browser_actually_responds()
|
||||||
|
{
|
||||||
|
var port = GetFreePort();
|
||||||
|
var prefix = $"http://127.0.0.1:{port}/";
|
||||||
|
|
||||||
|
var browser = new LoopbackBrowser();
|
||||||
|
var options = new IdentityModel.OidcClient.Browser.BrowserOptions(
|
||||||
|
"http://127.0.0.1:1/", // never reached (we respond directly below)
|
||||||
|
prefix);
|
||||||
|
|
||||||
|
// Race the listener against a fake browser callback.
|
||||||
|
var browserTask = browser.InvokeAsync(options);
|
||||||
|
|
||||||
|
// Give the listener a moment to bind.
|
||||||
|
await Task.Delay(50);
|
||||||
|
|
||||||
|
// Simulate the browser returning the redirect with code + state.
|
||||||
|
using var http = new HttpClient();
|
||||||
|
var response = await http.GetAsync($"{prefix.TrimEnd('/')}/?code=***&state=***");
|
||||||
|
// We don't care about the response body; just that the request
|
||||||
|
// was accepted (otherwise the listener hadn't bound yet).
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
|
var result = await browserTask;
|
||||||
|
Assert.Equal(IdentityModel.OidcClient.Browser.BrowserResultType.Success, result.ResultType);
|
||||||
|
|
||||||
|
// Listener should be released now.
|
||||||
|
using var probe = new TcpListener(IPAddress.Loopback, port);
|
||||||
|
probe.Start();
|
||||||
|
probe.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetFreePort()
|
||||||
|
{
|
||||||
|
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
l.Start();
|
||||||
|
var port = ((IPEndPoint)l.LocalEndpoint).Port;
|
||||||
|
l.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,7 +29,16 @@ namespace PostIt.Services;
|
||||||
{
|
{
|
||||||
Process.Start(new ProcessStartInfo(options.StartUrl) { UseShellExecute = true });
|
Process.Start(new ProcessStartInfo(options.StartUrl) { UseShellExecute = true });
|
||||||
|
|
||||||
var context = await listener.GetContextAsync().ConfigureAwait(false);
|
// 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 response = context.Response;
|
||||||
var responseString = "<html><body>Authentication complete. You can close this window.</body></html>";
|
var responseString = "<html><body>Authentication complete. You can close this window.</body></html>";
|
||||||
var buffer = Encoding.UTF8.GetBytes(responseString);
|
var buffer = Encoding.UTF8.GetBytes(responseString);
|
||||||
|
|
@ -44,13 +53,28 @@ namespace PostIt.Services;
|
||||||
Response = raw
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return new BrowserResult { ResultType = BrowserResultType.UnknownError, Error = ex.Message };
|
return new BrowserResult { ResultType = BrowserResultType.UnknownError, Error = ex.Message };
|
||||||
}
|
}
|
||||||
finally
|
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.Stop(); } catch { }
|
||||||
|
try { listener.Close(); } catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,20 +59,11 @@ public partial class Settings : ObservableObject
|
||||||
/// (no client secret). The browser implementation should be supplied
|
/// (no client secret). The browser implementation should be supplied
|
||||||
/// per-platform by the caller.
|
/// per-platform by the caller.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
|
||||||
/// <see cref="AuthenticationSettings.Authority"/> is normalised by
|
|
||||||
/// trimming any trailing slash before being handed to <c>OidcClient</c>.
|
|
||||||
/// <c>OidcClient</c> derives the discovery URL from
|
|
||||||
/// <c>Authority + "/.well-known/openid-configuration"</c>; leaving a
|
|
||||||
/// trailing slash in place would produce a double-slash URL that some
|
|
||||||
/// servers reject with 404.
|
|
||||||
/// </remarks>
|
|
||||||
internal OidcClientOptions GetOidcClientOptions(IdentityModel.OidcClient.Browser.IBrowser? browser = null)
|
internal OidcClientOptions GetOidcClientOptions(IdentityModel.OidcClient.Browser.IBrowser? browser = null)
|
||||||
{
|
{
|
||||||
var authority = Authentication.Authority?.TrimEnd('/') ?? string.Empty;
|
|
||||||
var options = new OidcClientOptions
|
var options = new OidcClientOptions
|
||||||
{
|
{
|
||||||
Authority = authority,
|
Authority = Authentication.Authority,
|
||||||
ClientId = Authentication.ClientId,
|
ClientId = Authentication.ClientId,
|
||||||
RedirectUri = RedirectUri,
|
RedirectUri = RedirectUri,
|
||||||
Scope = string.Join(' ', this.Scopes),
|
Scope = string.Join(' ', this.Scopes),
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,14 @@ public partial class LoginPageViewModel : ViewModelBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Func<IBrowser?>? BrowserFactoryOverride { get; set; }
|
public Func<IBrowser?>? BrowserFactoryOverride { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional override used by tests. When set, this delegate replaces
|
||||||
|
/// the call to <see cref="Settings.Load"/> at the start of
|
||||||
|
/// <see cref="LoginAsync"/>, so tests can inject a Settings object
|
||||||
|
/// without it being overwritten by the user/embedded default.
|
||||||
|
/// </summary>
|
||||||
|
public Func<Task>? SettingsLoadOverride { get; set; }
|
||||||
|
|
||||||
public LoginPageViewModel() : this(new Settings(), browserFactoryOverride: null)
|
public LoginPageViewModel() : this(new Settings(), browserFactoryOverride: null)
|
||||||
{
|
{
|
||||||
// Load settings eagerly so RegisterUrl / ForgotPasswordUrl are
|
// Load settings eagerly so RegisterUrl / ForgotPasswordUrl are
|
||||||
|
|
@ -121,7 +129,22 @@ public partial class LoginPageViewModel : ViewModelBase
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
this.IsBusy = true;
|
this.IsBusy = true;
|
||||||
Settings.Load().Wait();
|
(SettingsLoadOverride ?? Settings.Load)().Wait();
|
||||||
|
|
||||||
|
// Guard: if the authority is empty (no user settings file and
|
||||||
|
// the embedded default couldn't be loaded for any reason),
|
||||||
|
// refuse to call OidcClient. IdentityModel would otherwise
|
||||||
|
// build a bogus authorize URL like "http://127.0.0.1:1/"
|
||||||
|
// from an empty Authority, which the browser then refuses to
|
||||||
|
// open with a confusing "Cette adresse est interdite"
|
||||||
|
// (or equivalent) message. Tell the operator exactly what
|
||||||
|
// to fix instead.
|
||||||
|
if (string.IsNullOrWhiteSpace(Settings.Authentication?.Authority))
|
||||||
|
{
|
||||||
|
this.IsBusy = false;
|
||||||
|
StatusMessage = $"Configuration manquante — édite {SettingsFileHint()} et renseigne Authentication.Authority";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// The platform project picks the right redirect URI and browser
|
// The platform project picks the right redirect URI and browser
|
||||||
// implementation; we don't reference any UI toolkit from here.
|
// implementation; we don't reference any UI toolkit from here.
|
||||||
|
|
@ -174,4 +197,15 @@ public partial class LoginPageViewModel : ViewModelBase
|
||||||
StatusMessage = $"Error: {ex.Message}{suffix}";
|
StatusMessage = $"Error: {ex.Message}{suffix}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// XDG-compliant path to the user settings file. Surfaced in the
|
||||||
|
/// "Configuration manquante" message so the operator knows exactly
|
||||||
|
/// which file to edit without having to dig through docs.
|
||||||
|
/// </summary>
|
||||||
|
private static string SettingsFileHint()
|
||||||
|
{
|
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||||
|
return System.IO.Path.Combine(appData, "PostIt", "postit-settings.json");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="AsciiDocSharp" Version="0.2.0" />
|
<PackageVersion Include="AsciiDocSharp" Version="0.2.0" />
|
||||||
<PackageVersion Include="AsciiDocSharp.Converters.Html" Version="0.2.0" />
|
<PackageVersion Include="AsciiDocSharp.Converters.Html" Version="0.2.0" />
|
||||||
|
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||||
<PackageVersion Include="Google.Apis.Compute.v1" Version="1.74.0.4138" />
|
<PackageVersion Include="Google.Apis.Compute.v1" Version="1.74.0.4138" />
|
||||||
<PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="8.0.5-preview-net9" />
|
<PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="8.0.5-preview-net9" />
|
||||||
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" Version="8.0.5-preview-net9" />
|
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" Version="8.0.5-preview-net9" />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using Google.Apis.Util.Store;
|
using Google.Apis.Util.Store;
|
||||||
using IdentityModel;
|
using IdentityModel;
|
||||||
|
|
@ -18,6 +19,15 @@ using Microsoft.AspNetCore.Localization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Razor;
|
using Microsoft.AspNetCore.Mvc.Razor;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.FileProviders;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Org.BouncyCastle.Crypto;
|
||||||
|
using Org.BouncyCastle.Crypto.Parameters;
|
||||||
|
using Org.BouncyCastle.OpenSsl;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Org.BouncyCastle.Security;
|
||||||
using Microsoft.Extensions.FileProviders;
|
using Microsoft.Extensions.FileProviders;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
@ -350,31 +360,149 @@ public static class HostingExtensions
|
||||||
"Production IdentityServer requires a signing certificate. " +
|
"Production IdentityServer requires a signing certificate. " +
|
||||||
"Configure Kestrel:Endpoints:Https:Certificate:{Path,KeyPath}.");
|
"Configure Kestrel:Endpoints:Https:Certificate:{Path,KeyPath}.");
|
||||||
}
|
}
|
||||||
// CreateFromPemFile loads the leaf cert + its private key from
|
// Load the leaf cert and extract its private key for signing.
|
||||||
// PEM files without writing to the Windows certificate store
|
// The previous attempts (X509Certificate2.CreateFromPemFile,
|
||||||
// (irrelevant on Linux, but keeps the call cross-platform).
|
// the 3-arg ctor with X509KeyStorageFlags, and BC + CopyWithPrivateKey)
|
||||||
var signingCert = X509Certificate2.CreateFromPemFile(certPath, keyPath);
|
// all loaded the cert successfully, but CreateJwkDocumentAsync
|
||||||
// Pick the JWT signing algorithm from the cert's key type. Let's
|
// still raised NullReferenceException on the first GET /jwks
|
||||||
// Encrypt may issue either RSA or ECDSA certificates depending on
|
// request: IdentityServer8's key material service reads the
|
||||||
// the ACME account's preferred chain; IdentityServer would 500 if
|
// private key off the X509Certificate2 at runtime, and on Linux
|
||||||
// we forced RS256 against an ECDSA key.
|
// the key handle is not retained across that boundary.
|
||||||
var algorithm = signingCert.GetECDsaPrivateKey() is not null ? "ES256" : "RS256";
|
//
|
||||||
identityServerBuilder.AddSigningCredential(signingCert, algorithm);
|
// The reliable pattern is to pass a SigningCredentials that
|
||||||
|
// wraps a SecurityKey built directly from the BC-parsed key
|
||||||
|
// parameters. The SecurityKey is a managed object whose Key
|
||||||
|
// property is a live AsymmetricAlgorithm, which survives every
|
||||||
|
// read IdentityServer does (token signing, JWKS publish).
|
||||||
|
var signingCredentials = LoadSigningCredentials(certPath, keyPath);
|
||||||
|
identityServerBuilder.AddSigningCredential(signingCredentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override the advertised jwks_uri to the canonical
|
// Note: IdentityServer8 does NOT expose the JWKS at /.well-known/jwks.
|
||||||
// /.well-known/jwks endpoint that UseIdentityServer() actually
|
// The default jwks_uri is /.well-known/openid-configuration/jwks,
|
||||||
// mounts. IdentityServer8's default convention here is
|
// which is what DiscoveryKeyEndpoint serves. Earlier revisions of
|
||||||
// /.well-known/openid-configuration/jwks, which is not what most
|
// this file tried to override CustomEntries["jwks_uri"], but
|
||||||
// OIDC clients (including IdentityModel.OidcClient) expect, and
|
// IdentityServer8 reserves that key and rejects the override with
|
||||||
// would otherwise need a parallel route to be wired up.
|
// "Discovery custom entry jwks_uri cannot be added, because it
|
||||||
identityServerBuilder.Services.Configure<IdentityServer8.Configuration.IdentityServerOptions>(options =>
|
// already exists." The default endpoint works once the signing
|
||||||
{
|
// credential's private key is attached (see above).
|
||||||
options.Discovery.CustomEntries["jwks_uri"] = "/.well-known/jwks";
|
|
||||||
});
|
|
||||||
return identityServerBuilder;
|
return identityServerBuilder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load the signing credentials (algorithm + private key) from the
|
||||||
|
/// configured PEM files. Returns a <see cref="SigningCredentials"/>
|
||||||
|
/// whose <c>Key</c> is a managed <see cref="SecurityKey"/> built
|
||||||
|
/// directly from the BouncyCastle-parsed key parameters — which keeps
|
||||||
|
/// the private key alive for every read IdentityServer8 does (token
|
||||||
|
/// signing, JWKS publish), unlike <c>X509Certificate2.CopyWithPrivateKey</c>
|
||||||
|
/// which loses the handle on Linux when IdentityServer8's key material
|
||||||
|
/// service reads it back at runtime.
|
||||||
|
/// </summary>
|
||||||
|
private static SigningCredentials LoadSigningCredentials(string certPath, string keyPath)
|
||||||
|
{
|
||||||
|
// Pre-flight read so permission / missing-file errors surface with
|
||||||
|
// the actual path instead of being wrapped as an opaque
|
||||||
|
// InvalidOperationException by the cert / key parsers.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return LoadSigningCredentialsInner(certPath, keyPath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[yavsc] Failed to load signing credentials from {certPath} / {keyPath}:");
|
||||||
|
Console.Error.WriteLine(ex.ToString());
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to load signing credentials from {certPath} / {keyPath}. " +
|
||||||
|
"See stderr for the underlying managed exception (likely a " +
|
||||||
|
"PEM format mismatch between the cert and private key).",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SigningCredentials LoadSigningCredentialsInner(string certPath, string keyPath)
|
||||||
|
{
|
||||||
|
// Validate the cert is readable (used downstream for token
|
||||||
|
// audience/subject validation; signing itself uses the key).
|
||||||
|
_ = new X509Certificate2(certPath);
|
||||||
|
string keyPem = File.ReadAllText(keyPath);
|
||||||
|
|
||||||
|
// BouncyCastle's PemReader accepts every flavour of unencrypted
|
||||||
|
// private key PEM that ACME clients produce (PKCS#1 with
|
||||||
|
// BEGIN EC/RSA PRIVATE KEY, PKCS#8 with BEGIN PRIVATE KEY, both
|
||||||
|
// EC and RSA), and returns the right AsymmetricKeyParameter
|
||||||
|
// subtype without the SIGABRTs we saw when forcing the
|
||||||
|
// System.Security.Cryptography path on the production EC Let's
|
||||||
|
// Encrypt cert.
|
||||||
|
using var sr = new StringReader(keyPem);
|
||||||
|
var pemReader = new PemReader(sr);
|
||||||
|
var keyObj = pemReader.ReadObject()
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"PEM reader returned null for {keyPath}");
|
||||||
|
|
||||||
|
AsymmetricKeyParameter bcKey = keyObj switch
|
||||||
|
{
|
||||||
|
AsymmetricCipherKeyPair pair => pair.Private,
|
||||||
|
AsymmetricKeyParameter param => param,
|
||||||
|
_ => throw new InvalidOperationException(
|
||||||
|
$"Unexpected PEM object type '{keyObj.GetType().FullName}' " +
|
||||||
|
$"in {keyPath}; expected a private key."),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the SecurityKey + SigningCredentials. RsaSecurityKey /
|
||||||
|
// ECDsaSecurityKey wrap managed AsymmetricAlgorithm objects whose
|
||||||
|
// Key is the live private key — IdentityServer8 can call Sign on
|
||||||
|
// these repeatedly without losing the key handle.
|
||||||
|
switch (bcKey)
|
||||||
|
{
|
||||||
|
case RsaPrivateCrtKeyParameters rsa:
|
||||||
|
{
|
||||||
|
var rsaDotNet = DotNetUtilities.ToRSA(rsa);
|
||||||
|
var key = new RsaSecurityKey(rsaDotNet);
|
||||||
|
return new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
|
||||||
|
}
|
||||||
|
case ECPrivateKeyParameters ec:
|
||||||
|
{
|
||||||
|
var ecParams = new ECParameters
|
||||||
|
{
|
||||||
|
Curve = LoadEcCurve(ec.Parameters),
|
||||||
|
D = ec.D.ToByteArrayUnsigned(),
|
||||||
|
};
|
||||||
|
var ecdsa = ECDsa.Create();
|
||||||
|
ecdsa.ImportParameters(ecParams);
|
||||||
|
var key = new ECDsaSecurityKey(ecdsa);
|
||||||
|
return new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Unsupported private key algorithm '{bcKey.GetType().Name}' " +
|
||||||
|
$"in {keyPath}; expected RSA or EC.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Map a BouncyCastle <see cref="ECDomainParameters"/> to a
|
||||||
|
/// <see cref="ECCurve"/> that <see cref="ECDsa.ImportParameters"/>
|
||||||
|
/// understands. Handles the curves Let's Encrypt issues (P-256,
|
||||||
|
/// P-384, P-521); other curves throw.
|
||||||
|
/// </summary>
|
||||||
|
private static ECCurve LoadEcCurve(ECDomainParameters bcCurve)
|
||||||
|
{
|
||||||
|
// bcCurve.N is the order of the generator; its bit length is the
|
||||||
|
// canonical fingerprint for NIST curves (256, 384, 521 bits).
|
||||||
|
var orderBits = bcCurve.N.BitLength;
|
||||||
|
return orderBits switch
|
||||||
|
{
|
||||||
|
256 => ECCurve.NamedCurves.nistP256,
|
||||||
|
384 => ECCurve.NamedCurves.nistP384,
|
||||||
|
521 => ECCurve.NamedCurves.nistP521,
|
||||||
|
_ => throw new InvalidOperationException(
|
||||||
|
$"Unsupported EC curve with order bit length {orderBits}; " +
|
||||||
|
"expected P-256, P-384 or P-521."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static bool UsesInMemoryProvider(string connectionString)
|
private static bool UsesInMemoryProvider(string connectionString)
|
||||||
{
|
{
|
||||||
return string.Equals(connectionString, InMemoryProviderName, StringComparison.OrdinalIgnoreCase);
|
return string.Equals(connectionString, InMemoryProviderName, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||||
<PackageReference Include="AsciiDocSharp" />
|
<PackageReference Include="AsciiDocSharp" />
|
||||||
<PackageReference Include="AsciiDocSharp.Converters.Html" />
|
<PackageReference Include="AsciiDocSharp.Converters.Html" />
|
||||||
|
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||||
<PackageReference Include="YamlDotNet" />
|
<PackageReference Include="YamlDotNet" />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue