diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..6ffb8e6b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +OpenIddict is an open-source framework for building OAuth 2.0/OpenID Connect servers and clients in .NET. It provides a modular architecture with pluggable stores (EF Core, EF 6.x, MongoDB) and host integrations (ASP.NET Core, OWIN). + +## Build Commands + +The project uses Microsoft.DotNet.Arcade.Sdk with .NET SDK 10.0. + +```bash +# Build (Windows) +Build.cmd + +# Build (Unix/macOS) +./build.sh + +# Full CI build with tests +# Windows: +eng\common\Build.cmd -restore -build -test +# Unix: +./eng/common/build.sh -restore -build -test + +# Build specific project +dotnet build src/OpenIddict.Core/OpenIddict.Core.csproj + +# Run all tests +dotnet test + +# Run a single test project +dotnet test test/OpenIddict.Core.Tests/OpenIddict.Core.Tests.csproj + +# Run a single test by name +dotnet test test/OpenIddict.Core.Tests/OpenIddict.Core.Tests.csproj --filter "FullyQualifiedName~MyTestMethod" + +# Full CI build (restore, build, test, sign, pack) +eng\common\Build.cmd -configuration Release -ci -prepareMachine -restore -build -test -sign -pack +``` + +## Multi-Targeting + +Projects target many frameworks simultaneously: net462, net472, net48, netstandard2.0, netstandard2.1, net8.0, net9.0, net10.0, plus mobile platforms (Android, iOS, macOS, Mac Catalyst, Windows) when workloads are available. + +Conditional compilation uses `SUPPORTS_*` defines (e.g., `SUPPORTS_KEY_DERIVATION_WITH_SPECIFIED_HASH_ALGORITHM`, `SUPPORTS_CERTIFICATE_LOADER`) set in `Directory.Build.targets`. Use `#if` directives for platform-specific code paths. + +## Architecture + +### Core Abstraction Layers + +1. **OpenIddict.Abstractions** — Interfaces, constants, descriptors, store contracts, and primitive types. Everything depends on this. +2. **OpenIddict.Core** — Generic managers (`OpenIddictApplicationManager`, `OpenIddictAuthorizationManager`, `OpenIddictScopeManager`, `OpenIddictTokenManager`) and caches. Managers coordinate stores, caches, validation, and business logic. +3. **OpenIddict.Server / .Client / .Validation** — Protocol implementations using an event-driven handler/filter/dispatcher pattern. + +### Store Pattern + +Store interfaces (`IOpenIddictApplicationStore`, etc.) abstract persistence. Implementations: +- `OpenIddict.EntityFrameworkCore` — EF Core stores +- `OpenIddict.EntityFramework` — EF 6.x stores +- `OpenIddict.MongoDb` — MongoDB stores +- Corresponding `.Models` projects hold entity classes + +### Host Integration Pattern + +Each protocol component (Server, Client, Validation) has host-specific projects: +- `.AspNetCore` — ASP.NET Core middleware integration +- `.Owin` — OWIN/Katana integration for ASP.NET 4.x +- `.DataProtection` — ASP.NET Core Data Protection token formats + +### Handler/Filter Pattern + +Request processing uses event handlers guarded by filters. Handlers are registered via `IOpenIddictServerHandlers`, and filters (e.g., `RequireAccessTokenGenerated`, `RequireClientIdParameter`) control execution flow. + +### DI Registration + +Builder pattern via `OpenIddictBuilder` with extension methods in `Microsoft.Extensions.DependencyInjection` namespace. Uses `TryAddScoped`/`TryAddSingleton` for idempotent registration. + +## Code Conventions + +- C# 14, nullable reference types enabled, implicit usings enabled (`Directory.Build.props`) +- `TreatWarningsAsErrors: true` +- File-scoped namespaces throughout +- `ValueTask` for async manager methods; `CancellationToken` on all async APIs +- `SR.GetResourceString()` for localized exception messages (resource strings in Abstractions) +- Strong-name signed assemblies (key at `eng/key.snk`) +- CRLF line endings, 4-space indentation (enforced via `.editorconfig`) + +## Testing + +- xUnit with Moq +- Test projects mirror source projects under `/test/` (e.g., `OpenIddict.Core.Tests`) +- Integration tests in `*.IntegrationTests` projects +- `[Fact]` for deterministic tests, `[Theory]` + `[InlineData]` for parameterized tests +- CI runs tests on Windows, Ubuntu, and macOS + +## Key Directories + +- `/eng/` — Arcade build infrastructure, signing key +- `/gen/` — Code generators (Client WebIntegration provider generator) +- `/src/` — Source libraries (~30 projects) +- `/test/` — Test projects (~18 projects) +- `/sandbox/` — Sample/reference applications +- `/shared/OpenIddict.Extensions/` — Shared helper utilities and polyfills diff --git a/OpenIddict.slnx b/OpenIddict.slnx index a8d8cd05..6dbe5ce6 100644 --- a/OpenIddict.slnx +++ b/OpenIddict.slnx @@ -35,6 +35,7 @@ + diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/AuthorizationController.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/AuthorizationController.cs new file mode 100644 index 00000000..62ccfa0f --- /dev/null +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/AuthorizationController.cs @@ -0,0 +1,219 @@ +using System.Security.Claims; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Abstractions; +using OpenIddict.Sandbox.AspNetCore.CimdServer.Models; +using OpenIddict.Server.AspNetCore; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace OpenIddict.Sandbox.AspNetCore.CimdServer; + +public class AuthorizationController : Controller +{ + private readonly IOpenIddictApplicationManager _applicationManager; + private readonly IOpenIddictAuthorizationManager _authorizationManager; + private readonly IOpenIddictScopeManager _scopeManager; + private readonly SignInManager _signInManager; + private readonly UserManager _userManager; + + public AuthorizationController( + IOpenIddictApplicationManager applicationManager, + IOpenIddictAuthorizationManager authorizationManager, + IOpenIddictScopeManager scopeManager, + SignInManager signInManager, + UserManager userManager) + { + _applicationManager = applicationManager; + _authorizationManager = authorizationManager; + _scopeManager = scopeManager; + _signInManager = signInManager; + _userManager = userManager; + } + + [HttpGet("~/connect/authorize")] + [HttpPost("~/connect/authorize")] + [IgnoreAntiforgeryToken] + public async Task Authorize() + { + var request = HttpContext.GetOpenIddictServerRequest() ?? + throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); + + // Try to retrieve the user principal stored in the authentication cookie. + var result = await HttpContext.AuthenticateAsync(); + if (result is not { Succeeded: true }) + { + // If the user is not logged in, redirect to login. + // For this demo, we auto-sign in the test user to simplify testing. + var user = await _userManager.FindByNameAsync("testuser"); + if (user is not null) + { + await _signInManager.SignInAsync(user, isPersistent: false); + return Challenge(); + } + + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in." + })); + } + + var userEntity = await _userManager.GetUserAsync(result.Principal) ?? + throw new InvalidOperationException("The user details cannot be retrieved."); + + var application = await _applicationManager.FindByClientIdAsync(request.ClientId!) ?? + throw new InvalidOperationException("Details concerning the calling client application cannot be found."); + + // Auto-approve consent for this demonstrator. + var identity = new ClaimsIdentity( + authenticationType: TokenValidationParameters.DefaultAuthenticationType, + nameType: Claims.Name, + roleType: Claims.Role); + + identity.SetClaim(Claims.Subject, await _userManager.GetUserIdAsync(userEntity)) + .SetClaim(Claims.Email, await _userManager.GetEmailAsync(userEntity)) + .SetClaim(Claims.Name, await _userManager.GetUserNameAsync(userEntity)); + + identity.SetScopes(request.GetScopes()); + identity.SetResources(await _scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync()); + + var authorization = await _authorizationManager.CreateAsync( + identity: identity, + subject: await _userManager.GetUserIdAsync(userEntity), + client: (await _applicationManager.GetIdAsync(application))!, + type: AuthorizationTypes.Permanent, + scopes: identity.GetScopes()); + + identity.SetAuthorizationId(await _authorizationManager.GetIdAsync(authorization)); + identity.SetDestinations(GetDestinations); + + return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + [HttpPost("~/connect/token")] + [IgnoreAntiforgeryToken] + [Produces("application/json")] + public async Task Exchange() + { + var request = HttpContext.GetOpenIddictServerRequest() ?? + throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); + + if (request.IsPasswordGrantType()) + { + var user = await _userManager.FindByNameAsync(request.Username!); + if (user is null) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The username/password couple is invalid." + })); + } + + var passwordResult = await _signInManager.CheckPasswordSignInAsync(user, request.Password!, lockoutOnFailure: false); + if (!passwordResult.Succeeded) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The username/password couple is invalid." + })); + } + + var identity = new ClaimsIdentity( + authenticationType: TokenValidationParameters.DefaultAuthenticationType, + nameType: Claims.Name, + roleType: Claims.Role); + + identity.SetClaim(Claims.Subject, await _userManager.GetUserIdAsync(user)) + .SetClaim(Claims.Email, await _userManager.GetEmailAsync(user)) + .SetClaim(Claims.Name, await _userManager.GetUserNameAsync(user)); + + identity.SetScopes(request.GetScopes()); + identity.SetResources(await _scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync()); + identity.SetDestinations(GetDestinations); + + return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType()) + { + var result = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + + var user = await _userManager.FindByIdAsync(result.Principal!.GetClaim(Claims.Subject)!); + if (user is null) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The token is no longer valid." + })); + } + + if (!await _signInManager.CanSignInAsync(user)) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is no longer allowed to sign in." + })); + } + + var identity = new ClaimsIdentity(result.Principal!.Claims, + authenticationType: TokenValidationParameters.DefaultAuthenticationType, + nameType: Claims.Name, + roleType: Claims.Role); + + identity.SetClaim(Claims.Subject, await _userManager.GetUserIdAsync(user)) + .SetClaim(Claims.Email, await _userManager.GetEmailAsync(user)) + .SetClaim(Claims.Name, await _userManager.GetUserNameAsync(user)); + + identity.SetDestinations(GetDestinations); + + return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + throw new InvalidOperationException("The specified grant type is not supported."); + } + + private static IEnumerable GetDestinations(Claim claim) + { + switch (claim.Type) + { + case Claims.Name: + yield return Destinations.AccessToken; + + if (claim.Subject!.HasScope(Scopes.Profile)) + yield return Destinations.IdentityToken; + + yield break; + + case Claims.Email: + yield return Destinations.AccessToken; + + if (claim.Subject!.HasScope(Scopes.Email)) + yield return Destinations.IdentityToken; + + yield break; + + case "AspNet.Identity.SecurityStamp": yield break; + + default: + yield return Destinations.AccessToken; + yield break; + } + } +} diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/Models/ApplicationDbContext.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/Models/ApplicationDbContext.cs new file mode 100644 index 00000000..653d18c9 --- /dev/null +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/Models/ApplicationDbContext.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace OpenIddict.Sandbox.AspNetCore.CimdServer.Models; + +public class ApplicationDbContext : IdentityDbContext +{ + public ApplicationDbContext(DbContextOptions options) + : base(options) + { + } +} diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/Models/ApplicationUser.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/Models/ApplicationUser.cs new file mode 100644 index 00000000..935a0b78 --- /dev/null +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/Models/ApplicationUser.cs @@ -0,0 +1,5 @@ +using Microsoft.AspNetCore.Identity; + +namespace OpenIddict.Sandbox.AspNetCore.CimdServer.Models; + +public class ApplicationUser : IdentityUser { } diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/OpenIddict.Sandbox.AspNetCore.CimdServer.csproj b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/OpenIddict.Sandbox.AspNetCore.CimdServer.csproj new file mode 100644 index 00000000..c330ebf8 --- /dev/null +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/OpenIddict.Sandbox.AspNetCore.CimdServer.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + false + + + + + + + + + + + + + + + + + diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/Program.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/Program.cs new file mode 100644 index 00000000..88320d20 --- /dev/null +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/Program.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using OpenIddict.Sandbox.AspNetCore.CimdServer; +using OpenIddict.Sandbox.AspNetCore.CimdServer.Models; + +var builder = WebApplication.CreateBuilder(args); + +builder.WebHost.UseUrls("https://localhost:7295"); + +builder.Services.AddControllers(); + +builder.Services.AddDbContext(options => +{ + options.UseSqlite($"Filename={Path.Combine(Path.GetTempPath(), "openiddict-sandbox-aspnetcore-cimdserver.sqlite3")}"); + options.UseOpenIddict(); +}); + +builder.Services.AddIdentity() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + +builder.Services.AddOpenIddict() + .AddCore(options => + { + options.UseEntityFrameworkCore() + .UseDbContext(); + }) + .AddServer(options => + { + options.SetAuthorizationEndpointUris("connect/authorize") + .SetTokenEndpointUris("connect/token"); + + options.AllowAuthorizationCodeFlow() + .AllowPasswordFlow() + .AllowRefreshTokenFlow(); + + options.RequireProofKeyForCodeExchange(); + + options.RegisterScopes("openid", "profile", "email"); + + options.AddDevelopmentEncryptionCertificate() + .AddDevelopmentSigningCertificate(); + + options.UseAspNetCore() + .EnableAuthorizationEndpointPassthrough() + .EnableTokenEndpointPassthrough(); + }) + .AddValidation(options => + { + options.UseLocalServer(); + options.UseAspNetCore(); + }); + +builder.Services.AddHostedService(); + +var app = builder.Build(); + +app.UseDeveloperExceptionPage(); +app.UseRouting(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/Properties/launchSettings.json b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/Properties/launchSettings.json new file mode 100644 index 00000000..f0980192 --- /dev/null +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "OpenIddict.Sandbox.AspNetCore.CimdServer": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:53769;http://localhost:53770" + } + } +} \ No newline at end of file diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/Worker.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/Worker.cs new file mode 100644 index 00000000..204d3fb6 --- /dev/null +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/Worker.cs @@ -0,0 +1,80 @@ +using Microsoft.AspNetCore.Identity; +using OpenIddict.Abstractions; +using OpenIddict.Sandbox.AspNetCore.CimdServer.Models; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace OpenIddict.Sandbox.AspNetCore.CimdServer; + +public class Worker : IHostedService +{ + private readonly IServiceProvider _provider; + + public Worker(IServiceProvider provider) + => _provider = provider; + + public async Task StartAsync(CancellationToken cancellationToken) + { + await using var scope = _provider.CreateAsyncScope(); + + var context = scope.ServiceProvider.GetRequiredService(); + await context.Database.EnsureCreatedAsync(cancellationToken); + + await SeedUsersAsync(scope.ServiceProvider); + await SeedClientsAsync(scope.ServiceProvider); + } + + private static async Task SeedUsersAsync(IServiceProvider provider) + { + var userManager = provider.GetRequiredService>(); + + if (await userManager.FindByNameAsync("testuser") is null) + { + var user = new ApplicationUser + { + UserName = "testuser", + Email = "testuser@example.com" + }; + + await userManager.CreateAsync(user, "Pass123$"); + } + } + + private static async Task SeedClientsAsync(IServiceProvider provider) + { + var manager = provider.GetRequiredService(); + + // Pre-registered test client for baseline verification. + if (await manager.FindByClientIdAsync("test-client") is null) + { + await manager.CreateAsync(new OpenIddictApplicationDescriptor + { + ApplicationType = ApplicationTypes.Native, + ClientId = "test-client", + ClientType = ClientTypes.Public, + ConsentType = ConsentTypes.Systematic, + DisplayName = "Test client (pre-registered)", + RedirectUris = + { + new Uri("http://localhost/callback") + }, + Permissions = + { + Permissions.Endpoints.Authorization, + Permissions.Endpoints.Token, + Permissions.GrantTypes.AuthorizationCode, + Permissions.GrantTypes.Password, + Permissions.GrantTypes.RefreshToken, + Permissions.ResponseTypes.Code, + Permissions.Scopes.Email, + Permissions.Scopes.Profile + }, + Requirements = + { + Requirements.Features.ProofKeyForCodeExchange + } + }); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +}