Compare commits
No commits in common. "13d985e4e3d104a713be815668f329698592f33f" and "60551179290c2725a3164cae708ff534b4e81fdb" have entirely different histories.
13d985e4e3
...
6055117929
8 changed files with 18 additions and 328 deletions
|
|
@ -20,7 +20,6 @@ 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,6 +69,7 @@ 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,6 +1,5 @@
|
||||||
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
|
||||||
{
|
{
|
||||||
|
|
@ -41,82 +40,4 @@ 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,15 +361,6 @@ 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,7 +5,8 @@
|
||||||
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"/>
|
||||||
|
|
@ -19,8 +20,6 @@
|
||||||
<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"/>
|
||||||
|
|
@ -31,33 +30,25 @@
|
||||||
<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="Scopes (space-separated)"/>
|
<TextBlock Grid.Row="4" Text="Blogs API URL"/>
|
||||||
<TextBox Grid.Row="5" x:Name="ScopesTextBox"
|
<TextBox Grid.Row="5" x:Name="BlogsApiUrlTextBox"
|
||||||
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="8" Text="Business API URL"/>
|
<TextBlock Grid.Row="6" Text="Business API URL"/>
|
||||||
<TextBox Grid.Row="9" x:Name="BusinessApiUrlTextBox"
|
<TextBox Grid.Row="7" x:Name="BusinessApiUrlTextBox"
|
||||||
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
|
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
|
||||||
|
|
||||||
<TextBlock Grid.Row="10" Text="Dark mode"/>
|
<TextBlock Grid.Row="8" Text="Dark mode"/>
|
||||||
<CheckBox Grid.Row="11" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
|
<CheckBox Grid.Row="9" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
|
||||||
|
|
||||||
<!-- Sauver: bound to the Save RelayCommand on the Settings
|
<!-- Sauver: bound to the SaveCommand on the Settings VM, with
|
||||||
VM. The source generator emits an ICommand property whose
|
IsEnabled driven by the inverse of IsDirty so the button
|
||||||
name matches the source method exactly (no "Command"
|
auto-disables when there's nothing to persist. -->
|
||||||
suffix is added), so we bind {Binding Save} here. See
|
<Button Grid.Row="10" Content="Sauver"
|
||||||
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"
|
||||||
Command="{Binding Save}"
|
Margin="0,12,0,0"
|
||||||
IsEnabled="{Binding IsDirty}"/>
|
Command="{Binding SaveCommand}"
|
||||||
|
IsEnabled="{Binding !IsDirty}"/>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
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,9 +1,3 @@
|
||||||
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,20 +478,6 @@ 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
|
||||||
|
|
@ -527,7 +513,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) { KeyId = kid };
|
var key = new RsaSecurityKey(rsaDotNet);
|
||||||
return new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
|
return new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
|
||||||
}
|
}
|
||||||
case ECPrivateKeyParameters ec:
|
case ECPrivateKeyParameters ec:
|
||||||
|
|
@ -539,7 +525,7 @@ public static class HostingExtensions
|
||||||
};
|
};
|
||||||
var ecdsa = ECDsa.Create();
|
var ecdsa = ECDsa.Create();
|
||||||
ecdsa.ImportParameters(ecParams);
|
ecdsa.ImportParameters(ecParams);
|
||||||
var key = new ECDsaSecurityKey(ecdsa) { KeyId = kid };
|
var key = new ECDsaSecurityKey(ecdsa);
|
||||||
return new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256);
|
return new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|
@ -549,34 +535,6 @@ 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