Skip to content

Replace IdentityServer4 with OpenIddict and upgrade to .net 10#28

Open
juliangiebel wants to merge 43 commits into
space-wizards:masterfrom
juliangiebel:2025.08.10-openiddict-net9-upgrade
Open

Replace IdentityServer4 with OpenIddict and upgrade to .net 10#28
juliangiebel wants to merge 43 commits into
space-wizards:masterfrom
juliangiebel:2025.08.10-openiddict-net9-upgrade

Conversation

@juliangiebel

@juliangiebel juliangiebel commented Sep 1, 2025

Copy link
Copy Markdown
Contributor

!!!This PR was locally tested extensively. It should still be tested with different oauth clients used in production though. Esp. the wiki as it can potentially break those oauth clients/application. Ideally upgrading SS14.Web is tested on a separate instance created from a snapshot first!!!

This PR updates the project to .net 9 and replaces IdentityServer4 with OpenIddict while trying to ensure that existing OAuth applications keep working. For that purpose there is a data migration sql script under tools/identityserver4_to_openiddict_data_migration.sql.

Differences between IdentityServer4 and OpenIddict

The biggest difference between the two OIDC solutions is the way client/applications are stored and the amount of extra features and settings.
OpenIddict uses a lot less database entities to store applications as it stores most non queried settings and configurations as json inside the application entity. It also doesn't normalize entities to quite the extent IS4 does.

This means there wasn't a need for a separate schema.
Also a lot of settings that weren't used got removed from the UI as implementing them with OpenIddict would mean adding custom settings and implementing custom event handlers (Which this PR already does for settings that had to be re-implemented like "PlainPkce" and "Allow PS256")

Encryption and Signing keys have to be supplied as certificates using PFX files.

OpenIddict also uses a different hashing algorithm for secrets and it salts them so migrated keys from IS4 are marked as legacy and handled seperatly using a re-implementation of the the way secrets are hashed in IS4 (Which uses standard .net cryptography methods).

Additional changes

  • With the upgrade to .net 9 this PR also consolidates Program.cs and Startup.cs for SS14.Web/SS14.Web into a single top lever Program.cs file.
  • Some files use #nullable true.
  • Custom implementations for postgres type conversions/handlers weren't necessary anymore and got removed.

Noteworthy improvements

  • The /.well-known/openid-configuration now only lists actually supported capabilities and features
  • Access and authentication tokens are encrypted (Which might've also been already the case with IS4)
  • Requested scopes are now present in the ID token in addition to getting them the userinfo endpoint

Generating encryption and signing certificates

At least one encryption and one signing certificate needs to be generated and configured for each encryption/signing algorithm that needs to be supported.
For this purpose a csx file exists under tools\GenerateCerts.csx(I used the .net10 preview to execute it otherwise it needs a proper project file).
Executing that file will generate 3 certificates. One encryption certificate using RS265 and two signing certificates using RS265 and PS265 respectively.

Example certificate configuration

OpenId:
  Certificates:
    DefaultSigningAlgorithm: RS256
    EncryptionCertificates:
      - Path: 'C:\Users\julia\Projects\SS14.Web\cert\server-encryption-certificate.pfx'
    SigningCertificates:
      - Path: 'C:\Users\julia\Projects\SS14.Web\cert\server-signing-certificate-rsa-pss.pfx'
        Algorithm: PS256
      - Path: 'C:\Users\julia\Projects\SS14.Web\cert\server-signing-certificate-rsa.pfx'

Fixes #20
Fixes #17

Upgrade nuget packages
Fix npgsql errors from upgrade
Note: (IpAddress, int) is now NpgsqlCidr
Implement unit tests for multiple secret handling methods
Implement custom url validator
Work on admin oauth app settings page
Implement legacy token handling test
Comment thread Tools/GenerateCerts.csx

var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(10));

File.WriteAllBytes("server-encryption-certificate.pfx", certificate.Export(X509ContentType.Pfx, string.Empty));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might want to add a check if any of these files exist and throw hard, so we don't accidentally the certs.

@VasilisThePikachu VasilisThePikachu Nov 22, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering this tool will be ran once I doubt it's really an issue. I guess it's quick though fo code just in case.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lowercase SQL is a crime

@VasilisThePikachu VasilisThePikachu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok well one thing you SHOULD actually do it update the dotnet version for the test so it wont fail

Also i guess look into net 10 support now, OpenIddict does support it

@juliangiebel juliangiebel changed the title Replace IdentityServer4 with OpenIddict and upgrade to .net 9 Replace IdentityServer4 with OpenIddict and upgrade to .net 10 Mar 2, 2026
This is necessary to keep the endpoint path that's been used previously while having the authorize logic inside the consent page.
That avoids having to haul around the auth request data between redirects (which would be annoying to implement at this point)

@Fildrance Fildrance left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall it works! Looks slim, which is nice. Almost all of my comments are kinda nitpicks and are dismissable, bragging about inconsistencies in codestyle (which is funny, considering we review stuff with razor pages, kek). I tried my best not to devolve into poking questionable linebreaks :>

Tested it with local calls, with OAuthTest, analyzed OAuthTest and it seems to be statifying all in all.

I do, however, propose some more additions before we release it:

  • adding settings for blocking user/'oauth client' creation and updates (with some setting-provided message) - so we could bascially make database read-only and not have any data-loss in case of backup if something goes poorly during release
  • remove tables deletion from migrations so old stuff would still work fine if we just revert app
  • prepare migration up for migrating down
  • hook migration script into c# migration class :>

using Microsoft.Extensions.Logging;
using OpenIddict.Core;
using SS14.Auth.Shared.Data;
using SS14.Web.Extensions;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unused using

Scopes: x.Scopes
));

StringToFile(zip, "UserOAuthClients.json", data);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now it is unused but it is good method to have i think, so let it be?

{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserManager<SpaceUser> _userManager;
private readonly SignInManager<SpaceUser> _signInManager;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unused field

if (_httpContextAccessor.HttpContext is not { } context)
return null;

var user = await _userManager.GetUserAsync(context.User);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its a little bit sad that we have to do 2 calls for storage - GetUserAsync and GetUserIdAsync, but yeah, i don't see any nicer way... sadge

/// <summary>
/// Provides claims for the specified identity.
/// </summary>
/// <param name="userId"></param>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't leave them empty :godo:


public class TestMultipleClientSecretHandling
{
[TestCase( "0.1755342104.key1111.desc", 0, ExpectedResult = 0)]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

odd whitespaces at start of test case and unused usings

Comment thread Tools/GenerateCerts.csx

certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(10));

File.WriteAllBytes("server-signing-certificate-rsa-pss.pfx", certificate.Export(X509ContentType.Pfx, string.Empty));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would say it would be nice to have something added in readme.md - what it does (what for certs should be used with openiddict in current scheme), when to use it.

Comment on lines +3 to +25
(select "SpaceUserId" from public."UserOAuthClients" uc where c."Id" = uc."ClientId"),
"LogoUri", null as ApplicationType, c."ClientId",
(select string_agg(array_to_string(array [
cs."Id"::text,
floor(extract(epoch from cs."Created"))::text,
'_OLD_' || cs."Value",
replace(cs."Description", '*', '')
], '.'), ',') from "IS4"."ClientSecrets" cs where cs."ClientId" = c."Id") ClientSecret,
'confidential' as ClientType, gen_random_uuid() as ConcurrencyToken,
case when "RequireConsent" then 'Explicit' else 'Implicit' end as "ConsentType",
"ClientName" as DisplayName, null as DisplayNames, null as JsonWebKeySet,
'["ept:authorization","ept:token","ept:introspection","ept:end_session","gt:refresh_token","gt:authorization_code","rst:code","scp:email","scp:profile","scp:roles"]'
as Permissions,
null as PostLogoutRedirectUris,
null as Properties,
(select json_agg("RedirectUri") from "IS4"."ClientRedirectUris" cr where cr."ClientId" = c."Id")::text "RedirectUris",
case when "RequirePkce" then '["ft:pkce"]' end as "Requirements",
json_build_object(
'space:AllowPlainPkce', "AllowPlainTextPkce"::TEXT,
'space:SigningAlgorithm', "AllowedIdentityTokenSigningAlgorithms",
'space:Disabled', ("Enabled" = false)::TEXT
)::text as "Settings",
"ClientUri" as WebsiteUrl

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
(select "SpaceUserId" from public."UserOAuthClients" uc where c."Id" = uc."ClientId"),
"LogoUri", null as ApplicationType, c."ClientId",
(select string_agg(array_to_string(array [
cs."Id"::text,
floor(extract(epoch from cs."Created"))::text,
'_OLD_' || cs."Value",
replace(cs."Description", '*', '')
], '.'), ',') from "IS4"."ClientSecrets" cs where cs."ClientId" = c."Id") ClientSecret,
'confidential' as ClientType, gen_random_uuid() as ConcurrencyToken,
case when "RequireConsent" then 'Explicit' else 'Implicit' end as "ConsentType",
"ClientName" as DisplayName, null as DisplayNames, null as JsonWebKeySet,
'["ept:authorization","ept:token","ept:introspection","ept:end_session","gt:refresh_token","gt:authorization_code","rst:code","scp:email","scp:profile","scp:roles"]'
as Permissions,
null as PostLogoutRedirectUris,
null as Properties,
(select json_agg("RedirectUri") from "IS4"."ClientRedirectUris" cr where cr."ClientId" = c."Id")::text "RedirectUris",
case when "RequirePkce" then '["ft:pkce"]' end as "Requirements",
json_build_object(
'space:AllowPlainPkce', "AllowPlainTextPkce"::TEXT,
'space:SigningAlgorithm', "AllowedIdentityTokenSigningAlgorithms",
'space:Disabled', ("Enabled" = false)::TEXT
)::text as "Settings",
"ClientUri" as WebsiteUrl
(select "SpaceUserId" from public."UserOAuthClients" uc where c."Id" = uc."ClientId"),
"LogoUri",
null as ApplicationType,
c."ClientId",
(select string_agg(array_to_string(array [
cs."Id"::text,
floor(extract(epoch from cs."Created"))::text,
'_OLD_' || cs."Value",
replace(cs."Description", '*', '')
], '.'), ',') from "IS4"."ClientSecrets" cs where cs."ClientId" = c."Id") ClientSecret,
'confidential' as ClientType,
gen_random_uuid() as ConcurrencyToken,
case when "RequireConsent" then 'Explicit' else 'Implicit' end as "ConsentType",
"ClientName" as DisplayName,
null as DisplayNames,
null as JsonWebKeySet,
'["ept:authorization","ept:token","ept:introspection","ept:end_session","gt:refresh_token","gt:authorization_code","rst:code","scp:email","scp:profile","scp:roles"]'
as Permissions,
null as PostLogoutRedirectUris,
null as Properties,
(select json_agg("RedirectUri") from "IS4"."ClientRedirectUris" cr where cr."ClientId" = c."Id")::text "RedirectUris",
case when "RequirePkce" then '["ft:pkce"]' end as "Requirements",
json_build_object(
'space:AllowPlainPkce', "AllowPlainTextPkce"::TEXT,
'space:SigningAlgorithm', "AllowedIdentityTokenSigningAlgorithms",
'space:Disabled', ("Enabled" = false)::TEXT
)::text as "Settings",
"ClientUri" as WebsiteUrl

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix indent and line breaks (1 field max per line)

Comment thread .gitignore
*/appsettings.Secret.yml
*/tempkey.jwk No newline at end of file
*/tempkey.jwk
cert

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By default they are generated in tools folder, so i would propose ignoring them there and pathing them there :|

@@ -0,0 +1,26 @@
insert into public."OpenIddictApplications"
select gen_random_uuid() as Id,
(select "SpaceUserId" from public."UserOAuthClients" uc where c."Id" = uc."ClientId"),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

have we not deleted UserOAuthClients in AddSpaceAppHomePageProperty migration?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Redirect URI entry for OAuth apps needs to normalize escape codes Allow including claims in identity tokens for OAuth.

5 participants