diff --git a/Authorization.API.Tests/ClientSecretHasherTests.cs b/Authorization.API.Tests/ClientSecretHasherTests.cs new file mode 100644 index 0000000..b77077a --- /dev/null +++ b/Authorization.API.Tests/ClientSecretHasherTests.cs @@ -0,0 +1,26 @@ +using Authorization.API.Services; + +namespace Authorization.API.Tests; + +public class ClientSecretHasherTests +{ + private readonly ClientSecretHasher _hasher = new(); + + [Fact] + public void HashAndVerify_RoundTrips() + { + var hash = _hasher.Hash("demo-secret"); + + Assert.True(_hasher.IsHashed(hash)); + Assert.True(_hasher.Verify(hash, "demo-secret")); + Assert.False(_hasher.Verify(hash, "other-secret")); + Assert.NotEqual("demo-secret", hash); + } + + [Fact] + public void Verify_AcceptsLegacyPlaintextDuringUpgrade() + { + Assert.True(_hasher.Verify("demo-secret", "demo-secret")); + Assert.False(_hasher.Verify("demo-secret", "nope")); + } +} diff --git a/Authorization.API.Tests/DatabaseInitializerTests.cs b/Authorization.API.Tests/DatabaseInitializerTests.cs index aee44ef..e276bdb 100644 --- a/Authorization.API.Tests/DatabaseInitializerTests.cs +++ b/Authorization.API.Tests/DatabaseInitializerTests.cs @@ -1,6 +1,7 @@ using Authorization.API.Context; using Authorization.API.Data; using Authorization.API.Models; +using Authorization.API.Services; using Microsoft.AspNetCore.Identity; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -23,6 +24,7 @@ public async Task SeedAsync_CreatesDemoUsersAndClient() services.AddIdentity() .AddEntityFrameworkStores() .AddDefaultTokenProviders(); + services.AddSingleton(); await using var provider = services.BuildServiceProvider(); await using var scope = provider.CreateAsyncScope(); @@ -40,7 +42,9 @@ public async Task SeedAsync_CreatesDemoUsersAndClient() Assert.NotNull(admin); Assert.NotNull(demo); Assert.True(await userManager.IsInRoleAsync(admin!, "Administrator")); - Assert.Equal(options.DemoClientSecret, client.ClientSecret); + var hasher = scope.ServiceProvider.GetRequiredService(); + Assert.True(hasher.Verify(client.ClientSecret, options.DemoClientSecret)); + Assert.NotEqual(options.DemoClientSecret, client.ClientSecret); Assert.Contains("api", client.AllowedScopes); Assert.True(await db.ApiScopes.AnyAsync(s => s.Name == "openid")); } diff --git a/Authorization.API/Controllers/AccountApiController.cs b/Authorization.API/Controllers/AccountApiController.cs index 8fb6dd7..5e7d934 100644 --- a/Authorization.API/Controllers/AccountApiController.cs +++ b/Authorization.API/Controllers/AccountApiController.cs @@ -51,7 +51,7 @@ public async Task LoginToken([FromBody] LoginModel model) return Unauthorized("Invalid email or password."); } - var accessToken = _tokenService.GenerateJwtToken(user); + var accessToken = _tokenService.GenerateJwtToken(user, await _userManager.GetRolesAsync(user)); var refreshToken = _tokenService.GenerateRefreshToken(); await _tokenService.StoreRefreshToken(refreshToken, user.Id); @@ -138,7 +138,7 @@ public async Task RefreshToken([FromBody] RefreshTokenRequest mod var newAccessToken = user == null ? _tokenService.GenerateJwtToken(client!) - : _tokenService.GenerateJwtToken(user); + : _tokenService.GenerateJwtToken(user, await _userManager.GetRolesAsync(user)); var newRefreshToken = _tokenService.GenerateRefreshToken(); await _tokenService.StoreRefreshToken(newRefreshToken, user?.Id, client?.ClientId); diff --git a/Authorization.API/Controllers/ClientsController.cs b/Authorization.API/Controllers/ClientsController.cs new file mode 100644 index 0000000..5b0faa2 --- /dev/null +++ b/Authorization.API/Controllers/ClientsController.cs @@ -0,0 +1,173 @@ +using Authorization.API.Helpers; +using Authorization.API.Models; +using Authorization.API.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Authorization.API.Controllers; + +[ApiController] +[Route("admin/clients")] +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme, Roles = "Administrator")] +public class ClientsController : ControllerBase +{ + private readonly IClientService _clients; + private readonly IClientSecretHasher _secretHasher; + + public ClientsController(IClientService clients, IClientSecretHasher secretHasher) + { + _clients = clients; + _secretHasher = secretHasher; + } + + [HttpGet] + public async Task>> List(CancellationToken cancellationToken) + { + var clients = await _clients.ListAsync(cancellationToken); + return Ok(clients.Select(ClientResponse.From)); + } + + [HttpGet("{clientId}")] + public async Task> Get(string clientId) + { + var client = await _clients.GetClientById(clientId); + if (client == null) + { + return NotFound(); + } + + return Ok(ClientResponse.From(client)); + } + + [HttpPost] + public async Task> Create( + [FromBody] ClientCreateRequest request, + CancellationToken cancellationToken) + { + if (await _clients.GetClientById(request.ClientId) != null) + { + return Conflict(new { error = "client_id already exists." }); + } + + var plaintextSecret = string.IsNullOrWhiteSpace(request.ClientSecret) + ? TokenHelper.GenerateSecureCode(32) + : request.ClientSecret; + + var client = new Client + { + ClientId = request.ClientId, + Description = request.Description, + ClientSecret = _secretHasher.Hash(plaintextSecret), + RedirectUri = request.RedirectUri, + PostLogoutRedirectUri = request.PostLogoutRedirectUri, + RequirePkce = request.RequirePkce, + AllowRefreshToken = request.AllowRefreshToken, + AllowedScopes = request.AllowedScopes + }; + + await _clients.CreateAsync(client, cancellationToken); + + var response = new ClientCreatedResponse + { + ClientId = client.ClientId, + Description = client.Description, + RedirectUri = client.RedirectUri, + PostLogoutRedirectUri = client.PostLogoutRedirectUri, + RequirePkce = client.RequirePkce, + AllowRefreshToken = client.AllowRefreshToken, + AllowedScopes = client.AllowedScopes.ToList(), + ClientSecret = plaintextSecret + }; + + return CreatedAtAction(nameof(Get), new { clientId = client.ClientId }, response); + } + + [HttpPut("{clientId}")] + public async Task> Update( + string clientId, + [FromBody] ClientUpdateRequest request, + CancellationToken cancellationToken) + { + var client = await _clients.GetClientById(clientId); + if (client == null) + { + return NotFound(); + } + + if (request.Description != null) + { + client.Description = request.Description; + } + + if (request.RedirectUri != null) + { + client.RedirectUri = request.RedirectUri; + } + + if (request.PostLogoutRedirectUri != null) + { + client.PostLogoutRedirectUri = request.PostLogoutRedirectUri; + } + + if (request.RequirePkce.HasValue) + { + client.RequirePkce = request.RequirePkce.Value; + } + + if (request.AllowRefreshToken.HasValue) + { + client.AllowRefreshToken = request.AllowRefreshToken.Value; + } + + if (request.AllowedScopes != null) + { + client.AllowedScopes = request.AllowedScopes; + } + + await _clients.UpdateAsync(client, cancellationToken); + return Ok(ClientResponse.From(client)); + } + + [HttpPost("{clientId}/secret")] + public async Task> RotateSecret( + string clientId, + CancellationToken cancellationToken) + { + var client = await _clients.GetClientById(clientId); + if (client == null) + { + return NotFound(); + } + + var plaintextSecret = TokenHelper.GenerateSecureCode(32); + client.ClientSecret = _secretHasher.Hash(plaintextSecret); + await _clients.UpdateAsync(client, cancellationToken); + + var response = ClientResponse.From(client); + return Ok(new ClientCreatedResponse + { + ClientId = response.ClientId, + Description = response.Description, + RedirectUri = response.RedirectUri, + PostLogoutRedirectUri = response.PostLogoutRedirectUri, + RequirePkce = response.RequirePkce, + AllowRefreshToken = response.AllowRefreshToken, + AllowedScopes = response.AllowedScopes, + ClientSecret = plaintextSecret + }); + } + + [HttpDelete("{clientId}")] + public async Task Delete(string clientId, CancellationToken cancellationToken) + { + var client = await _clients.GetClientById(clientId); + if (client == null) + { + return NotFound(); + } + + await _clients.DeleteAsync(client, cancellationToken); + return NoContent(); + } +} diff --git a/Authorization.API/Controllers/TokenController.cs b/Authorization.API/Controllers/TokenController.cs index 4767726..1c1b3b7 100644 --- a/Authorization.API/Controllers/TokenController.cs +++ b/Authorization.API/Controllers/TokenController.cs @@ -2,6 +2,7 @@ using Authorization.API.Helpers; using Authorization.API.Models; using Authorization.API.Services; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -13,11 +14,19 @@ public class TokenController : ControllerBase { private readonly ApplicationDbContext _context; private readonly ITokenService _tokenService; - - public TokenController(ApplicationDbContext context, ITokenService tokenService) + private readonly IClientSecretHasher _secretHasher; + private readonly UserManager _userManager; + + public TokenController( + ApplicationDbContext context, + ITokenService tokenService, + IClientSecretHasher secretHasher, + UserManager userManager) { _context = context; _tokenService = tokenService; + _secretHasher = secretHasher; + _userManager = userManager; } [HttpPost] @@ -72,13 +81,18 @@ private async Task HandleAuthorizationCodeGrant(TokenRequest requ await _context.SaveChangesAsync(); ApplicationUser? user = null; + IList? roles = null; if (!string.IsNullOrEmpty(authCode.UserId)) { user = await _context.Users.FindAsync(authCode.UserId); + if (user != null) + { + roles = await _userManager.GetRolesAsync(user); + } } var scopes = SplitScopes(authCode.Scopes); - var accessToken = _tokenService.GenerateAccessToken(authCode.Subject, authCode.ClientId, scopes, user); + var accessToken = _tokenService.GenerateAccessToken(authCode.Subject, authCode.ClientId, scopes, user, roles); string? refreshToken = null; if (client.AllowRefreshToken) @@ -120,7 +134,8 @@ private async Task HandleRefreshTokenGrant(TokenRequest request) var scopes = request.Scope is null ? null : SplitScopes(request.Scope); var subject = storedToken.User?.Id ?? storedToken.UserId ?? storedToken.ClientId ?? request.ClientId; - var accessToken = _tokenService.GenerateAccessToken(subject, storedToken.ClientId ?? request.ClientId, scopes, storedToken.User); + IList? roles = storedToken.User == null ? null : await _userManager.GetRolesAsync(storedToken.User); + var accessToken = _tokenService.GenerateAccessToken(subject, storedToken.ClientId ?? request.ClientId, scopes, storedToken.User, roles); var newRefreshToken = _tokenService.GenerateRefreshToken(); await _tokenService.StoreRefreshToken(newRefreshToken, storedToken.UserId, request.ClientId); @@ -164,7 +179,7 @@ private async Task HandleClientCredentialsGrant(TokenRequest requ } var client = await _context.Clients.SingleOrDefaultAsync(c => c.ClientId == request.ClientId); - if (client == null || !TokenHelper.SecretsEqual(client.ClientSecret, request.ClientSecret)) + if (client == null || !_secretHasher.Verify(client.ClientSecret, request.ClientSecret)) { return null; } diff --git a/Authorization.API/Data/DatabaseInitializer.cs b/Authorization.API/Data/DatabaseInitializer.cs index e97a998..8bae1b8 100644 --- a/Authorization.API/Data/DatabaseInitializer.cs +++ b/Authorization.API/Data/DatabaseInitializer.cs @@ -1,5 +1,7 @@ using Authorization.API.Context; +using Authorization.API.Data; using Authorization.API.Models; +using Authorization.API.Services; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -85,6 +87,7 @@ public static async Task SeedAsync( var roleManager = services.GetRequiredService>(); var userManager = services.GetRequiredService>(); var db = services.GetRequiredService(); + var secretHasher = services.GetRequiredService(); foreach (var roleName in new[] { "Administrator", "User" }) { @@ -117,7 +120,7 @@ public static async Task SeedAsync( { ClientId = options.DemoClientId, Description = "Local demo confidential client", - ClientSecret = options.DemoClientSecret, + ClientSecret = secretHasher.Hash(options.DemoClientSecret), RedirectUri = options.DemoRedirectUri, RequirePkce = true, AllowRefreshToken = true, @@ -125,6 +128,14 @@ public static async Task SeedAsync( }); } + foreach (var client in await db.Clients.ToListAsync(cancellationToken)) + { + if (!string.IsNullOrEmpty(client.ClientSecret) && !secretHasher.IsHashed(client.ClientSecret)) + { + client.ClientSecret = secretHasher.Hash(client.ClientSecret); + } + } + await db.SaveChangesAsync(cancellationToken); logger.LogInformation("Development seed data is in place."); } diff --git a/Authorization.API/Models/ClientDtos.cs b/Authorization.API/Models/ClientDtos.cs new file mode 100644 index 0000000..4ae8092 --- /dev/null +++ b/Authorization.API/Models/ClientDtos.cs @@ -0,0 +1,65 @@ +using System.ComponentModel.DataAnnotations; + +namespace Authorization.API.Models; + +public class ClientCreateRequest +{ + [Required] + public string ClientId { get; set; } = string.Empty; + + public string? Description { get; set; } + + public string? ClientSecret { get; set; } + + public string? RedirectUri { get; set; } + + public string? PostLogoutRedirectUri { get; set; } + + public bool RequirePkce { get; set; } = true; + + public bool AllowRefreshToken { get; set; } = true; + + public List AllowedScopes { get; set; } = []; +} + +public class ClientUpdateRequest +{ + public string? Description { get; set; } + + public string? RedirectUri { get; set; } + + public string? PostLogoutRedirectUri { get; set; } + + public bool? RequirePkce { get; set; } + + public bool? AllowRefreshToken { get; set; } + + public List? AllowedScopes { get; set; } +} + +public class ClientResponse +{ + public string ClientId { get; set; } = string.Empty; + public string? Description { get; set; } + public string? RedirectUri { get; set; } + public string? PostLogoutRedirectUri { get; set; } + public bool RequirePkce { get; set; } + public bool AllowRefreshToken { get; set; } + public IReadOnlyList AllowedScopes { get; set; } = []; + + public static ClientResponse From(Client client) => new() + { + ClientId = client.ClientId, + Description = client.Description, + RedirectUri = client.RedirectUri, + PostLogoutRedirectUri = client.PostLogoutRedirectUri, + RequirePkce = client.RequirePkce, + AllowRefreshToken = client.AllowRefreshToken, + AllowedScopes = client.AllowedScopes.ToList() + }; +} + +public class ClientCreatedResponse : ClientResponse +{ + public string ClientSecret { get; set; } = string.Empty; +} diff --git a/Authorization.API/Program.cs b/Authorization.API/Program.cs index 1c7b02f..b284d67 100644 --- a/Authorization.API/Program.cs +++ b/Authorization.API/Program.cs @@ -8,6 +8,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Scalar.AspNetCore; +using System.Security.Claims; using System.Text; var builder = WebApplication.CreateBuilder(args); @@ -58,6 +59,7 @@ }); builder.Services.AddSingleton(_ => new EncryptionService(encryptionKey)); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -80,6 +82,7 @@ ValidateIssuerSigningKey = true, ValidIssuer = jwtSettings["Issuer"], ValidAudience = jwtSettings["Audience"], + RoleClaimType = ClaimTypes.Role, ClockSkew = TimeSpan.Zero }; }); diff --git a/Authorization.API/Services/ClientSecretHasher.cs b/Authorization.API/Services/ClientSecretHasher.cs new file mode 100644 index 0000000..881a0a7 --- /dev/null +++ b/Authorization.API/Services/ClientSecretHasher.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Identity; + +namespace Authorization.API.Services; + +public interface IClientSecretHasher +{ + string Hash(string secret); + bool Verify(string hashedSecret, string? providedSecret); + bool IsHashed(string value); +} + +public class ClientSecretHasher : IClientSecretHasher +{ + private static readonly object Sentinel = new(); + private readonly PasswordHasher _hasher = new(); + + public string Hash(string secret) + { + ArgumentException.ThrowIfNullOrWhiteSpace(secret); + return _hasher.HashPassword(Sentinel, secret); + } + + public bool Verify(string hashedSecret, string? providedSecret) + { + if (string.IsNullOrEmpty(hashedSecret) || string.IsNullOrEmpty(providedSecret)) + { + return false; + } + + if (!IsHashed(hashedSecret)) + { + return TokenHelperEquals(hashedSecret, providedSecret); + } + + var result = _hasher.VerifyHashedPassword(Sentinel, hashedSecret, providedSecret); + return result != PasswordVerificationResult.Failed; + } + + public bool IsHashed(string value) + { + return !string.IsNullOrEmpty(value) + && value.StartsWith("AQAAAA", StringComparison.Ordinal); + } + + private static bool TokenHelperEquals(string stored, string provided) + { + return Helpers.TokenHelper.SecretsEqual(stored, provided); + } +} diff --git a/Authorization.API/Services/ClientService.cs b/Authorization.API/Services/ClientService.cs index 2b2cd6e..6d4f163 100644 --- a/Authorization.API/Services/ClientService.cs +++ b/Authorization.API/Services/ClientService.cs @@ -4,45 +4,51 @@ namespace Authorization.API.Services; -/// -/// The client service interface. -/// public interface IClientService { - /// - /// Get client by id. - /// - /// The client id. - /// ]]> Task GetClientById(string clientId); + Task> ListAsync(CancellationToken cancellationToken = default); + Task CreateAsync(Client client, CancellationToken cancellationToken = default); + Task UpdateAsync(Client client, CancellationToken cancellationToken = default); + Task DeleteAsync(Client client, CancellationToken cancellationToken = default); } -/// -/// The client service. -/// public class ClientService : IClientService { - /// - /// The db context. - /// private readonly ApplicationDbContext _dbContext; - /// - /// Initializes a new instance of the class. - /// - /// The db context. + public ClientService(ApplicationDbContext dbContext) { _dbContext = dbContext; } - /// - /// Get client by id. - /// - /// The client id. - /// ]]> public async Task GetClientById(string clientId) { return await _dbContext.Clients.FirstOrDefaultAsync(u => u.ClientId == clientId); } + public async Task> ListAsync(CancellationToken cancellationToken = default) + { + return await _dbContext.Clients + .OrderBy(c => c.ClientId) + .ToListAsync(cancellationToken); + } + + public async Task CreateAsync(Client client, CancellationToken cancellationToken = default) + { + _dbContext.Clients.Add(client); + await _dbContext.SaveChangesAsync(cancellationToken); + return client; + } + + public async Task UpdateAsync(Client client, CancellationToken cancellationToken = default) + { + await _dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteAsync(Client client, CancellationToken cancellationToken = default) + { + _dbContext.Clients.Remove(client); + await _dbContext.SaveChangesAsync(cancellationToken); + } } diff --git a/Authorization.API/Services/TokenService.cs b/Authorization.API/Services/TokenService.cs index f4a5c5e..ccb13f0 100644 --- a/Authorization.API/Services/TokenService.cs +++ b/Authorization.API/Services/TokenService.cs @@ -20,9 +20,9 @@ Task GenerateAuthorizationCode( string? codeChallenge, string? codeChallengeMethod); - string GenerateAccessToken(string subject, string clientId, IEnumerable? scopes = null, ApplicationUser? user = null); + string GenerateAccessToken(string subject, string clientId, IEnumerable? scopes = null, ApplicationUser? user = null, IEnumerable? roles = null); - string GenerateJwtToken(ApplicationUser user); + string GenerateJwtToken(ApplicationUser user, IEnumerable? roles = null); string GenerateJwtToken(Client client); @@ -88,7 +88,7 @@ public async Task GenerateAuthorizationCode( return code; } - public string GenerateAccessToken(string subject, string clientId, IEnumerable? scopes = null, ApplicationUser? user = null) + public string GenerateAccessToken(string subject, string clientId, IEnumerable? scopes = null, ApplicationUser? user = null, IEnumerable? roles = null) { var claims = new List { @@ -108,6 +108,8 @@ public string GenerateAccessToken(string subject, string clientId, IEnumerable? roles = null) { var claims = new List { @@ -145,6 +147,8 @@ public string GenerateJwtToken(ApplicationUser user) claims.Add(new Claim(ClaimTypes.Email, user.Email)); } + AddRoleClaims(claims, roles); + return GenerateJwtToken(claims); } @@ -332,4 +336,20 @@ public async Task ValidateRefreshToken(string refreshToken, string? userId return null; } } + + private static void AddRoleClaims(List claims, IEnumerable? roles) + { + if (roles == null) + { + return; + } + + foreach (var role in roles) + { + if (!string.IsNullOrWhiteSpace(role)) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + } + } + } } diff --git a/README.md b/README.md index a8cb21e..b51fafc 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,34 @@ Set secrets with environment variables or user secrets, not production config fi Production should set `Database__MigrateOnStartup` and `Seed__Enabled` to `false` unless you intentionally want boot-time migration. +## Admin client APIs + +Register and rotate OAuth clients without writing SQL. These endpoints require a JWT for a user in the `Administrator` role (the seeded `admin@localhost` user). + +```bash +# 1. Sign in as admin +curl -s -X POST http://localhost:8080/account/login/token \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@localhost","password":"Admin123!"}' + +# 2. Create a client (the plaintext secret is returned once) +curl -X POST http://localhost:8080/admin/clients \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"clientId":"spa","redirectUri":"http://localhost:3000/callback","allowedScopes":["openid","api"]}' +``` + +| Method | Path | Purpose | +| --- | --- | --- | +| GET | `/admin/clients` | List clients (secrets never returned) | +| GET | `/admin/clients/{id}` | Get one client | +| POST | `/admin/clients` | Create client; secret is hashed at rest | +| PUT | `/admin/clients/{id}` | Update metadata and scopes | +| POST | `/admin/clients/{id}/secret` | Rotate secret | +| DELETE | `/admin/clients/{id}` | Delete client | + +Client secrets are stored with ASP.NET Identity's password hasher. Existing plaintext secrets are hashed automatically on the next startup seed. + ## Example: authorization code + PKCE ```text