diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index 61d8ac734..2938848fd 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 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
dotnet-version: '10.0.x'
- uses: actions/setup-node@v4
with:
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/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml
index 77431a0ca..8a4c7f1be 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 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
+ with:
+ global-json-file: global.json
+ dotnet-version: '10.0.x'
- name: Build
run: dotnet build eShop.Web.slnf
- name: Test
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0945a1af4..5ff9969db 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -65,7 +65,7 @@
-
+
diff --git a/README.md b/README.md
index 4e66b1ec9..e3e976508 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 [.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
@@ -66,6 +67,16 @@ 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="
+ ```
+
+ ```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
@@ -117,6 +128,16 @@ 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 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
Run the server tests:
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..d995dbb79 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/Configuration/OidcConfiguration.cs b/src/Identity.API/Configuration/OidcConfiguration.cs
index 29966a2b9..7a2484e73 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, 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/Controllers/AccountController.cs b/src/Identity.API/Controllers/AccountController.cs
index e1fccb786..47844e53d 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 },
};
@@ -74,6 +81,33 @@ 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
+ .Where(parameter => parameter.Key != LogoutConfirmationPage.AskedAgain)
+ .Select(parameter => KeyValuePair.Create(parameter.Key, parameter.Value.ToString())),
+ ],
+ AskedAgain = Request.Query.ContainsKey(LogoutConfirmationPage.AskedAgain),
+ });
+ }
+
[HttpGet]
public IActionResult AccessDenied() => View();
}
diff --git a/src/Identity.API/GlobalUsings.cs b/src/Identity.API/GlobalUsings.cs
index 521187071..4308b0830 100644
--- a/src/Identity.API/GlobalUsings.cs
+++ b/src/Identity.API/GlobalUsings.cs
@@ -9,9 +9,10 @@
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.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 96deb0cb6..c7426fd33 100644
--- a/src/Identity.API/Identity.API.csproj
+++ b/src/Identity.API/Identity.API.csproj
@@ -1,12 +1,12 @@
- net10.0
+ net11.0aspnet-eShopOnContainers.Identity-90487118-103c-4ff0-b9da-e5e26f7ab0c5
-
+
diff --git a/src/Identity.API/Models/LogoutViewModel.cs b/src/Identity.API/Models/LogoutViewModel.cs
new file mode 100644
index 000000000..1d32d7352
--- /dev/null
+++ b/src/Identity.API/Models/LogoutViewModel.cs
@@ -0,0 +1,21 @@
+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; }
+
+ ///
+ /// Whether the user already answered and the answer was no longer accepted.
+ ///
+ public bool AskedAgain { get; set; }
+ }
+}
diff --git a/src/Identity.API/Program.cs b/src/Identity.API/Program.cs
index 30ed25a54..53faf22ec 100644
--- a/src/Identity.API/Program.cs
+++ b/src/Identity.API/Program.cs
@@ -1,5 +1,8 @@
+using Abblix.DependencyInjection;
using Abblix.Oidc.Server.AspNetCore;
+using Abblix.Oidc.Server.Features.Consents;
using Abblix.Oidc.Server.Common.Interfaces;
+using Abblix.Oidc.Server.MinimalApi.Formatters.Interfaces;
using Microsoft.AspNetCore.Authentication.Cookies;
var builder = WebApplication.CreateBuilder(args);
@@ -55,13 +58,35 @@
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, 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(
+ "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();
+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.
@@ -90,6 +115,7 @@
// This cookie policy fixes login issues with Chrome 80+ using HTTP
app.UseCookiePolicy(new CookiePolicyOptions { MinimumSameSitePolicy = SameSiteMode.Lax });
+
app.UseRouting();
// The protocol endpoints carry CORS metadata, because a browser-based client reads the discovery
@@ -100,9 +126,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 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();
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/LogoutConfirmationPage.cs b/src/Identity.API/Services/LogoutConfirmationPage.cs
new file mode 100644
index 000000000..d3b872136
--- /dev/null
+++ b/src/Identity.API/Services/LogoutConfirmationPage.cs
@@ -0,0 +1,72 @@
+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,
+ IHttpContextAccessor httpContextAccessor,
+ LinkGenerator linkGenerator) : 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)
+ {
+ if (response.TryGetSuccess(out var success) && success is ConfirmationRequired confirmation)
+ return Task.FromResult(Results.Redirect(PageUrl(request, confirmation.Confirmation)));
+
+ return inner.FormatResponseAsync(request, response);
+ }
+
+ ///
+ /// The page's address with the parameters it posts back as its query.
+ ///
+ ///
+ /// 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 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),
+ 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),
+ ];
+
+ 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 QueryHelpers.AddQueryString(path, parameters.Where(parameter => parameter.Value != null));
+ }
+ }
+}
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/Views/Account/Logout.cshtml b/src/Identity.API/Views/Account/Logout.cshtml
new file mode 100644
index 000000000..2adbc88da
--- /dev/null
+++ b/src/Identity.API/Views/Account/Logout.cshtml
@@ -0,0 +1,25 @@
+@model LogoutViewModel
+
+
+
+
Logout
+ @if (Model.AskedAgain)
+ {
+
Your earlier answer has expired. Would you still like to log out?
+ }
+ else
+ {
+
Would you like to log out?
+ }
+
+
+
+
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..032d5b05b 100644
--- a/src/eShop.AppHost/AppHost.cs
+++ b/src/eShop.AppHost/AppHost.cs
@@ -28,6 +28,16 @@
.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 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");
+}
var basketApi = builder.AddDotnetProject("basket-api", "../Basket.API")
.WithReference(redis)
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.0falsefalseExe
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();
}