Extract the kid calculation out of LoadSigningCredentialsInner
into a new internal static HostingExtensions.ComputeKid(string),
and cover it with five focused unit tests in
Yavsc.Org.Tests.ComputeKidTests.
The kid is the bit of signing-credential metadata that ties a
JWT to the right key in the JWKS. Without it, resource servers
(Yavsc.Blogs, Yavsc.Api) fail signature validation with IDX10500
'The signature key was not found', as fixed in 2c6d1157. That fix
inlined three lines of thumbprint-truncation logic at the top of
LoadSigningCredentialsInner, but left the calculation untested.
The tests in this commit pin its shape, value, stability, and
uniqueness, so a future refactor (e.g. switching from SHA-1 to
SHA-256, or moving to X509CertificateLoader for SYSLIB0057) has
to update them deliberately instead of silently changing the
JWKS key id.
Concretely:
- InternalsVisibleTo("Yavsc.Org.Tests") in AssemblyInfo.cs
gives the test project access to the new internal method
without forcing LoadSigningCredentialsInner to leak
further.
- ComputeKid(string) is the single source of truth for the
16-hex truncation; the production call site in
LoadSigningCredentialsInner now reads
'var kid = ComputeKid(certPath);'.
- The inline comment block is updated to say SHA-1 (which is
what X509Certificate2.GetCertHash() actually returns) instead
of the previous SHA-256 claim. The behaviour is unchanged.
- ComputeKid uses X509CertificateLoader.LoadCertificateFromFile
rather than the obsolete 'new X509Certificate2(string)' ctor
(SYSLIB0057); same on-disk behaviour, no obsolete warning.
Tests cover:
- 16-char upper-case hex output matching the first 16 hex
chars of the cert's GetCertHash();
- stability across repeated reads of the same cert;
- distinctness between two independently generated certs;
- the SHA-1 size of the underlying thumbprint (20 bytes), so
a future switch to SHA-256 forces a test update;
- CryptographicException propagation for a missing cert file
(Assert.ThrowsAny to stay portable across the Linux OpenSSL
and Windows leaf exception types).
IdentityServer8 was emitting JWTs without a 'kid' header and
serving the JWKS without per-key identifiers, because
LoadSigningCredentialsInner constructed RsaSecurityKey /
ECDsaSecurityKey objects without an explicit KeyId. Resource
servers (Yavsc.Blogs, Yavsc.Api) cannot match a token to a key
in the JWKS without one, so every signature validation failed
with 'The signature key was not found' (Microsoft.IdentityModel
IDX10500).
Root cause: SigningCredentials were built directly from the
BC-parsed key parameters, bypassing the X509Certificate2 path
IdentityServer normally derives the kid from. The fix derives
a stable KeyId from the certificate's SHA-256 thumbprint
(truncated to 16 hex chars) and sets it on both SecurityKey
variants before constructing SigningCredentials.
The thumbprint-based kid is stable across process restarts as
long as the cert doesn't change, and changes naturally on
LetsEncrypt renewal (~90 days), which is the right behaviour:
old tokens age out, resource servers refresh their JWKS cache
to discover the new kid.
Production rollout: redeploy Yavsc.Org and re-login (or let
the refresh-token path rotate) so newly issued tokens carry
the kid. Pre-restart tokens will continue to be rejected with
IDX10500 until they expire or are refreshed.
EF Core was throwing at startup with:
System.InvalidOperationException: The LINQ expression
'[ApiResourceScopeSpecification,...].Any(s => s.ResourceName == r.Name)'
could not be translated.
The cause: Constants.ApiResourcesScopes is a static readonly C# array,
not an IQueryable, but it was used directly inside a Where clause on an
IQueryable<ApiResource>. EF tried to translate the closure over
Constants.ApiResourcesScopes into a SQL sub-query, which is not a
supported operation.
Materialise the wanted resource names into a HashSet before letting EF
see the Where — the collection is small (5 entries) so there's no
performance reason to push it down. After this fix,
EnsureDefaultApplicationScopes runs to completion at startup and
the seed actually has a chance of doing its job (assuming the rows
aren't already present).
The previous commit (be334a69) relied on C# defaults to populate
the Postgres NOT NULL columns Enabled, Required, Emphasize,
ShowInDiscoveryDocument (on ApiScopes) and Created (on
ApiResources). Both tables declare these columns NOT NULL without
a database default, so EF Core ends up shipping C# defaults
(false / DateTime.MinValue) that violate the constraints or
silently disable the seeded rows.
Concretely, if we deployed be334a69 as-is:
- ApiScopes.Enabled = false -> the scope is invisible to
DefaultResourceValidator, exactly the bug we're fixing.
- ApiResources.Created = DateTime.MinValue (0001-01-01) ->
Postgres rejects the INSERT with
'null value in column Created violates not-null constraint'.
Set the values explicitly so the seeder produces the same state
whether it runs once or a hundred times, fresh database or not.
The previous commit (37440171) added ApiScope rows for the
application scopes (admin, moderation, performer, client, blogs).
It was a partial fix: an ApiScope alone is not a valid scope from
DefaultResourceValidator's point of view. The validator only
recognises a scope if it can find an ApiResource that exposes it
(via ApiResourceScopes). Without that link, /connect/authorize
rejects the request with 'Scope X not found in store', even
though the scope row exists. This is what killed the PostIt login
in production.
This commit:
1. Extends Constants.ApiResourcesScopes with ResourceName +
ResourceDisplayName. Topology: one ApiResource per scope
('admin' resource exposes 'admin' scope, 'blogs' resource
exposes 'blogs' scope, etc.) — keeps each scope's audience
specific if/when we split products across separate audiences.
2. Ensures EnsureDefaultApplicationScopes also inserts the
matching ApiResource rows (deduped on Name) and ApiResourceScope
rows linking each resource to its scope. Idempotent: missing
rows are added, nothing is removed.
3. Removes the b.UseSeeding(...) call inside AddConfigurationStore.
EF Core's UseSeeding callback only fires when the database is
empty, so on a live ConfigurationDb (which already had Clients
and ClientScopes) it never ran — that is why the previous commit
had no visible effect on production. The seeder is now invoked
explicitly from MigrateDatabase via SeedConfigurationDatabase,
which resolves ConfigurationDbContext from the DI and runs
EnsureDefaultConfiguration on every startup, regardless of
whether the database was fresh.
Seeding failures are caught and logged (best-effort) so a
misconfigured seeder cannot prevent the host from booting.
Live data on yavsc.pschneider.fr is still missing the
ApiResource/ApiResourceScope rows; a one-shot SQL or a redeploy
with this commit is needed before PostIt can log in. Production
fix to follow.
EnsureDefaultApplicationScopes was inserting every entry of
Constants.ApiResourcesScopes (admin, moderation, performer, client,
blogs) into the IdentityResources table, as Profile-derived rows.
That made them visible to /connect/discovery's scopes_supported
under the identity section, but no API resource would ever issue a
token bearing them — IdentityServer then rejected clients that
requested any of these scopes with 'invalid_scope' at the token
endpoint.
The most visible casualty was PostIt, a public PKCE client whose
postit-settings.json asks for scope=openid profile offline_access
blogs. 'blogs' is the scope that gates the Yavsc.Blogs deployment
(blogs.pschneider.fr), so the login flow died at the token step.
Fix:
- Constants.ApiResourcesScopes entries are now seeded as ApiScope
rows (with Name + DisplayName). IdentityResources stays limited
to the actual OpenID Connect profile (openid, profile).
- EnsureDefaultConfiguration gains an idempotent
AlignPostItClientScopes pass that adds any missing scope from
PostItScopes to the existing 'postit' client's AllowedScopes.
Nothing is removed — manual revocation stays manual.
Existing live databases pick up both changes on next startup:
missing ApiScope rows are inserted, and the postit client's
ClientScope rows catch up.
Narrow ISmtpClient to the four operations MailSender actually uses,
behind a Yavsc.Interfaces.ISmtpClientFactory. Production wires
MailKitSmtpClient (SmtpClientFactory); tests wire a recording fake
(RecordingSmtpClientFactory). The fake is pre-registered in
WebServerFixture so SMTP calls are short-circuited; the EMailling
test now asserts the Connect -> Authenticate -> Send -> Disconnect
sequence. Yavsc.Abstract stays free of MailKit/MimeKit.
The partial class ClientController had two constructors declared
across ClientController.cs and ClientController.Collections.cs.
ASP.NET Core DI failed to pick one at request time with:
System.InvalidOperationException: Multiple constructors accepting
all given argument types have been found in type
'Yavsc.Controllers.ClientController'.
Move IHtmlLocalizer<ClientController> into the primary constructor
in ClientController.cs and drop the duplicate one in
ClientController.Collections.cs. The Collections partial now keeps
only its readonly field and action methods; the constructor and
field assignment are unified on the main file.
Also add the missing 'using Microsoft.AspNetCore.Mvc.Localization;'
to ClientController.cs so IHtmlLocalizer resolves.
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.
The previous commit set Platform.CreateBrowser to null on the desktop
side, so LoginAsync would still fail with 'No browser is available'.
Close that loop with an explicit desktop bootstrap.
PostIt.Desktop/PlatformBootstrap.cs mirrors the Android side: it
populates Platform.DefaultRedirectUri and Platform.CreateBrowser
once at startup. Program.Main calls EnsureInitialized before
BuildAvaloniaApp so the LoginPageViewModel sees a working browser
before any login attempt.
The Yavsc.Org seed now reads Site:ExternalUrl from configuration so
the RedirectUri list for the PostIt client follows the same setting
as the rest of the application (same value used in
Administration/ClientController, AccountController, etc.). Without
this, an embedded 'launch PostIt from a Yavsc.Org page' scenario
would be rejected by IdentityServer (redirect_uri mismatch).
BuildPostItRedirectUris is a small helper that yields the constant
PostItRedirectUris (loopback + Android custom scheme) followed by
Site:ExternalUrl when set. Both SeedNewPostItClient (fresh db) and
MigratePostItClientToPublic (existing db) consume it. The legacy
cleanup block (which used to remove https://yavsc.pschneider.fr/
and yavsc://callback) is dropped: Site:ExternalUrl is now the
canonical way to authorise that path and may legitimately equal
that value.
PostIt is a desktop/mobile app talking to Yavsc.Org
(https://yavsc.pschneider.fr) as an OIDC identity provider. The
previous grant used the client_credentials flow with a client_secret
embedded in postit-settings.json: this was both insecure (secret
travels with the binary) and inappropriate for an interactive app
(token had no user identity, so the API could not scope or audit).
The new flow is Authorization Code + PKCE:
* PostIt client (Settings/AuthenticationSettings.cs): the
ClientSecret property is removed; GetOidcClientOptions now drops
the secret and accepts an optional IBrowser supplied per-platform.
* Settings.cs: new AndroidRedirectUri constant ('android://postit-signin')
that the Android app uses; RedirectUri is no longer hard-coded in
MainViewModel.
* MainViewModel.cs: the manual discovery + client_credentials POST is
replaced with OidcClient.LoginAsync (Authorization Code + PKCE).
* Settings sample: Authority points at the real Yavsc.Org OP, not at
a non-existent Keycloak-style realm path.
* Yavsc.Org/Extensions/HostingExtensions.cs: the 'postit' client seed
is now idempotent (MigratePostItClientToPublic) and detects
legacy state on existing ConfigurationDb rows - flips
RequireClientSecret=false, RequirePkce=true, drops any ClientSecret
row, and replaces the legacy RedirectUris
(https://yavsc.pschneider.fr/, yavsc://callback) with the current
set (http://127.0.0.1:7890/, android://postit-signin).
PostIt.Android:
* MainActivity: explicit Name attribute so the activity alias can
target a stable component; LaunchMode.SingleTask so the existing
instance receives the deep-link Intent; OnNewIntent forwards the
callback URI through AndroidOidcCallbackSink.
* AndroidManifest.xml: activity-alias PostIt.Android.OidcCallbackActivity
exposing scheme=android host=postit-signin to Android, so the OP
redirect lands back in the running PostIt instance.
The IdentityModel.OidcClient.Browser.SystemBrowser package and a
thin AndroidSystemBrowser implementation are added in a follow-up so
OidcClient.LoginAsync can actually drive Chrome Custom Tabs and
consume AndroidOidcCallbackSink.
The Site:Audience setting was conflating two distinct concepts: an OAuth
JWT audience (a single resource identifier) and a CORS allow-list (an
array of origins). Collapsing them caused several latent bugs:
- OAuth/JWT validation expected a single string while CORS WithOrigins
accepts an array.
- Password-reset callback URLs and OAuth client RedirectUri/Origin were
being built from what was meant to be an audience identifier, not a
base URL.
- Yavsc.Org's main CORS policy was hardcoded to '*', with no way to
restrict it without code changes.
Changes:
- SiteSettings.Audience (string) replaced with CorsAllowedOrigins
(IList<string>).
- OAuth JWT Authority still reads Site:Authority; Audience now reads
Site:ExternalUrl (Org only; Api/Blogs use ValidateAudience=false).
- MailSender and AccountController build reset-callback URLs from
Site:ExternalUrl.
- ClientController uses Site:ExternalUrl for OAuth RedirectUri/Origin
defaults on newly created clients.
- Yavsc.Api and Yavsc.Blogs now read CORS origins from
Site:CorsAllowedOrigins instead of hardcoded URLs.
Add shared AddYavscCors / AddYavscJwtBearer extension methods in
Yavsc.Server/Helpers/ServiceExtensions.cs to enforce a single
configuration contract across all runtime services (Api, Blogs, Org).
Fails closed when CorsAllowedOrigins is empty; fails fast at startup
when Site:Authority is missing.
Remove obsolete ConfigurationHelpers.GetAudience (no remaining callers).
Local appsettings-*.json files (which carry deployment-specific values
and are gitignored) must be updated to add Site:CorsAllowedOrigins.
Chromium rejects cookies that have SameSite=None but no
Secure flag. The default Identity cookie policy uses
SameSite=None, which is invalid on http://localhost (no
TLS, no Secure). Result on http://localhost:5000:
Cookie '.AspNetCore.Identity.Application' rejected
because it has the 'SameSite=None' attribute but is
missing the 'secure' attribute.
Fix: in Development environment, configure
ConfigureApplicationCookie and ConfigureExternalCookie
to use SameSite=Lax and SameAsRequest SecurePolicy.
Lax is permissive enough for OAuth callbacks (top-level
GET navigations) and avoids the rejection.
Production (https://) is untouched — the default
SameSite=None is correct when Secure is set.
Note on the sameSiteMode reference: SameSiteMode is
defined in two namespaces
(Microsoft.AspNetCore.Http and Microsoft.Net.Http.Headers).
The file already uses 'using Microsoft.Net.Http.Headers;'
so a bare 'SameSiteMode' is ambiguous. Using the
fully-qualified name 'Microsoft.AspNetCore.Http.SameSiteMode'
to disambiguate, no new using needed.
Tested: dotnet build OK, dotnet test 11/11 green.
- Add in-memory database support for test isolation in WebServerFixture
- Implement TestMailSender fake SMTP provider for email test support
- Add thread synchronization to billing service registration to prevent race conditions
- Make RegisterBilling<T> idempotent to safely handle reconfiguration
- Configure test environment via in-memory settings (UseTestEmailSender, UseInMemoryDatabase)
- Add regression tests for billing module idempotency and duplicate registration detection
- Fix tests: EMaillingTests.SendEMailSynchrone, BillingServiceTests (2 tests), HaveConfigurationRoot (3 tests)
All core test infrastructure tests now passing.