From 3744017127eb7552ce2e0888fecaa8ccba21f8a5 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Thu, 25 Jun 2026 20:46:58 +0100 Subject: [PATCH] Seed ApiScopes for ApiResourcesScopes, align PostIt client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Yavsc.Org/Extensions/HostingExtensions.cs | 86 ++++++++++++++++--- 1 file changed, 75 insertions(+), 11 deletions(-) diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index d764f362..d460538b 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -539,7 +539,10 @@ public static class HostingExtensions var identityResources = context.Set(); var apiScopes = context.Set(); - // IdentityResources standards + // IdentityResources standards (OpenId + Profile only). + // Application-defined API scopes from Constants.ApiResourcesScopes + // are NOT identity resources — they belong to the ApiScopes table + // and are seeded as such further down. if (!identityResources.Any(r => r.Name == "openid")) { var openid = new IdentityResources.OpenId().ToEntity(); @@ -551,18 +554,29 @@ public static class HostingExtensions var profile = new IdentityResources.Profile().ToEntity(); identityResources.Add(profile); } - foreach (var scope in Constants.ApiResourcesScopes) - { - if (!identityResources.Any(s => s.Name == scope.ScopeName)) + // Application-defined API scopes (admin, moderation, performer, + // client, blogs, …). Seeded into ApiScopes, not IdentityResources: + // these gate access to API resources (e.g. the Yavsc.Blogs + // deployment requires the "blogs" scope) and must therefore be + // discoverable through /connect/discovery's + // scopes_supported of type resource, not identity. + // + // NOTE: prior versions inserted these into IdentityResources, + // which made them visible to /connect/authorize but unfulfillable + // (no API resource recognises an identity-scoped consent as + // access to a downstream resource). Clients like PostIt that + // request one of these scopes were rejected with "invalid_scope" + // at the token endpoint. Keep this in ApiScopes. + foreach (var scopeSpec in Constants.ApiResourcesScopes) + { + if (!apiScopes.Any(s => s.Name == scopeSpec.ScopeName)) { - identityResources.Add( - new IdentityResources.Profile() - { - Name = scope.ScopeName, - DisplayName = scope.Description, - Enabled = true - }.ToEntity()); + apiScopes.Add(new IdentityServer8.EntityFramework.Entities.ApiScope + { + Name = scopeSpec.ScopeName, + DisplayName = scopeSpec.Description, + }); } } context.SaveChanges(); @@ -618,6 +632,16 @@ public static class HostingExtensions return; } + // The PostIt client was already seeded in a previous run. + // Make sure its AllowedScopes still match what the server + // actually exposes — for instance, "blogs" only exists as an + // ApiScope since we fixed the seed (see EnsureDefaultApplicationScopes + // above). Without this pass, a client created before the fix + // would still ask for a scope the server no longer recognises + // and IdentityServer would answer "invalid_scope" at the token + // endpoint. Idempotent: missing scopes are added, nothing is + // removed (manual revocation stays manual). + AlignPostItClientScopes(context, existingClient); }; } @@ -674,6 +698,46 @@ public static class HostingExtensions context.SaveChanges(); } + /// + /// Idempotent reconciliation of the PostIt client's AllowedScopes + /// against the scopes the server actually publishes. Adds any missing + /// scope as a ClientScope row; never removes anything (revocation is a + /// manual operation, not a seed concern). Called on every startup from + /// so a client created before + /// a scope was introduced (or before the seed was corrected) catches up + /// automatically. + /// + private static void AlignPostItClientScopes( + DbContext context, + IdentityServer8.EntityFramework.Entities.Client postitClient + ) + { + var clientScopes = context.Set(); + var existingScopeNames = clientScopes + .Where(s => s.Client == postitClient || s.ClientId == postitClient.Id) + .Select(s => s.Scope) + .ToHashSet(); + + bool changed = false; + foreach (var scope in PostItScopes) + { + if (existingScopeNames.Contains(scope)) + continue; + + clientScopes.Add(new IdentityServer8.EntityFramework.Entities.ClientScope + { + Client = postitClient, + Scope = scope + }); + changed = true; + } + + if (changed) + { + context.SaveChanges(); + } + } + /// /// Compose the full set of redirect URIs for the PostIt client. The base /// URIs cover the standalone desktop/mobile flows; the value of