Compare commits
4 commits
6055117929
...
13d985e4e3
| Author | SHA1 | Date | |
|---|---|---|---|
| 13d985e4e3 | |||
| d3664c5cdc | |||
| 375e6482a6 | |||
| 2c6d11577c |
8 changed files with 328 additions and 18 deletions
|
|
@ -20,6 +20,7 @@ request:
|
|||
flow: authorization_code
|
||||
authorizationUrl: "{{Authority}}/connect/authorize"
|
||||
accessTokenUrl: "{{Authority}}/connect/token"
|
||||
refreshTokenUrl: https://yavsc.pschneider.fr/connect/token
|
||||
callbackUrl: "{{Authority}}"
|
||||
credentials:
|
||||
clientId: postit
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ public partial class App : Application
|
|||
services.AddSingleton(api);
|
||||
services.AddSingleton(client);
|
||||
services.AddTransient<MainPageViewModel>();
|
||||
services.AddTransient<Settings>();
|
||||
services.AddTransient<HomePageViewModel>();
|
||||
services.AddTransient<SignaturePageViewModel>();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
public partial class AuthenticationSettings : ObservableObject
|
||||
{
|
||||
|
|
@ -40,4 +41,82 @@ public partial class AuthenticationSettings : ObservableObject
|
|||
[ObservableProperty]
|
||||
public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri;
|
||||
|
||||
/// <summary>
|
||||
/// Space-separated view of <see cref="Scopes"/>. Exists for the
|
||||
/// <c>SettingsPage</c> TextBox binding — a <c>string[]</c> does not
|
||||
/// round-trip through XAML binding to <c>TextBox.Text</c>, so we
|
||||
/// expose the array as a string here and re-parse on assignment.
|
||||
/// <para>
|
||||
/// <c>[JsonIgnore]</c> on purpose: <see cref="Scopes"/> is the
|
||||
/// persisted shape (matches the on-disk format in
|
||||
/// <c>postit-settings.json</c> and the runtime contract in
|
||||
/// <see cref="PostIt.ViewModels.Settings.GetOidcClientOptions"/>).
|
||||
/// Writing this property back to disk would duplicate the
|
||||
/// information and confuse the deserializer.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
[ObservableProperty]
|
||||
public partial string ScopeListText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Refresh <see cref="ScopeListText"/> from <see cref="Scopes"/> so
|
||||
/// the TextBox shows the current persisted state after a Load().
|
||||
/// Called from <c>Settings.ApplyJson</c> on each disk / embedded
|
||||
/// hydration; the source generator's <c>OnScopesChanged</c> partial
|
||||
/// below keeps the two in sync in the other direction (edits made
|
||||
/// in the TextBox).
|
||||
/// </summary>
|
||||
public void RefreshScopeListText()
|
||||
{
|
||||
ScopeListText = Scopes is null ? string.Empty : string.Join(' ', Scopes);
|
||||
}
|
||||
|
||||
partial void OnScopeListTextChanged(string value)
|
||||
{
|
||||
if (Scopes is null)
|
||||
{
|
||||
Scopes = Array.Empty<string>();
|
||||
}
|
||||
// Split on any whitespace, drop empties. Matches what
|
||||
// string.Join(' ', Scopes) produces when Scopes is null-free,
|
||||
// so a round-trip (Display → Edit → Display) is lossless
|
||||
// for sane inputs.
|
||||
var parts = value?.Split(
|
||||
new[] { ' ', '\t', '\n', '\r' },
|
||||
StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>();
|
||||
|
||||
// Skip the write if the parsed array is equal to the current
|
||||
// one — avoids a PropertyChanged loop between OnScopesChanged
|
||||
// and OnScopeListTextChanged when RefreshScopeListText runs.
|
||||
if (Scopes is not null && Scopes.Length == parts.Length)
|
||||
{
|
||||
var same = true;
|
||||
for (var i = 0; i < parts.Length; i++)
|
||||
{
|
||||
if (!string.Equals(Scopes[i], parts[i], StringComparison.Ordinal))
|
||||
{
|
||||
same = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (same) return;
|
||||
}
|
||||
Scopes = parts;
|
||||
}
|
||||
|
||||
partial void OnScopesChanged(string[] value)
|
||||
{
|
||||
// Keep ScopeListText in sync when Scopes is reassigned from
|
||||
// outside (JSON hydration, MergeScopes, programmatic
|
||||
// updates). Compute the new value and only fire if it
|
||||
// differs from what's already shown, otherwise the TextBox
|
||||
// would briefly flicker / re-set the caret on every load.
|
||||
var newText = value is null ? string.Empty : string.Join(' ', value);
|
||||
if (!string.Equals(ScopeListText, newText, StringComparison.Ordinal))
|
||||
{
|
||||
ScopeListText = newText;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -361,6 +361,15 @@ public partial class Settings : ViewModelBase
|
|||
// triggered by the assignments above doesn't leave it
|
||||
// stuck at true.
|
||||
IsDirty = false;
|
||||
// Refresh the space-separated ScopeListText view after
|
||||
// hydration so the SettingsPage TextBox reflects the
|
||||
// loaded scopes (and not the default empty string the
|
||||
// ObservableProperty was constructed with). OnScopesChanged
|
||||
// already tries to do this, but it skips when the new
|
||||
// array parses to the same text — calling explicitly
|
||||
// forces a re-sync and normalises any whitespace the
|
||||
// JSON might have introduced.
|
||||
this.Authentication?.RefreshScopeListText();
|
||||
// Re-notify the command in case the button was bound
|
||||
// before Load finished and the CanExecute cache is
|
||||
// stale.
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@
|
|||
x:Class="PostIt.Views.SettingsPage"
|
||||
xmlns:vm="using:PostIt.ViewModels"
|
||||
x:DataType="vm:Settings"
|
||||
Width="400"
|
||||
Height="300">
|
||||
>
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
|
|
@ -20,6 +19,8 @@
|
|||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Text="Authority"/>
|
||||
|
|
@ -30,25 +31,33 @@
|
|||
<TextBox Grid.Row="3" x:Name="ClientIdTextBox"
|
||||
Text="{Binding Authentication.ClientId, Mode=TwoWay}"/>
|
||||
|
||||
<TextBlock Grid.Row="4" Text="Blogs API URL"/>
|
||||
<TextBox Grid.Row="5" x:Name="BlogsApiUrlTextBox"
|
||||
<TextBlock Grid.Row="4" Text="Scopes (space-separated)"/>
|
||||
<TextBox Grid.Row="5" x:Name="ScopesTextBox"
|
||||
Text="{Binding Authentication.ScopeListText, Mode=TwoWay}"/>
|
||||
|
||||
<TextBlock Grid.Row="6" Text="Blogs API URL"/>
|
||||
<TextBox Grid.Row="7" x:Name="BlogsApiUrlTextBox"
|
||||
Text="{Binding BlogsApiUrl, Mode=TwoWay}"/>
|
||||
|
||||
<TextBlock Grid.Row="6" Text="Business API URL"/>
|
||||
<TextBox Grid.Row="7" x:Name="BusinessApiUrlTextBox"
|
||||
<TextBlock Grid.Row="8" Text="Business API URL"/>
|
||||
<TextBox Grid.Row="9" x:Name="BusinessApiUrlTextBox"
|
||||
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
|
||||
|
||||
<TextBlock Grid.Row="8" Text="Dark mode"/>
|
||||
<CheckBox Grid.Row="9" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
|
||||
<TextBlock Grid.Row="10" Text="Dark mode"/>
|
||||
<CheckBox Grid.Row="11" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
|
||||
|
||||
<!-- Sauver: bound to the SaveCommand on the Settings VM, with
|
||||
IsEnabled driven by the inverse of IsDirty so the button
|
||||
auto-disables when there's nothing to persist. -->
|
||||
<Button Grid.Row="10" Content="Sauver"
|
||||
<!-- Sauver: bound to the Save RelayCommand on the Settings
|
||||
VM. The source generator emits an ICommand property whose
|
||||
name matches the source method exactly (no "Command"
|
||||
suffix is added), so we bind {Binding Save} here. See
|
||||
AGENTS.md "Avalonia + CommunityToolkit.Mvvm : conventions
|
||||
de binding pour [RelayCommand]" for the full rationale.
|
||||
IsEnabled tracks IsDirty so the button auto-disables
|
||||
when there's nothing to persist. -->
|
||||
<Button Grid.Row="12" Content="Sauver"
|
||||
HorizontalAlignment="Right"
|
||||
Margin="0,12,0,0"
|
||||
Command="{Binding SaveCommand}"
|
||||
IsEnabled="{Binding !IsDirty}"/>
|
||||
Command="{Binding Save}"
|
||||
IsEnabled="{Binding IsDirty}"/>
|
||||
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
|
|
|||
165
src/Yavsc.Org.Tests/ComputeKidTests.cs
Normal file
165
src/Yavsc.Org.Tests/ComputeKidTests.cs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Xunit;
|
||||
using Yavsc.Extensions;
|
||||
|
||||
namespace Yavsc.Org.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="HostingExtensions.ComputeKid"/>, the
|
||||
/// helper that derives the JWT <c>kid</c> header / JWKS key id
|
||||
/// from the signing certificate. The kid is consumed by every
|
||||
/// resource server (Yavsc.Blogs, Yavsc.Api) to match a token to
|
||||
/// the right key in the JWKS, so getting its shape and stability
|
||||
/// right is the whole point of the fix in commit 2c6d1157
|
||||
/// (IDX10500 regression).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// We don't load the production cert (Let's Encrypt PEM + RSA
|
||||
/// private key) — we generate throwaway self-signed certs in a
|
||||
/// temp dir. The contract under test is the truncation /
|
||||
/// encoding of the thumbprint, which is independent of the key
|
||||
/// type and the cert issuer.
|
||||
/// </remarks>
|
||||
public class ComputeKidTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public ComputeKidTests()
|
||||
{
|
||||
_tempDir = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"yavsc-compute-kid-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_tempDir, recursive: true); }
|
||||
catch { /* best effort — Temp gets cleaned eventually */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeKid_returns_first_16_hex_chars_of_cert_thumbprint()
|
||||
{
|
||||
var certPath = WriteSelfSignedCertRsa(out var expectedThumbHex);
|
||||
|
||||
var kid = HostingExtensions.ComputeKid(certPath);
|
||||
|
||||
// 16 hex chars = 64 bits, enough to be globally unique
|
||||
// within a deployment and compact enough for a JWT header.
|
||||
Assert.Equal(16, kid.Length);
|
||||
Assert.True(
|
||||
kid.All(c => "0123456789ABCDEF".Contains(c)),
|
||||
$"kid '{kid}' contains non-uppercase-hex characters");
|
||||
|
||||
// Match the first 16 chars of the thumbprint exactly. We
|
||||
// compute the expected value from the same cert the helper
|
||||
// was given — no magic constants, no copy-paste of the
|
||||
// truncation logic under test.
|
||||
Assert.Equal(expectedThumbHex[..16], kid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeKid_is_stable_across_repeated_reads()
|
||||
{
|
||||
var certPath = WriteSelfSignedCertRsa(out _);
|
||||
|
||||
var first = HostingExtensions.ComputeKid(certPath);
|
||||
var second = HostingExtensions.ComputeKid(certPath);
|
||||
var third = HostingExtensions.ComputeKid(certPath);
|
||||
|
||||
// Stability matters: a non-deterministic kid would
|
||||
// invalidate tokens on every IdentityServer restart.
|
||||
Assert.Equal(first, second);
|
||||
Assert.Equal(second, third);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeKid_differs_between_distinct_certificates()
|
||||
{
|
||||
var certPathA = WriteSelfSignedCertRsa(out _);
|
||||
var certPathB = WriteSelfSignedCertRsa(out _);
|
||||
|
||||
var kidA = HostingExtensions.ComputeKid(certPathA);
|
||||
var kidB = HostingExtensions.ComputeKid(certPathB);
|
||||
|
||||
// Two independent RNG-drawn RSA keys will (in practice
|
||||
// always) yield different thumbprints. A 64-bit truncated
|
||||
// space has collisions at ~2^32 certs; we won't get there.
|
||||
Assert.NotEqual(kidA, kidB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeKid_uses_thumbprint_not_subject_or_serial()
|
||||
{
|
||||
// The previous fix-message claimed SHA-256; the helper
|
||||
// actually reads X509Certificate2.GetCertHash() which is
|
||||
// SHA-1. Pin that behaviour so a future refactor that
|
||||
// switches to SHA-256 (or any other digest) is forced to
|
||||
// update the test deliberately.
|
||||
var certPath = WriteSelfSignedCertRsa(out var thumbHex);
|
||||
|
||||
var kid = HostingExtensions.ComputeKid(certPath);
|
||||
|
||||
// 16 hex chars is half of a 20-byte SHA-1 thumbprint.
|
||||
// SHA-256 would be 32 bytes (64 hex chars) before
|
||||
// truncation; SHA-1 is the only common digest whose
|
||||
// hex encoding fits the 16-char prefix we observe.
|
||||
Assert.Equal(20, thumbHex.Length / 2);
|
||||
Assert.Equal(thumbHex[..16], kid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeKid_propagates_cryptographic_exception_for_missing_file()
|
||||
{
|
||||
// The wrapper LoadSigningCredentials wraps this in an
|
||||
// InvalidOperationException, but ComputeKid itself is a
|
||||
// plain helper — it must surface the parser error so the
|
||||
// wrapper can attach the cert path to the message. We
|
||||
// assert against the base CryptographicException rather
|
||||
// than the concrete subtype because the runtime picks
|
||||
// different leaf types per platform (on Linux/OpenSSL we
|
||||
// get Interop+Crypto+OpenSslCryptographicException, on
|
||||
// Windows we'd get the older CryptographicException
|
||||
// directly); the contract is the same either way.
|
||||
var missing = Path.Combine(_tempDir, "does-not-exist.pem");
|
||||
|
||||
Assert.ThrowsAny<CryptographicException>(
|
||||
() => HostingExtensions.ComputeKid(missing));
|
||||
}
|
||||
|
||||
// --- helpers ----------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Generate a throwaway self-signed RSA-2048 cert, export it
|
||||
/// as PEM to a file inside the test temp dir, and return the
|
||||
/// path. The out parameter receives the upper-case hex form
|
||||
/// of the cert's SHA-1 thumbprint so tests can pin the
|
||||
/// expected kid without re-implementing the helper.
|
||||
/// </summary>
|
||||
private string WriteSelfSignedCertRsa(out string thumbHex)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest(
|
||||
"CN=yavsc-test",
|
||||
rsa,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
using var cert = req.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
DateTimeOffset.UtcNow.AddYears(1));
|
||||
|
||||
// Capture the thumbprint before exporting — the cert is
|
||||
// disposed by `using` and the exported PEM is what the
|
||||
// helper will read.
|
||||
thumbHex = Convert.ToHexString(cert.GetCertHash());
|
||||
|
||||
var path = Path.Combine(_tempDir, "cert-" + Guid.NewGuid().ToString("N") + ".pem");
|
||||
File.WriteAllText(path, cert.ExportCertificatePem());
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,9 @@
|
|||
using Microsoft.Extensions.Localization;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: RootNamespace("Yavsc")]
|
||||
|
||||
// Expose internals to the Yavsc.Org.Tests project so unit tests can
|
||||
// reach the signing-credential loader (LoadSigningCredentials / kid
|
||||
// derivation) without going through the full IdentityServer boot.
|
||||
[assembly: InternalsVisibleTo("Yavsc.Org.Tests")]
|
||||
|
|
|
|||
|
|
@ -478,6 +478,20 @@ public static class HostingExtensions
|
|||
// Validate the cert is readable (used downstream for token
|
||||
// audience/subject validation; signing itself uses the key).
|
||||
|
||||
// Derive a stable KeyId from the certificate's SHA-1
|
||||
// thumbprint (the default for X509Certificate2.GetCertHash()).
|
||||
// Without an explicit KeyId, IdentityServer emits JWTs without
|
||||
// a 'kid' header and the JWKS without per-key identifiers,
|
||||
// which breaks signature validation on resource servers (they
|
||||
// cannot match a token to a key in the JWKS, they fail with
|
||||
// IDX10500 "The signature key was not found"). Truncating the
|
||||
// 40-hex-char SHA-1 to 16 hex chars is enough to be globally
|
||||
// unique within a deployment and keeps the JWT header compact.
|
||||
// The thumbprint changes on cert renewal, which is the desired
|
||||
// behaviour: old tokens age out, resource servers refresh
|
||||
// their JWKS cache for the new kid.
|
||||
var kid = ComputeKid(certPath);
|
||||
|
||||
string keyPem = File.ReadAllText(keyPath);
|
||||
|
||||
// BouncyCastle's PemReader accepts every flavour of unencrypted
|
||||
|
|
@ -513,7 +527,7 @@ public static class HostingExtensions
|
|||
#pragma warning disable CA1416 // Valider la compatibilité de la plateforme
|
||||
var rsaDotNet = DotNetUtilities.ToRSA(rsa);
|
||||
#pragma warning restore CA1416 // Valider la compatibilité de la plateforme
|
||||
var key = new RsaSecurityKey(rsaDotNet);
|
||||
var key = new RsaSecurityKey(rsaDotNet) { KeyId = kid };
|
||||
return new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
|
||||
}
|
||||
case ECPrivateKeyParameters ec:
|
||||
|
|
@ -525,7 +539,7 @@ public static class HostingExtensions
|
|||
};
|
||||
var ecdsa = ECDsa.Create();
|
||||
ecdsa.ImportParameters(ecParams);
|
||||
var key = new ECDsaSecurityKey(ecdsa);
|
||||
var key = new ECDsaSecurityKey(ecdsa) { KeyId = kid };
|
||||
return new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256);
|
||||
}
|
||||
default:
|
||||
|
|
@ -535,6 +549,34 @@ public static class HostingExtensions
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derive the <c>kid</c> used to identify the signing key in the
|
||||
/// JWT header and the JWKS. Takes the first 16 hex characters of
|
||||
/// the certificate's SHA-1 thumbprint. See the inline rationale in
|
||||
/// <see cref="LoadSigningCredentialsInner"/> for why this is
|
||||
/// needed (IdentityServer8 + IDX10500).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Internal so unit tests in <c>Yavsc.Org.Tests</c> can exercise
|
||||
/// the truncation/encoding without going through the full PEM /
|
||||
/// BouncyCastle pipeline. The input is a path rather than a
|
||||
/// pre-loaded <see cref="X509Certificate2"/> to match the
|
||||
/// production call site.
|
||||
/// </remarks>
|
||||
internal static string ComputeKid(string certPath)
|
||||
{
|
||||
// X509CertificateLoader is the .NET 9+ replacement for the
|
||||
// obsolete `new X509Certificate2(string)` ctor (SYSLIB0057).
|
||||
// Same on-disk format (PEM or DER), same thumbprint, just
|
||||
// doesn't trip the obsolete-API warning at build time.
|
||||
var certForKid = X509CertificateLoader.LoadCertificateFromFile(certPath);
|
||||
var certHash = certForKid.GetCertHash();
|
||||
// GetCertHash() returns a SHA-1 thumbprint (20 bytes, 40 hex
|
||||
// chars). Truncating to 16 hex chars keeps the JWT header
|
||||
// compact; Math.Min guards against an unexpected short hash.
|
||||
return Convert.ToHexString(certHash)[..Math.Min(16, certHash.Length * 2)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map a BouncyCastle <see cref="ECDomainParameters"/> to a
|
||||
/// <see cref="ECCurve"/> that <see cref="ECDsa.ImportParameters"/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue