From 7736424f13d0020d11b47ac145922436d9e938e6 Mon Sep 17 00:00:00 2001 From: Kirill Kovalev Date: Fri, 16 Oct 2026 09:00:28 +0500 Subject: [PATCH 01/12] Run the identity server on Abblix OIDC Server 3.0 Identity.API targets .NET 11, the only runtime the 3.0 packages support, and the rest of the solution stays on .NET 10; global.json selects the .NET 11 SDK, which builds both. The library's MVC endpoints carry no routes of their own in 3.0, so Program.cs maps them with MapOidcControllers, and the sign-in screen asks IOidcEndpointResolver for the authorization endpoint it resumes, which answers with the path the endpoint is mapped on. Until 3.0 is published on nuget.org the package comes from a development build on GitHub Packages, so nuget.config names that source again and a restore needs a token with read:packages. (cherry picked from commit d2f6770e05507160cba3657b8cd0c7afbb2fb7ff) --- Directory.Packages.props | 2 +- global.json | 4 ++-- nuget.config | 7 +++++++ src/Identity.API/Controllers/AccountController.cs | 15 +++++++++++---- src/Identity.API/Identity.API.csproj | 2 +- src/Identity.API/Program.cs | 7 ++++--- 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0945a1af4..1c38c5acf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -65,7 +65,7 @@ - + diff --git a/global.json b/global.json index 659b45463..d25d59ffe 100644 --- a/global.json +++ b/global.json @@ -1,8 +1,8 @@ { "sdk": { - "version": "10.0.302", + "version": "11.0.100-rc.2.26504.105", "rollForward": "latestFeature", - "allowPrerelease": false + "allowPrerelease": true }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/nuget.config b/nuget.config index 9fbf074fd..eea497f64 100644 --- a/nuget.config +++ b/nuget.config @@ -4,6 +4,10 @@ + + @@ -11,5 +15,8 @@ + + + \ No newline at end of file diff --git a/src/Identity.API/Controllers/AccountController.cs b/src/Identity.API/Controllers/AccountController.cs index e1fccb786..959206dc4 100644 --- a/src/Identity.API/Controllers/AccountController.cs +++ b/src/Identity.API/Controllers/AccountController.cs @@ -1,8 +1,9 @@ using Abblix.Oidc.Server.Features.RandomGenerators; using Abblix.Oidc.Server.Model; +using Abblix.Oidc.Server.Common.Configuration; +using Abblix.Oidc.Server.Common.Interfaces; using IdentityServerHost.Quickstart.UI; using Microsoft.AspNetCore.Authentication.Cookies; -using Path = Abblix.Oidc.Server.Mvc.Path; using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; using UriBuilder = Abblix.Utils.UriBuilder; @@ -23,7 +24,7 @@ public class AccountController( SignInManager signInManager, IAuthSessionService authSessionService, ISessionIdGenerator sessionIdGenerator, - IUriResolver uriResolver, + IOidcEndpointResolver endpointResolver, TimeProvider clock) : Controller { [HttpGet] @@ -65,8 +66,14 @@ public async Task Login(LoginViewModel model) await authSessionService.SignInAsync(authSession); - // Resume the interrupted authorization request by its reference. - var authorizeUrl = new UriBuilder(uriResolver.Content(Path.Authorize)) + // Resume the interrupted authorization request by its reference. The library answers with the + // path it mapped the endpoint on, so a moved path or a prefix needs no change here; it answers + // nothing only for a disabled endpoint, and a server without its authorization endpoint has no + // request to resume. + var authorizationEndpoint = endpointResolver.Resolve(OidcEndpoints.Authorize) + ?? throw new InvalidOperationException("The authorization endpoint is not mapped."); + + var authorizeUrl = new UriBuilder(authorizationEndpoint) { Query = { [AuthorizationRequest.Parameters.RequestUri] = model.RequestUri }, }; diff --git a/src/Identity.API/Identity.API.csproj b/src/Identity.API/Identity.API.csproj index 96deb0cb6..1290b7755 100644 --- a/src/Identity.API/Identity.API.csproj +++ b/src/Identity.API/Identity.API.csproj @@ -1,7 +1,7 @@  - net10.0 + net11.0 aspnet-eShopOnContainers.Identity-90487118-103c-4ff0-b9da-e5e26f7ab0c5 diff --git a/src/Identity.API/Program.cs b/src/Identity.API/Program.cs index 30ed25a54..7e145d517 100644 --- a/src/Identity.API/Program.cs +++ b/src/Identity.API/Program.cs @@ -100,9 +100,10 @@ app.UseAuthentication(); app.UseAuthorization(); -// The protocol endpoints are attribute-routed controllers supplied by the library, so they are -// mapped the same way any other controller is. -app.MapControllers(); +// The protocol endpoints are controllers supplied by the library with no routes of their own: the +// library maps them at the paths its options give them, and an endpoint that is not enabled is not +// mapped at all. The default route below serves this application's own sign-in screen. +app.MapOidcControllers(); app.MapDefaultControllerRoute(); app.Run(); From 7d364e617287dde102a1b09079976f0c199cbf8d Mon Sep 17 00:00:00 2001 From: Kirill Kovalev Date: Fri, 16 Oct 2026 09:28:24 +0500 Subject: [PATCH 02/12] Serve the protocol endpoints through the Minimal API adapter Identity.API references Abblix.OIDC.Server.MinimalAPI instead of the MVC adapter and maps the protocol endpoints with MapOidcEndpoints, the way the other services of this solution serve their HTTP APIs. The sign-in screen stays an MVC controller with views, as in the upstream Identity.API, so the default controller route remains. (cherry picked from commit e688dcb1945e053208c5cecaf94efe5db18af755) --- Directory.Packages.props | 2 +- src/Identity.API/GlobalUsings.cs | 2 +- src/Identity.API/Identity.API.csproj | 2 +- src/Identity.API/Program.cs | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 1c38c5acf..b573d4944 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -65,7 +65,7 @@ - + diff --git a/src/Identity.API/GlobalUsings.cs b/src/Identity.API/GlobalUsings.cs index 521187071..d46500a0b 100644 --- a/src/Identity.API/GlobalUsings.cs +++ b/src/Identity.API/GlobalUsings.cs @@ -11,7 +11,7 @@ global using Abblix.Oidc.Server.Features.ClientInformation; global using Abblix.Oidc.Server.Features.UserAuthentication; global using Abblix.Oidc.Server.Features.UserInfo; -global using Abblix.Oidc.Server.Mvc; +global using Abblix.Oidc.Server.MinimalApi; global using Microsoft.AspNetCore.Authentication; global using Microsoft.AspNetCore.Authorization; global using Microsoft.AspNetCore.Builder; diff --git a/src/Identity.API/Identity.API.csproj b/src/Identity.API/Identity.API.csproj index 1290b7755..c7426fd33 100644 --- a/src/Identity.API/Identity.API.csproj +++ b/src/Identity.API/Identity.API.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/Identity.API/Program.cs b/src/Identity.API/Program.cs index 7e145d517..55c0afaf3 100644 --- a/src/Identity.API/Program.cs +++ b/src/Identity.API/Program.cs @@ -100,10 +100,10 @@ app.UseAuthentication(); app.UseAuthorization(); -// The protocol endpoints are controllers supplied by the library with no routes of their own: the -// library maps them at the paths its options give them, and an endpoint that is not enabled is not -// mapped at all. The default route below serves this application's own sign-in screen. -app.MapOidcControllers(); +// The protocol endpoints are Minimal API route handlers supplied by the library, mapped at the paths +// its options give them; an endpoint that is not enabled is not mapped at all. The default route below +// serves this application's own sign-in screen, which stays an MVC controller with views. +app.MapOidcEndpoints(); app.MapDefaultControllerRoute(); app.Run(); From 536e0e4d16508d0cada528f19217fe335f68b571 Mon Sep 17 00:00:00 2001 From: Kirill Kovalev Date: Fri, 16 Oct 2026 10:14:26 +0500 Subject: [PATCH 03/12] Ask the user on a page of its own before an unconfirmed logout Abblix OIDC Server 3.0 asks the end user to confirm a logout it would not perform on its own, such as one whose ID token hint names a session other than the current one, and answers it with the value the confirming request carries back. LogoutConfirmationPage decorates the end-session response formatter and sends that answer to /Account/Logout with the parameters of the original request, and the Logout view, the upstream identity server's own logout prompt, posts them back to the end-session endpoint when the user agrees. (cherry picked from commit 9ee745aa0d821eb28981a0c4591fe2b131722588) --- .../Controllers/AccountController.cs | 21 +++++++++ src/Identity.API/Models/LogoutViewModel.cs | 16 +++++++ src/Identity.API/Program.cs | 6 +++ .../Services/LogoutConfirmationPage.cs | 45 +++++++++++++++++++ src/Identity.API/Views/Account/Logout.cshtml | 18 ++++++++ 5 files changed, 106 insertions(+) create mode 100644 src/Identity.API/Models/LogoutViewModel.cs create mode 100644 src/Identity.API/Services/LogoutConfirmationPage.cs create mode 100644 src/Identity.API/Views/Account/Logout.cshtml diff --git a/src/Identity.API/Controllers/AccountController.cs b/src/Identity.API/Controllers/AccountController.cs index 959206dc4..6224121c0 100644 --- a/src/Identity.API/Controllers/AccountController.cs +++ b/src/Identity.API/Controllers/AccountController.cs @@ -81,6 +81,27 @@ public async Task Login(LoginViewModel model) return Redirect(authorizeUrl); } + /// + /// Asks the user to confirm a logout the server would not perform on its own, such as one whose ID token hint + /// names a session other than the current one. + /// + /// + /// sends the browser here with the parameters of the original request + /// and the value the server issued for the answer; the page posts them back to the end-session endpoint. + /// + [HttpGet] + public IActionResult Logout() + { + var endSessionEndpoint = endpointResolver.Resolve(OidcEndpoints.EndSession) + ?? throw new InvalidOperationException("The end-session endpoint is not mapped."); + + return View(new LogoutViewModel + { + EndSessionEndpoint = endSessionEndpoint, + Parameters = [.. Request.Query.Select(parameter => KeyValuePair.Create(parameter.Key, parameter.Value.ToString()))], + }); + } + [HttpGet] public IActionResult AccessDenied() => View(); } diff --git a/src/Identity.API/Models/LogoutViewModel.cs b/src/Identity.API/Models/LogoutViewModel.cs new file mode 100644 index 000000000..5139545e3 --- /dev/null +++ b/src/Identity.API/Models/LogoutViewModel.cs @@ -0,0 +1,16 @@ +namespace eShop.Identity.API.Models +{ + public class LogoutViewModel + { + /// + /// Where the confirming request goes: the server's end-session endpoint. + /// + public Uri EndSessionEndpoint { get; set; } + + /// + /// The parameters of the logout request being confirmed, the value the server issued for the answer among + /// them, sent back unchanged when the user agrees. + /// + public IReadOnlyList> Parameters { get; set; } + } +} diff --git a/src/Identity.API/Program.cs b/src/Identity.API/Program.cs index 55c0afaf3..c6cec7e99 100644 --- a/src/Identity.API/Program.cs +++ b/src/Identity.API/Program.cs @@ -1,5 +1,7 @@ +using Abblix.DependencyInjection; using Abblix.Oidc.Server.AspNetCore; using Abblix.Oidc.Server.Common.Interfaces; +using Abblix.Oidc.Server.MinimalApi.Formatters.Interfaces; using Microsoft.AspNetCore.Authentication.Cookies; var builder = WebApplication.CreateBuilder(args); @@ -62,6 +64,10 @@ options.Clients = oidcConfiguration.ToClientInfos(); }); +// A logout the user has to confirm is shown on this application's own page rather than answered with the +// library's JSON body, which a browser following a client's sign-out would display as it is. +builder.Services.Decorate(); + // The library registers the CORS policy the protocol endpoints name, and an empty origin list // means any origin may read them. eShop has no browser-based client, so the complete list of // origins that could ever need them is the addresses its own clients answer on. diff --git a/src/Identity.API/Services/LogoutConfirmationPage.cs b/src/Identity.API/Services/LogoutConfirmationPage.cs new file mode 100644 index 000000000..59cbe84e3 --- /dev/null +++ b/src/Identity.API/Services/LogoutConfirmationPage.cs @@ -0,0 +1,45 @@ +using Abblix.Oidc.Server.Common; +using Abblix.Oidc.Server.Endpoints.EndSession.Interfaces; +using Abblix.Oidc.Server.MinimalApi.Formatters.Interfaces; +using Abblix.Utils; +using Microsoft.AspNetCore.WebUtilities; +using EndSessionRequest = Abblix.Oidc.Server.Model.EndSessionRequest; + +namespace eShop.Identity.API.Services +{ + /// + /// Sends a logout the end user has to confirm to this application's own confirmation page. + /// + /// + /// The library answers such a request with the value the confirming request has to carry back, as a JSON body, + /// which a browser following a client's sign-out would show as it is. This decorator redirects to the page + /// instead and hands it that value together with the parameters of the original request, so that the page can + /// send all of them back to the end-session endpoint once the user agrees. + /// + public class LogoutConfirmationPage(IEndSessionResponseFormatter inner) : IEndSessionResponseFormatter + { + public Task FormatResponseAsync( + EndSessionRequest request, Result response) + { + if (response.TryGetSuccess(out var success) && success is ConfirmationRequired confirmation) + return Task.FromResult(Results.Redirect(PageUrl(request, confirmation.Confirmation))); + + return inner.FormatResponseAsync(request, response); + } + + private static string PageUrl(EndSessionRequest request, string confirmation) + { + KeyValuePair[] parameters = + [ + new(EndSessionRequest.Parameters.Confirmation, confirmation), + new(EndSessionRequest.Parameters.IdTokenHint, request.IdTokenHint), + new(EndSessionRequest.Parameters.LogoutHint, request.LogoutHint), + new(EndSessionRequest.Parameters.ClientId, request.ClientId), + new(EndSessionRequest.Parameters.PostLogoutRedirectUri, request.PostLogoutRedirectUri?.OriginalString), + new(EndSessionRequest.Parameters.State, request.State), + ]; + + return QueryHelpers.AddQueryString("/Account/Logout", parameters.Where(parameter => parameter.Value != null)); + } + } +} diff --git a/src/Identity.API/Views/Account/Logout.cshtml b/src/Identity.API/Views/Account/Logout.cshtml new file mode 100644 index 000000000..d75a55c92 --- /dev/null +++ b/src/Identity.API/Views/Account/Logout.cshtml @@ -0,0 +1,18 @@ +@model LogoutViewModel + +
+
+

Logout

+

Would you like to log out?

+
+ +
+ @foreach (var parameter in Model.Parameters) + { + + } +
+ +
+
+
From 71b40034f82e9f554e9615424bf7fcf4b4853b57 Mon Sep 17 00:00:00 2001 From: Kirill Kovalev Date: Fri, 16 Oct 2026 10:15:48 +0500 Subject: [PATCH 04/12] Install the SDK from global.json and the .NET 10 runtime in CI The pull-request validation and Playwright workflows install the SDK that global.json selects, which builds the identity server for .NET 11, and the .NET 10 runtime the other services and their tests run on. The setup step is pinned to the commit of actions/setup-dotnet v5.2.0. Checked: Pinning (setup-dotnet by SHA), Inputs (no expressions in run). N/A: Permissions, Secrets (steps changed hold none). (cherry picked from commit 459faf7bb5037695148fff67b3420af69d721918) --- .github/workflows/playwright.yml | 4 +++- .github/workflows/pr-validation.yml | 9 +++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 61d8ac734..9d38fe02c 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -20,8 +20,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 + # The SDK global.json selects builds the identity server for .NET 11; the other services run on .NET 10. + - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: + global-json-file: global.json dotnet-version: '10.0.x' - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 77431a0ca..8fb6bd5d1 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -21,8 +21,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Setup .NET (global.json) - uses: actions/setup-dotnet@v3 + # The SDK global.json selects builds the identity server for .NET 11; the tests of the other services run + # on .NET 10. + - name: Setup .NET (global.json and the .NET 10 runtime) + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + global-json-file: global.json + dotnet-version: '10.0.x' - name: Build run: dotnet build eShop.Web.slnf - name: Test From 1d57787006a7e9a6fbc9c53b87aaf38f6ed25fcc Mon Sep 17 00:00:00 2001 From: Kirill Kovalev Date: Fri, 16 Oct 2026 13:25:40 +0500 Subject: [PATCH 05/12] Build upstream's application tests for .NET 11 with the identity server The application tests reference Identity.API, which Abblix OIDC Server 3.0 builds for .NET 11 only, so a net10.0 test project could not restore against it. --- tests/Application.UnitTests/Application.UnitTests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Application.UnitTests/Application.UnitTests.csproj b/tests/Application.UnitTests/Application.UnitTests.csproj index c79404446..7201446cf 100644 --- a/tests/Application.UnitTests/Application.UnitTests.csproj +++ b/tests/Application.UnitTests/Application.UnitTests.csproj @@ -1,7 +1,7 @@ - net10.0 + net11.0 false false Exe From 5ac98a09b50bd0b1ae07601b50237b6034be4538 Mon Sep 17 00:00:00 2001 From: Kirill Kovalev Date: Fri, 16 Oct 2026 13:25:40 +0500 Subject: [PATCH 06/12] Say what restoring the 3.0 preview needs, and what each service runs on The Abblix package source is now keyed abblix, so its credentials come from NuGetPackageSourceCredentials_abblix, a name a shell can set; the source's comment says what the variable holds. The README asks for the .NET 11 SDK and the ASP.NET Core Runtime 10 the other services run on, and for the token restoring needs until 3.0 is on nuget.org. --- README.md | 13 ++++++++++--- nuget.config | 8 ++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4e66b1ec9..ea3fb0894 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# eShop on Abblix OIDC Server: a worked IdentityServer migration for .NET 10 +# eShop on Abblix OIDC Server: a worked IdentityServer migration Microsoft's [eShop](https://github.com/dotnet/eShop) reference application with its OpenID Connect provider migrated from Duende IdentityServer to @@ -45,13 +45,14 @@ A reference .NET application implementing an e-commerce website using a services ## Getting Started -This version of eShop is based on .NET 10. +This version of eShop is based on .NET 10. Its identity server, Identity.API, runs on .NET 11, the +runtime Abblix OIDC Server 3.0 is built for. Upstream, including its earlier releases: [dotnet/eShop](https://github.com/dotnet/eShop). ### Prerequisites -1. Install a [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) that satisfies [`global.json`](global.json). +1. Install a [.NET 11 SDK](https://dotnet.microsoft.com/download/dotnet/11.0) that satisfies [`global.json`](global.json), and the [ASP.NET Core Runtime 10](https://dotnet.microsoft.com/download/dotnet/10.0) the other services run on. 2. Install the [Aspire CLI](https://aspire.dev/get-started/install-cli/) and verify that it is available: ```console @@ -66,6 +67,12 @@ Upstream, including its earlier releases: [dotnet/eShop](https://github.com/dotn cd eShop ``` +5. Until Abblix OIDC Server 3.0 is published on nuget.org, its packages come from GitHub Packages, which needs a GitHub personal access token with the `read:packages` scope. Set it for the restore: + + ```powershell + $env:NuGetPackageSourceCredentials_abblix = "Username=;Password=" + ``` + No separate Aspire workload or Visual Studio component is required; the AppHost SDK and hosting integrations are referenced by the projects in this repository. #### Optional IDE setup diff --git a/nuget.config b/nuget.config index eea497f64..d995dbb79 100644 --- a/nuget.config +++ b/nuget.config @@ -5,9 +5,9 @@ - + authentication: restore reads it from the NuGetPackageSourceCredentials_abblix + environment variable, set to Username=;Password=. --> + @@ -15,7 +15,7 @@ - + From 271156386a01d939ef6e1c211052313398d5195b Mon Sep 17 00:00:00 2001 From: Kirill Kovalev Date: Fri, 16 Oct 2026 13:25:40 +0500 Subject: [PATCH 07/12] Ask again on the logout page when the earlier answer has expired A logout request carrying a confirmation the server no longer accepts, because the user waited longer than it lasts, is answered with a new one. The page now tells the user that the earlier answer expired instead of asking as if for the first time, and keeps the marker that says so out of what it posts back to the server. --- src/Identity.API/Controllers/AccountController.cs | 8 +++++++- src/Identity.API/Models/LogoutViewModel.cs | 5 +++++ src/Identity.API/Services/LogoutConfirmationPage.cs | 8 ++++++++ src/Identity.API/Views/Account/Logout.cshtml | 9 ++++++++- 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/Identity.API/Controllers/AccountController.cs b/src/Identity.API/Controllers/AccountController.cs index 6224121c0..47844e53d 100644 --- a/src/Identity.API/Controllers/AccountController.cs +++ b/src/Identity.API/Controllers/AccountController.cs @@ -98,7 +98,13 @@ public IActionResult Logout() return View(new LogoutViewModel { EndSessionEndpoint = endSessionEndpoint, - Parameters = [.. Request.Query.Select(parameter => KeyValuePair.Create(parameter.Key, parameter.Value.ToString()))], + Parameters = + [ + .. Request.Query + .Where(parameter => parameter.Key != LogoutConfirmationPage.AskedAgain) + .Select(parameter => KeyValuePair.Create(parameter.Key, parameter.Value.ToString())), + ], + AskedAgain = Request.Query.ContainsKey(LogoutConfirmationPage.AskedAgain), }); } diff --git a/src/Identity.API/Models/LogoutViewModel.cs b/src/Identity.API/Models/LogoutViewModel.cs index 5139545e3..1d32d7352 100644 --- a/src/Identity.API/Models/LogoutViewModel.cs +++ b/src/Identity.API/Models/LogoutViewModel.cs @@ -12,5 +12,10 @@ public class LogoutViewModel /// them, sent back unchanged when the user agrees. /// public IReadOnlyList> Parameters { get; set; } + + /// + /// Whether the user already answered and the answer was no longer accepted. + /// + public bool AskedAgain { get; set; } } } diff --git a/src/Identity.API/Services/LogoutConfirmationPage.cs b/src/Identity.API/Services/LogoutConfirmationPage.cs index 59cbe84e3..a4c4f3cac 100644 --- a/src/Identity.API/Services/LogoutConfirmationPage.cs +++ b/src/Identity.API/Services/LogoutConfirmationPage.cs @@ -18,6 +18,13 @@ namespace eShop.Identity.API.Services /// public class LogoutConfirmationPage(IEndSessionResponseFormatter inner) : IEndSessionResponseFormatter { + /// + /// Marks a page shown again because the answer the user gave was no longer accepted, typically because + /// it took longer than the confirmation lasts. It belongs to this page, not to the protocol, so the page + /// does not send it back. + /// + public const string AskedAgain = "asked_again"; + public Task FormatResponseAsync( EndSessionRequest request, Result response) { @@ -31,6 +38,7 @@ private static string PageUrl(EndSessionRequest request, string confirmation) { KeyValuePair[] parameters = [ + new(AskedAgain, request.Confirmation != null ? bool.TrueString : null), new(EndSessionRequest.Parameters.Confirmation, confirmation), new(EndSessionRequest.Parameters.IdTokenHint, request.IdTokenHint), new(EndSessionRequest.Parameters.LogoutHint, request.LogoutHint), diff --git a/src/Identity.API/Views/Account/Logout.cshtml b/src/Identity.API/Views/Account/Logout.cshtml index d75a55c92..2adbc88da 100644 --- a/src/Identity.API/Views/Account/Logout.cshtml +++ b/src/Identity.API/Views/Account/Logout.cshtml @@ -3,7 +3,14 @@

Logout

-

Would you like to log out?

+ @if (Model.AskedAgain) + { +

Your earlier answer has expired. Would you still like to log out?

+ } + else + { +

Would you like to log out?

+ }
From 65eb3d82e15634f9abca70cf77f5a612f347a01a Mon Sep 17 00:00:00 2001 From: Kirill Kovalev Date: Fri, 16 Oct 2026 13:47:36 +0500 Subject: [PATCH 08/12] Route the logout page through MVC and pass its locale preference on The confirmation redirect wrote the page's path as a fixed string, which missed a base path or a changed route, and dropped ui_locales, so a localized page could not honor it. It now resolves the page through the conventional route when the redirect is written and carries ui_locales with the other parameters. The README asks for both the .NET Runtime 10 and the ASP.NET Core Runtime 10 and shows the token variable for bash too; the workflows' comment on global.json reads as a sentence. --- .github/workflows/playwright.yml | 2 +- .github/workflows/pr-validation.yml | 2 +- README.md | 6 ++++- .../Services/LogoutConfirmationPage.cs | 26 ++++++++++++++++--- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 9d38fe02c..2938848fd 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - # The SDK global.json selects builds the identity server for .NET 11; the other services run on .NET 10. + # The SDK that global.json selects builds the identity server for .NET 11; the other services run on .NET 10. - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: global-json-file: global.json diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 8fb6bd5d1..8a4c7f1be 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - # The SDK global.json selects builds the identity server for .NET 11; the tests of the other services run + # The SDK that global.json selects builds the identity server for .NET 11; the tests of the other services run # on .NET 10. - name: Setup .NET (global.json and the .NET 10 runtime) uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 diff --git a/README.md b/README.md index ea3fb0894..2458f554b 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Upstream, including its earlier releases: [dotnet/eShop](https://github.com/dotn ### Prerequisites -1. Install a [.NET 11 SDK](https://dotnet.microsoft.com/download/dotnet/11.0) that satisfies [`global.json`](global.json), and the [ASP.NET Core Runtime 10](https://dotnet.microsoft.com/download/dotnet/10.0) the other services run on. +1. Install a [.NET 11 SDK](https://dotnet.microsoft.com/download/dotnet/11.0) that satisfies [`global.json`](global.json), and the [.NET Runtime 10 and ASP.NET Core Runtime 10](https://dotnet.microsoft.com/download/dotnet/10.0) the other services run on. 2. Install the [Aspire CLI](https://aspire.dev/get-started/install-cli/) and verify that it is available: ```console @@ -73,6 +73,10 @@ Upstream, including its earlier releases: [dotnet/eShop](https://github.com/dotn $env:NuGetPackageSourceCredentials_abblix = "Username=;Password=" ``` + ```bash + export NuGetPackageSourceCredentials_abblix="Username=;Password=" + ``` + No separate Aspire workload or Visual Studio component is required; the AppHost SDK and hosting integrations are referenced by the projects in this repository. #### Optional IDE setup diff --git a/src/Identity.API/Services/LogoutConfirmationPage.cs b/src/Identity.API/Services/LogoutConfirmationPage.cs index a4c4f3cac..4a1c20213 100644 --- a/src/Identity.API/Services/LogoutConfirmationPage.cs +++ b/src/Identity.API/Services/LogoutConfirmationPage.cs @@ -2,7 +2,6 @@ using Abblix.Oidc.Server.Endpoints.EndSession.Interfaces; using Abblix.Oidc.Server.MinimalApi.Formatters.Interfaces; using Abblix.Utils; -using Microsoft.AspNetCore.WebUtilities; using EndSessionRequest = Abblix.Oidc.Server.Model.EndSessionRequest; namespace eShop.Identity.API.Services @@ -29,12 +28,20 @@ public Task FormatResponseAsync( EndSessionRequest request, Result response) { if (response.TryGetSuccess(out var success) && success is ConfirmationRequired confirmation) - return Task.FromResult(Results.Redirect(PageUrl(request, confirmation.Confirmation))); + return Task.FromResult(Results.RedirectToRoute("default", PageValues(request, confirmation.Confirmation))); return inner.FormatResponseAsync(request, response); } - private static string PageUrl(EndSessionRequest request, string confirmation) + /// + /// The page's controller and action, and the parameters it posts back as its query. + /// + /// + /// Resolved through the conventional route MapDefaultControllerRoute registers, when the redirect is written, + /// so the address follows the controller's route and the application's base path; route values the + /// template does not use become the query string. + /// + private static RouteValueDictionary PageValues(EndSessionRequest request, string confirmation) { KeyValuePair[] parameters = [ @@ -45,9 +52,20 @@ private static string PageUrl(EndSessionRequest request, string confirmation) new(EndSessionRequest.Parameters.ClientId, request.ClientId), new(EndSessionRequest.Parameters.PostLogoutRedirectUri, request.PostLogoutRedirectUri?.OriginalString), new(EndSessionRequest.Parameters.State, request.State), + new(EndSessionRequest.Parameters.UiLocales, request.UiLocales is { } locales + ? string.Join(' ', locales.Select(locale => locale.Name)) + : null), ]; - return QueryHelpers.AddQueryString("/Account/Logout", parameters.Where(parameter => parameter.Value != null)); + var values = new RouteValueDictionary + { + ["controller"] = "Account", + ["action"] = nameof(Controllers.AccountController.Logout), + }; + foreach (var (name, value) in parameters.Where(parameter => parameter.Value != null)) + values[name] = value; + + return values; } } } From bbf0532c3c5e4f0bab7328394fccbcb5d4473ff1 Mon Sep 17 00:00:00 2001 From: Kirill Kovalev Date: Fri, 16 Oct 2026 14:18:37 +0500 Subject: [PATCH 09/12] Send the logout page a path built from its action Redirecting through the conventional route produced an absolute address rebuilt from the request's Host header, which behind a proxy that does not forward it points the browser at the inner host, and it stopped matching as soon as the account controller got a route of its own. The redirect now carries a path the link generator builds from the page's action, with the application's base path, and the query is written from the parameters as they are. --- src/Identity.API/Program.cs | 1 + .../Services/LogoutConfirmationPage.cs | 31 ++++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/Identity.API/Program.cs b/src/Identity.API/Program.cs index c6cec7e99..6df3c0cf7 100644 --- a/src/Identity.API/Program.cs +++ b/src/Identity.API/Program.cs @@ -66,6 +66,7 @@ // A logout the user has to confirm is shown on this application's own page rather than answered with the // library's JSON body, which a browser following a client's sign-out would display as it is. +builder.Services.AddHttpContextAccessor(); builder.Services.Decorate(); // The library registers the CORS policy the protocol endpoints name, and an empty origin list diff --git a/src/Identity.API/Services/LogoutConfirmationPage.cs b/src/Identity.API/Services/LogoutConfirmationPage.cs index 4a1c20213..d3b872136 100644 --- a/src/Identity.API/Services/LogoutConfirmationPage.cs +++ b/src/Identity.API/Services/LogoutConfirmationPage.cs @@ -2,6 +2,7 @@ using Abblix.Oidc.Server.Endpoints.EndSession.Interfaces; using Abblix.Oidc.Server.MinimalApi.Formatters.Interfaces; using Abblix.Utils; +using Microsoft.AspNetCore.WebUtilities; using EndSessionRequest = Abblix.Oidc.Server.Model.EndSessionRequest; namespace eShop.Identity.API.Services @@ -15,7 +16,10 @@ namespace eShop.Identity.API.Services /// instead and hands it that value together with the parameters of the original request, so that the page can /// send all of them back to the end-session endpoint once the user agrees. /// - public class LogoutConfirmationPage(IEndSessionResponseFormatter inner) : IEndSessionResponseFormatter + public class LogoutConfirmationPage( + IEndSessionResponseFormatter inner, + IHttpContextAccessor httpContextAccessor, + LinkGenerator linkGenerator) : IEndSessionResponseFormatter { /// /// Marks a page shown again because the answer the user gave was no longer accepted, typically because @@ -28,20 +32,20 @@ public Task FormatResponseAsync( EndSessionRequest request, Result response) { if (response.TryGetSuccess(out var success) && success is ConfirmationRequired confirmation) - return Task.FromResult(Results.RedirectToRoute("default", PageValues(request, confirmation.Confirmation))); + return Task.FromResult(Results.Redirect(PageUrl(request, confirmation.Confirmation))); return inner.FormatResponseAsync(request, response); } /// - /// The page's controller and action, and the parameters it posts back as its query. + /// The page's address with the parameters it posts back as its query. /// /// - /// Resolved through the conventional route MapDefaultControllerRoute registers, when the redirect is written, - /// so the address follows the controller's route and the application's base path; route values the - /// template does not use become the query string. + /// A path, not an absolute address, so the browser stays on the scheme and host it reached this server on + /// rather than one rebuilt from the request's Host header. The path comes from the page's action, so it + /// follows whatever route maps it and the application's base path. /// - private static RouteValueDictionary PageValues(EndSessionRequest request, string confirmation) + private string PageUrl(EndSessionRequest request, string confirmation) { KeyValuePair[] parameters = [ @@ -57,15 +61,12 @@ private static RouteValueDictionary PageValues(EndSessionRequest request, string : null), ]; - var values = new RouteValueDictionary - { - ["controller"] = "Account", - ["action"] = nameof(Controllers.AccountController.Logout), - }; - foreach (var (name, value) in parameters.Where(parameter => parameter.Value != null)) - values[name] = value; + var httpContext = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("The logout confirmation is formatted outside a request."); + var path = linkGenerator.GetPathByAction(httpContext, nameof(Controllers.AccountController.Logout), "Account") + ?? throw new InvalidOperationException("The logout page is not mapped."); - return values; + return QueryHelpers.AddQueryString(path, parameters.Where(parameter => parameter.Value != null)); } } } From 8d565a0277f0279519158cc492a976af6d114b75 Mon Sep 17 00:00:00 2001 From: Kirill Kovalev Date: Wed, 28 Oct 2026 15:24:56 +0300 Subject: [PATCH 10/12] Move the identity server to the current Abblix OIDC Server 3.0 development build The identity server declares its one tenant with the issuer the AppHost hands every service, resolves it with UseMultiTenancy, grants consent to eShop's own clients and implements its key and user info providers with the tenant first. --- Directory.Packages.props | 2 +- README.md | 8 +++++ .../Configuration/OidcConfiguration.cs | 17 ++++++++++ src/Identity.API/GlobalUsings.cs | 1 + src/Identity.API/Program.cs | 31 ++++++++++++++++--- .../Services/DatabaseKeysProvider.cs | 6 ++-- src/Identity.API/Services/UserInfoProvider.cs | 3 +- src/Identity.API/appsettings.json | 6 ++-- src/eShop.AppHost/AppHost.cs | 9 ++++++ .../OrderingApiFixture.cs | 5 +++ 10 files changed, 77 insertions(+), 11 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index b573d4944..a98336116 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -65,7 +65,7 @@ - + diff --git a/README.md b/README.md index 2458f554b..84ff1987d 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,14 @@ protocol endpoints and knows only where to send an unauthenticated user, which i fork was built to show. What it issues afterwards is the token behind the claims the checkout form fills itself from. +Every token names its issuer, and the services accept a token only when that issuer is exactly the +address they were given for the identity server. Abblix OIDC Server 3.0 takes the issuer from +configuration rather than from each request, so the AppHost hands `identity-api` the same +address as its `Oidc__Issuer` that it hands every other service as the identity server's. Running +`identity-api` without the AppHost means setting `Oidc__Issuer` yourself. The server answers only requests +addressed to the issuer's host, so the MAUI app in the Android emulator, which reaches your +machine as `10.0.2.2` rather than `localhost`, cannot sign in. + ### Running tests Run the server tests: diff --git a/src/Identity.API/Configuration/OidcConfiguration.cs b/src/Identity.API/Configuration/OidcConfiguration.cs index 29966a2b9..c7a558c4d 100644 --- a/src/Identity.API/Configuration/OidcConfiguration.cs +++ b/src/Identity.API/Configuration/OidcConfiguration.cs @@ -10,6 +10,23 @@ namespace eShop.Identity.API.Configuration /// public class OidcConfiguration { + /// + /// The issuer written into every token and the discovery document. + /// + /// + /// The resource servers and both web clients are given the identity server's address by the AppHost and + /// accept a token only when its issuer is exactly that string, so the AppHost hands this server the same + /// address as Oidc__Issuer. A run without the AppHost sets it to the address the clients use. + /// + public Uri Issuer { get; set; } + + /// + /// Lets the server accept an http issuer. The AppHost sets it only for its http run, made when + /// ESHOP_USE_HTTP_ENDPOINTS=1 (the Playwright journeys in CI), and the Ordering functional tests set it for + /// theirs; every other run keeps the https requirement OpenID Connect places on an issuer. + /// + public bool AllowHttpIssuer { get; set; } + public ScopeDefinition[] Scopes { get; set; } = []; /// diff --git a/src/Identity.API/GlobalUsings.cs b/src/Identity.API/GlobalUsings.cs index d46500a0b..4308b0830 100644 --- a/src/Identity.API/GlobalUsings.cs +++ b/src/Identity.API/GlobalUsings.cs @@ -9,6 +9,7 @@ global using Abblix.Jwt; global using Abblix.Oidc.Server.Common.Constants; global using Abblix.Oidc.Server.Features.ClientInformation; +global using Abblix.Oidc.Server.Features.MultiTenancy; global using Abblix.Oidc.Server.Features.UserAuthentication; global using Abblix.Oidc.Server.Features.UserInfo; global using Abblix.Oidc.Server.MinimalApi; diff --git a/src/Identity.API/Program.cs b/src/Identity.API/Program.cs index 6df3c0cf7..710bbc6d2 100644 --- a/src/Identity.API/Program.cs +++ b/src/Identity.API/Program.cs @@ -1,5 +1,7 @@ using Abblix.DependencyInjection; using Abblix.Oidc.Server.AspNetCore; +using Abblix.Oidc.Server.AspNetCore.MultiTenancy; +using Abblix.Oidc.Server.Features.Consents; using Abblix.Oidc.Server.Common.Interfaces; using Abblix.Oidc.Server.MinimalApi.Formatters.Interfaces; using Microsoft.AspNetCore.Authentication.Cookies; @@ -57,13 +59,31 @@ var oidcConfiguration = builder.Configuration.GetSection("Oidc").Get() ?? throw new InvalidOperationException("The Oidc configuration section is missing."); +// Abblix OIDC Server 3.0 serves its settings per tenant, and eShop is one: one issuer, one client registry. +// The issuer is fixed rather than taken from each request, since a request reaches the tenant only at its +// issuer's host and every token carries it. It has to equal, character for character, the address the AppHost +// hands the resource servers and the web clients as the identity server's, because they accept no other. +var issuer = oidcConfiguration.Issuer + ?? throw new InvalidOperationException( + "Oidc:Issuer is not set. Set it to the address the clients reach the identity server at."); + builder.Services.AddOidcServices(options => { - options.LoginUri = new Uri("/Account/Login", UriKind.Relative); - options.Scopes = oidcConfiguration.Scopes; - options.Clients = oidcConfiguration.ToClientInfos(); + options.AllowHttpIssuer = oidcConfiguration.AllowHttpIssuer; + options.Tenants.Add(new TenantDefinition + { + Id = "eshop", + Issuer = issuer, + LoginUri = new Uri("/Account/Login", UriKind.Relative), + Scopes = oidcConfiguration.Scopes, + Clients = oidcConfiguration.ToClientInfos(), + }); }); +// Every client registered above is part of eShop itself, so there is no third party the user would be asked to +// trust and no consent screen to show. +builder.Services.AddConsentGrantedForEveryClient(); + // A logout the user has to confirm is shown on this application's own page rather than answered with the // library's JSON body, which a browser following a client's sign-out would display as it is. builder.Services.AddHttpContextAccessor(); @@ -97,7 +117,10 @@ // This cookie policy fixes login issues with Chrome 80+ using HTTP app.UseCookiePolicy(new CookiePolicyOptions { MinimumSameSitePolicy = SameSiteMode.Lax }); -app.UseRouting(); + +// Resolves the request to its tenant and then routes it, so it takes the place of UseRouting: the server refuses +// to start without it, and refuses it placed after a routing call of the application's own. +app.UseMultiTenancy(); // The protocol endpoints carry CORS metadata, because a browser-based client reads the discovery // document and the JWKS cross-origin. Without this call every one of them answers 500: routing diff --git a/src/Identity.API/Services/DatabaseKeysProvider.cs b/src/Identity.API/Services/DatabaseKeysProvider.cs index ef06942ff..c4055a04d 100644 --- a/src/Identity.API/Services/DatabaseKeysProvider.cs +++ b/src/Identity.API/Services/DatabaseKeysProvider.cs @@ -16,10 +16,12 @@ public class DatabaseKeysProvider( IDbContextFactory contextFactory, TimeProvider clock) : IAuthServiceKeysProvider { - public IAsyncEnumerable GetEncryptionKeys(bool includePrivateKeys = false) + public IAsyncEnumerable GetEncryptionKeys(TenantDefinition tenant, bool includePrivateKeys = false) => AsyncEnumerable.Empty(); - public async IAsyncEnumerable GetSigningKeys(bool includePrivateKeys = false) + // eShop declares a single tenant, so the one key set in the table is that tenant's and the argument + // selects nothing. + public async IAsyncEnumerable GetSigningKeys(TenantDefinition tenant, bool includePrivateKeys = false) { await using var db = await contextFactory.CreateDbContextAsync(); diff --git a/src/Identity.API/Services/UserInfoProvider.cs b/src/Identity.API/Services/UserInfoProvider.cs index c3c46471f..cdba07a09 100644 --- a/src/Identity.API/Services/UserInfoProvider.cs +++ b/src/Identity.API/Services/UserInfoProvider.cs @@ -14,7 +14,8 @@ namespace eShop.Identity.API.Services /// public class UserInfoProvider(UserManager userManager) : IUserInfoProvider { - public async Task GetUserInfoAsync(AuthSession authSession, IEnumerable requestedClaims) + public async Task GetUserInfoAsync( + TenantDefinition tenant, AuthSession authSession, IEnumerable requestedClaims) { var user = await userManager.FindByIdAsync(authSession.Subject); if (user is null) diff --git a/src/Identity.API/appsettings.json b/src/Identity.API/appsettings.json index 696f1de80..53ef7a6f5 100644 --- a/src/Identity.API/appsettings.json +++ b/src/Identity.API/appsettings.json @@ -67,7 +67,7 @@ "authorization_code", "refresh_token" ], - "OfflineAccessAllowed": true, + "OfflineAccess": "Allowed", "AllowedScopes": [ "openid", "profile", @@ -82,11 +82,11 @@ // The app refreshes only minutes before its two-hour access token expires, so a refresh // token that lapses after an hour of disuse would already be dead by then. Its services // read the stored token without a lock, so one of them can present a token another has - // just spent; a single-use token would then revoke the whole chain and sign the user out. + // just spent; a rotated token would then revoke the whole chain and sign the user out. "RefreshToken": { "AbsoluteExpiresIn": "30.00:00:00", "SlidingExpiresIn": null, - "AllowReuse": true + "ReusePolicy": "Reuse" } } }, diff --git a/src/eShop.AppHost/AppHost.cs b/src/eShop.AppHost/AppHost.cs index 6650f9090..ff37edd09 100644 --- a/src/eShop.AppHost/AppHost.cs +++ b/src/eShop.AppHost/AppHost.cs @@ -28,6 +28,15 @@ .WithHttpHealthCheck("/health"); var identityEndpoint = identityApi.GetEndpoint(launchProfileName); + +// The identity server writes its issuer into every token as configured, and each service below accepts a token only +// from the address it is given here, so the identity server is given that same address as its issuer +identityApi.WithEnvironment("Oidc__Issuer", identityEndpoint); +if (launchProfileName == "http") +{ + // An issuer over http is refused unless allowed explicitly; only the http run for the Playwright tests needs it + identityApi.WithEnvironment("Oidc__AllowHttpIssuer", "true"); +} var basketApi = builder.AddDotnetProject("basket-api", "../Basket.API") .WithReference(redis) diff --git a/tests/Ordering.FunctionalTests/OrderingApiFixture.cs b/tests/Ordering.FunctionalTests/OrderingApiFixture.cs index 9ca68abf3..ad2d22bf5 100644 --- a/tests/Ordering.FunctionalTests/OrderingApiFixture.cs +++ b/tests/Ordering.FunctionalTests/OrderingApiFixture.cs @@ -23,6 +23,11 @@ public OrderingApiFixture() Postgres = appBuilder.AddPostgres("OrderingDB"); IdentityDB = appBuilder.AddPostgres("IdentityDB"); IdentityApi = appBuilder.AddProject("identity-api").WithReference(IdentityDB); + + // The identity server's issuer is the address the API under test is given as Identity:Url, over http here + IdentityApi + .WithEnvironment("Oidc__Issuer", IdentityApi.GetEndpoint("http")) + .WithEnvironment("Oidc__AllowHttpIssuer", "true"); _app = appBuilder.Build(); } From 208d242a2b5e2ef129dc31fd39334d8261b37a07 Mon Sep 17 00:00:00 2001 From: Kirill Kovalev Date: Wed, 28 Oct 2026 19:20:58 +0300 Subject: [PATCH 11/12] Serve the identity server's one tenant without UseMultiTenancy() Identity.API routes with UseRouting on Abblix 3.0.0-dev.0.731, which serves a single tenant from the options without the call. --- Directory.Packages.props | 2 +- src/Identity.API/Program.cs | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a98336116..5ff9969db 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -65,7 +65,7 @@ - + diff --git a/src/Identity.API/Program.cs b/src/Identity.API/Program.cs index 710bbc6d2..23a3dea92 100644 --- a/src/Identity.API/Program.cs +++ b/src/Identity.API/Program.cs @@ -1,6 +1,5 @@ using Abblix.DependencyInjection; using Abblix.Oidc.Server.AspNetCore; -using Abblix.Oidc.Server.AspNetCore.MultiTenancy; using Abblix.Oidc.Server.Features.Consents; using Abblix.Oidc.Server.Common.Interfaces; using Abblix.Oidc.Server.MinimalApi.Formatters.Interfaces; @@ -118,9 +117,7 @@ // This cookie policy fixes login issues with Chrome 80+ using HTTP app.UseCookiePolicy(new CookiePolicyOptions { MinimumSameSitePolicy = SameSiteMode.Lax }); -// Resolves the request to its tenant and then routes it, so it takes the place of UseRouting: the server refuses -// to start without it, and refuses it placed after a routing call of the application's own. -app.UseMultiTenancy(); +app.UseRouting(); // The protocol endpoints carry CORS metadata, because a browser-based client reads the discovery // document and the JWKS cross-origin. Without this call every one of them answers 500: routing From e0e95dd0c3849475a0fa36c08fa4667f7beb2011 Mon Sep 17 00:00:00 2001 From: Kirill Kovalev Date: Wed, 28 Oct 2026 20:34:01 +0300 Subject: [PATCH 12/12] Give the true reasons for the MAUI sign-in limit and the http run The MAUI client refuses an issuer that differs from the address it asked; the http run starts but cannot sign in. The MAUI workflow installs the global.json SDK and the .NET 10 runtime. --- .github/workflows/pr-validation-maui.yml | 9 +++++++-- README.md | 8 +++++--- src/Identity.API/Configuration/OidcConfiguration.cs | 4 ++-- src/Identity.API/Program.cs | 3 +-- src/eShop.AppHost/AppHost.cs | 3 ++- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/.github/workflows/pr-validation-maui.yml b/.github/workflows/pr-validation-maui.yml index 2f63a0943..20db746e6 100644 --- a/.github/workflows/pr-validation-maui.yml +++ b/.github/workflows/pr-validation-maui.yml @@ -21,8 +21,13 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v4 - - name: Setup .NET (global.json) - uses: actions/setup-dotnet@v3 + # global.json selects the .NET 11 SDK the repository builds with; the client app and its unit tests target + # .NET 10, whose runtime the tests run on. + - name: Setup .NET (global.json and the .NET 10 runtime) + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + global-json-file: global.json + dotnet-version: '10.0.x' - name: Update Workloads run: dotnet workload update diff --git a/README.md b/README.md index 84ff1987d..e3e976508 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,11 @@ Every token names its issuer, and the services accept a token only when that iss address they were given for the identity server. Abblix OIDC Server 3.0 takes the issuer from configuration rather than from each request, so the AppHost hands `identity-api` the same address as its `Oidc__Issuer` that it hands every other service as the identity server's. Running -`identity-api` without the AppHost means setting `Oidc__Issuer` yourself. The server answers only requests -addressed to the issuer's host, so the MAUI app in the Android emulator, which reaches your -machine as `10.0.2.2` rather than `localhost`, cannot sign in. +`identity-api` without the AppHost means setting `Oidc__Issuer` yourself. The server writes the configured issuer into +discovery and every token whatever host name a request arrives with, and the MAUI app's OpenID +client refuses a discovery document whose issuer differs from the address it asked. In the +Android emulator the app reaches your machine as `10.0.2.2` rather than `localhost`, so it +cannot sign in. ### Running tests diff --git a/src/Identity.API/Configuration/OidcConfiguration.cs b/src/Identity.API/Configuration/OidcConfiguration.cs index c7a558c4d..7a2484e73 100644 --- a/src/Identity.API/Configuration/OidcConfiguration.cs +++ b/src/Identity.API/Configuration/OidcConfiguration.cs @@ -22,8 +22,8 @@ public class OidcConfiguration /// /// Lets the server accept an http issuer. The AppHost sets it only for its http run, made when - /// ESHOP_USE_HTTP_ENDPOINTS=1 (the Playwright journeys in CI), and the Ordering functional tests set it for - /// theirs; every other run keeps the https requirement OpenID Connect places on an issuer. + /// ESHOP_USE_HTTP_ENDPOINTS=1, and the Ordering functional tests set it for theirs; every other run keeps + /// the https requirement OpenID Connect places on an issuer. /// public bool AllowHttpIssuer { get; set; } diff --git a/src/Identity.API/Program.cs b/src/Identity.API/Program.cs index 23a3dea92..53faf22ec 100644 --- a/src/Identity.API/Program.cs +++ b/src/Identity.API/Program.cs @@ -59,8 +59,7 @@ ?? throw new InvalidOperationException("The Oidc configuration section is missing."); // Abblix OIDC Server 3.0 serves its settings per tenant, and eShop is one: one issuer, one client registry. -// The issuer is fixed rather than taken from each request, since a request reaches the tenant only at its -// issuer's host and every token carries it. It has to equal, character for character, the address the AppHost +// The issuer is fixed rather than taken from each request, because every token carries it. It has to equal, character for character, the address the AppHost // hands the resource servers and the web clients as the identity server's, because they accept no other. var issuer = oidcConfiguration.Issuer ?? throw new InvalidOperationException( diff --git a/src/eShop.AppHost/AppHost.cs b/src/eShop.AppHost/AppHost.cs index ff37edd09..032d5b05b 100644 --- a/src/eShop.AppHost/AppHost.cs +++ b/src/eShop.AppHost/AppHost.cs @@ -34,7 +34,8 @@ identityApi.WithEnvironment("Oidc__Issuer", identityEndpoint); if (launchProfileName == "http") { - // An issuer over http is refused unless allowed explicitly; only the http run for the Playwright tests needs it + // An issuer over http is refused unless allowed explicitly. Only the http run needs it, so that identity-api + // starts; signing in fails in that run, because the clients are registered at their https addresses. identityApi.WithEnvironment("Oidc__AllowHttpIssuer", "true"); }