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
|
flow: authorization_code
|
||||||
authorizationUrl: "{{Authority}}/connect/authorize"
|
authorizationUrl: "{{Authority}}/connect/authorize"
|
||||||
accessTokenUrl: "{{Authority}}/connect/token"
|
accessTokenUrl: "{{Authority}}/connect/token"
|
||||||
|
refreshTokenUrl: https://yavsc.pschneider.fr/connect/token
|
||||||
callbackUrl: "{{Authority}}"
|
callbackUrl: "{{Authority}}"
|
||||||
credentials:
|
credentials:
|
||||||
clientId: postit
|
clientId: postit
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,6 @@ public partial class App : Application
|
||||||
services.AddSingleton(api);
|
services.AddSingleton(api);
|
||||||
services.AddSingleton(client);
|
services.AddSingleton(client);
|
||||||
services.AddTransient<MainPageViewModel>();
|
services.AddTransient<MainPageViewModel>();
|
||||||
services.AddTransient<Settings>();
|
|
||||||
services.AddTransient<HomePageViewModel>();
|
services.AddTransient<HomePageViewModel>();
|
||||||
services.AddTransient<SignaturePageViewModel>();
|
services.AddTransient<SignaturePageViewModel>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
public partial class AuthenticationSettings : ObservableObject
|
public partial class AuthenticationSettings : ObservableObject
|
||||||
{
|
{
|
||||||
|
|
@ -40,4 +41,82 @@ public partial class AuthenticationSettings : ObservableObject
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri;
|
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
|
// triggered by the assignments above doesn't leave it
|
||||||
// stuck at true.
|
// stuck at true.
|
||||||
IsDirty = false;
|
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
|
// Re-notify the command in case the button was bound
|
||||||
// before Load finished and the CanExecute cache is
|
// before Load finished and the CanExecute cache is
|
||||||
// stale.
|
// stale.
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@
|
||||||
x:Class="PostIt.Views.SettingsPage"
|
x:Class="PostIt.Views.SettingsPage"
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
x:DataType="vm:Settings"
|
x:DataType="vm:Settings"
|
||||||
Width="400"
|
>
|
||||||
Height="300">
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
|
|
@ -20,6 +19,8 @@
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<TextBlock Grid.Row="0" Text="Authority"/>
|
<TextBlock Grid.Row="0" Text="Authority"/>
|
||||||
|
|
@ -30,25 +31,33 @@
|
||||||
<TextBox Grid.Row="3" x:Name="ClientIdTextBox"
|
<TextBox Grid.Row="3" x:Name="ClientIdTextBox"
|
||||||
Text="{Binding Authentication.ClientId, Mode=TwoWay}"/>
|
Text="{Binding Authentication.ClientId, Mode=TwoWay}"/>
|
||||||
|
|
||||||
<TextBlock Grid.Row="4" Text="Blogs API URL"/>
|
<TextBlock Grid.Row="4" Text="Scopes (space-separated)"/>
|
||||||
<TextBox Grid.Row="5" x:Name="BlogsApiUrlTextBox"
|
<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}"/>
|
Text="{Binding BlogsApiUrl, Mode=TwoWay}"/>
|
||||||
|
|
||||||
<TextBlock Grid.Row="6" Text="Business API URL"/>
|
<TextBlock Grid.Row="8" Text="Business API URL"/>
|
||||||
<TextBox Grid.Row="7" x:Name="BusinessApiUrlTextBox"
|
<TextBox Grid.Row="9" x:Name="BusinessApiUrlTextBox"
|
||||||
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
|
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
|
||||||
|
|
||||||
<TextBlock Grid.Row="8" Text="Dark mode"/>
|
<TextBlock Grid.Row="10" Text="Dark mode"/>
|
||||||
<CheckBox Grid.Row="9" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
|
<CheckBox Grid.Row="11" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
|
||||||
|
|
||||||
<!-- Sauver: bound to the SaveCommand on the Settings VM, with
|
<!-- Sauver: bound to the Save RelayCommand on the Settings
|
||||||
IsEnabled driven by the inverse of IsDirty so the button
|
VM. The source generator emits an ICommand property whose
|
||||||
auto-disables when there's nothing to persist. -->
|
name matches the source method exactly (no "Command"
|
||||||
<Button Grid.Row="10" Content="Sauver"
|
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"
|
HorizontalAlignment="Right"
|
||||||
Margin="0,12,0,0"
|
Command="{Binding Save}"
|
||||||
Command="{Binding SaveCommand}"
|
IsEnabled="{Binding IsDirty}"/>
|
||||||
IsEnabled="{Binding !IsDirty}"/>
|
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</ContentPage>
|
</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 Microsoft.Extensions.Localization;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
[assembly: RootNamespace("Yavsc")]
|
[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
|
// Validate the cert is readable (used downstream for token
|
||||||
// audience/subject validation; signing itself uses the key).
|
// 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);
|
string keyPem = File.ReadAllText(keyPath);
|
||||||
|
|
||||||
// BouncyCastle's PemReader accepts every flavour of unencrypted
|
// 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
|
#pragma warning disable CA1416 // Valider la compatibilité de la plateforme
|
||||||
var rsaDotNet = DotNetUtilities.ToRSA(rsa);
|
var rsaDotNet = DotNetUtilities.ToRSA(rsa);
|
||||||
#pragma warning restore CA1416 // Valider la compatibilité de la plateforme
|
#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);
|
return new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
|
||||||
}
|
}
|
||||||
case ECPrivateKeyParameters ec:
|
case ECPrivateKeyParameters ec:
|
||||||
|
|
@ -525,7 +539,7 @@ public static class HostingExtensions
|
||||||
};
|
};
|
||||||
var ecdsa = ECDsa.Create();
|
var ecdsa = ECDsa.Create();
|
||||||
ecdsa.ImportParameters(ecParams);
|
ecdsa.ImportParameters(ecParams);
|
||||||
var key = new ECDsaSecurityKey(ecdsa);
|
var key = new ECDsaSecurityKey(ecdsa) { KeyId = kid };
|
||||||
return new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256);
|
return new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256);
|
||||||
}
|
}
|
||||||
default:
|
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>
|
/// <summary>
|
||||||
/// Map a BouncyCastle <see cref="ECDomainParameters"/> to a
|
/// Map a BouncyCastle <see cref="ECDomainParameters"/> to a
|
||||||
/// <see cref="ECCurve"/> that <see cref="ECDsa.ImportParameters"/>
|
/// <see cref="ECCurve"/> that <see cref="ECDsa.ImportParameters"/>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue