Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 7 additions & 2 deletions .github/workflows/pr-validation-maui.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions .github/workflows/pr-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.19.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.19.0" />
<!-- OpenID Provider -->
<PackageVersion Include="Abblix.Oidc.Server.Mvc" Version="2.4.0" />
<PackageVersion Include="Abblix.OIDC.Server.MinimalAPI" Version="3.0.0-dev.0.731" />
<!-- Scaffolding -->
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
<!-- Grpc -->
Expand Down
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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=<GitHub user>;Password=<token>"
```

```bash
export NuGetPackageSourceCredentials_abblix="Username=<GitHub user>;Password=<token>"
```

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
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions global.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
7 changes: 7 additions & 0 deletions nuget.config
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@
<!--To inherit the global NuGet package sources remove the <clear/> line below -->
<clear />
<add key="nuget" value="https://api.nuget.org/v3/index.json" />
<!-- Pre-release builds of the Abblix packages. GitHub Packages always requires
authentication: restore reads it from the NuGetPackageSourceCredentials_abblix
environment variable, set to Username=<GitHub user>;Password=<token with read:packages>. -->
<add key="abblix" value="https://nuget.pkg.github.com/Abblix/index.json" />
</packageSources>
<packageSourceMapping>
<!-- key value for <packageSource> should match key values from <packageSources> element -->
<clear />
<packageSource key="nuget">
<package pattern="*" />
</packageSource>
<packageSource key="abblix">
<package pattern="Abblix.*" />
</packageSource>
</packageSourceMapping>
</configuration>
17 changes: 17 additions & 0 deletions src/Identity.API/Configuration/OidcConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,23 @@ namespace eShop.Identity.API.Configuration
/// </remarks>
public class OidcConfiguration
{
/// <summary>
/// The issuer written into every token and the discovery document.
/// </summary>
/// <remarks>
/// 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 <c>Oidc__Issuer</c>. A run without the AppHost sets it to the address the clients use.
/// </remarks>
public Uri Issuer { get; set; }

/// <summary>
/// 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.
/// </summary>
public bool AllowHttpIssuer { get; set; }

public ScopeDefinition[] Scopes { get; set; } = [];

/// <summary>
Expand Down
42 changes: 38 additions & 4 deletions src/Identity.API/Controllers/AccountController.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -23,7 +24,7 @@ public class AccountController(
SignInManager<ApplicationUser> signInManager,
IAuthSessionService authSessionService,
ISessionIdGenerator sessionIdGenerator,
IUriResolver uriResolver,
IOidcEndpointResolver endpointResolver,
TimeProvider clock) : Controller
{
[HttpGet]
Expand Down Expand Up @@ -65,15 +66,48 @@ public async Task<IActionResult> 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 },
};

return Redirect(authorizeUrl);
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <see cref="LogoutConfirmationPage"/> 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.
/// </remarks>
[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();
}
Expand Down
3 changes: 2 additions & 1 deletion src/Identity.API/GlobalUsings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/Identity.API/Identity.API.csproj
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net11.0</TargetFramework>
<UserSecretsId>aspnet-eShopOnContainers.Identity-90487118-103c-4ff0-b9da-e5e26f7ab0c5</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Abblix.Oidc.Server.Mvc" />
<PackageReference Include="Abblix.OIDC.Server.MinimalAPI" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools">
Expand Down
21 changes: 21 additions & 0 deletions src/Identity.API/Models/LogoutViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace eShop.Identity.API.Models
{
public class LogoutViewModel
{
/// <summary>
/// Where the confirming request goes: the server's end-session endpoint.
/// </summary>
public Uri EndSessionEndpoint { get; set; }

/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<KeyValuePair<string, string>> Parameters { get; set; }

/// <summary>
/// Whether the user already answered and the answer was no longer accepted.
/// </summary>
public bool AskedAgain { get; set; }
}
}
39 changes: 33 additions & 6 deletions src/Identity.API/Program.cs
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -55,13 +58,35 @@
var oidcConfiguration = builder.Configuration.GetSection("Oidc").Get<OidcConfiguration>()
?? 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<IEndSessionResponseFormatter, LogoutConfirmationPage>();

// 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.
Expand Down Expand Up @@ -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
Expand All @@ -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();
6 changes: 4 additions & 2 deletions src/Identity.API/Services/DatabaseKeysProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ public class DatabaseKeysProvider(
IDbContextFactory<ApplicationDbContext> contextFactory,
TimeProvider clock) : IAuthServiceKeysProvider
{
public IAsyncEnumerable<JsonWebKey> GetEncryptionKeys(bool includePrivateKeys = false)
public IAsyncEnumerable<JsonWebKey> GetEncryptionKeys(TenantDefinition tenant, bool includePrivateKeys = false)
=> AsyncEnumerable.Empty<JsonWebKey>();

public async IAsyncEnumerable<JsonWebKey> 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<JsonWebKey> GetSigningKeys(TenantDefinition tenant, bool includePrivateKeys = false)
{
await using var db = await contextFactory.CreateDbContextAsync();

Expand Down
Loading
Loading