Skip to content
Open
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
26 changes: 26 additions & 0 deletions Authorization.API.Tests/ClientSecretHasherTests.cs
Original file line number Diff line number Diff line change
@@ -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"));
}
}
6 changes: 5 additions & 1 deletion Authorization.API.Tests/DatabaseInitializerTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -23,6 +24,7 @@ public async Task SeedAsync_CreatesDemoUsersAndClient()
services.AddIdentity<ApplicationUser, ApplicationRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
services.AddSingleton<IClientSecretHasher, ClientSecretHasher>();

await using var provider = services.BuildServiceProvider();
await using var scope = provider.CreateAsyncScope();
Expand All @@ -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<IClientSecretHasher>();
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"));
}
Expand Down
4 changes: 2 additions & 2 deletions Authorization.API/Controllers/AccountApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public async Task<IActionResult> 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);

Expand Down Expand Up @@ -138,7 +138,7 @@ public async Task<IActionResult> 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);
Expand Down
173 changes: 173 additions & 0 deletions Authorization.API/Controllers/ClientsController.cs
Original file line number Diff line number Diff line change
@@ -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<ActionResult<IEnumerable<ClientResponse>>> List(CancellationToken cancellationToken)
{
var clients = await _clients.ListAsync(cancellationToken);
return Ok(clients.Select(ClientResponse.From));
}

[HttpGet("{clientId}")]
public async Task<ActionResult<ClientResponse>> Get(string clientId)
{
var client = await _clients.GetClientById(clientId);
if (client == null)
{
return NotFound();
}

return Ok(ClientResponse.From(client));
}

[HttpPost]
public async Task<ActionResult<ClientCreatedResponse>> 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<ActionResult<ClientResponse>> 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<ActionResult<ClientCreatedResponse>> 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<IActionResult> Delete(string clientId, CancellationToken cancellationToken)
{
var client = await _clients.GetClientById(clientId);
if (client == null)
{
return NotFound();
}

await _clients.DeleteAsync(client, cancellationToken);
return NoContent();
}
}
25 changes: 20 additions & 5 deletions Authorization.API/Controllers/TokenController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<ApplicationUser> _userManager;

public TokenController(
ApplicationDbContext context,
ITokenService tokenService,
IClientSecretHasher secretHasher,
UserManager<ApplicationUser> userManager)
{
_context = context;
_tokenService = tokenService;
_secretHasher = secretHasher;
_userManager = userManager;
}

[HttpPost]
Expand Down Expand Up @@ -72,13 +81,18 @@ private async Task<IActionResult> HandleAuthorizationCodeGrant(TokenRequest requ
await _context.SaveChangesAsync();

ApplicationUser? user = null;
IList<string>? 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)
Expand Down Expand Up @@ -120,7 +134,8 @@ private async Task<IActionResult> 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<string>? 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);
Expand Down Expand Up @@ -164,7 +179,7 @@ private async Task<IActionResult> 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;
}
Expand Down
13 changes: 12 additions & 1 deletion Authorization.API/Data/DatabaseInitializer.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -85,6 +87,7 @@ public static async Task SeedAsync(
var roleManager = services.GetRequiredService<RoleManager<ApplicationRole>>();
var userManager = services.GetRequiredService<UserManager<ApplicationUser>>();
var db = services.GetRequiredService<ApplicationDbContext>();
var secretHasher = services.GetRequiredService<IClientSecretHasher>();

foreach (var roleName in new[] { "Administrator", "User" })
{
Expand Down Expand Up @@ -117,14 +120,22 @@ 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,
AllowedScopes = ["openid", "profile", "email", "api"]
});
}

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.");
}
Expand Down
Loading