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/ConsentServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using Authorization.API.Context;
using Authorization.API.Models;
using Authorization.API.Services;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

namespace Authorization.API.Tests;

public class ConsentServiceTests
{
[Fact]
public async Task HasConsentAsync_RequiresAllRequestedScopes()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
await using var db = new ApplicationDbContext(new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(connection).Options);
await db.Database.EnsureCreatedAsync();

var consents = new ConsentService(db);
Assert.False(await consents.HasConsentAsync("u1", "spa", ["openid", "api"]));

await consents.GrantAsync("u1", "spa", ["openid", "profile"]);
Assert.True(await consents.HasConsentAsync("u1", "spa", ["openid"]));
Assert.False(await consents.HasConsentAsync("u1", "spa", ["openid", "api"]));
}
}
3 changes: 2 additions & 1 deletion Authorization.API.Tests/DatabaseInitializerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ public async Task SeedAsync_CreatesDemoUsersAndClient()
Assert.NotNull(demo);
Assert.True(await userManager.IsInRoleAsync(admin!, "Administrator"));
var hasher = scope.ServiceProvider.GetRequiredService<IClientSecretHasher>();
Assert.True(hasher.Verify(client.ClientSecret, options.DemoClientSecret));
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"));
Assert.True(await db.Clients.AnyAsync(c => c.ClientId == "demo-spa" && !c.RequireClientSecret));
}
}
55 changes: 55 additions & 0 deletions Authorization.API.Tests/RefreshTokenRotationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using Authorization.API.Context;
using Authorization.API.Models;
using Authorization.API.Services;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;

namespace Authorization.API.Tests;

public class RefreshTokenRotationTests
{
[Fact]
public async Task RotateRefreshToken_DetectsReuseAndRevokesFamily()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();

var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;

await using var db = new ApplicationDbContext(options);
await db.Database.EnsureCreatedAsync();

db.Clients.Add(new Client
{
ClientId = "spa",
RequireClientSecret = false,
RequirePkce = true
});
await db.SaveChangesAsync();

var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:SecretKey"] = "DEV_ONLY_JWT_SIGNING_KEY_MUST_BE_LONG_ENOUGH_32",
["Jwt:Issuer"] = "test",
["Jwt:Audience"] = "test"
}).Build();

var tokens = new TokenService(config, db);
var first = tokens.GenerateRefreshToken();
await tokens.StoreRefreshToken(first, clientId: "spa");

var rotated = await tokens.RotateRefreshToken(first, clientId: "spa");
Assert.False(rotated.IsInvalid);
Assert.False(string.IsNullOrEmpty(rotated.NewRefreshToken));

var reuse = await tokens.RotateRefreshToken(first, clientId: "spa");
Assert.True(reuse.ReuseDetected);

var familyStillValid = await tokens.RotateRefreshToken(rotated.NewRefreshToken, clientId: "spa");
Assert.True(familyStillValid.IsInvalid);
Assert.True(familyStillValid.ReuseDetected);
}
}
17 changes: 15 additions & 2 deletions Authorization.API/Context/ApplicationDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)

public DbSet<ApiResource> ApiResources { get; set; }

public DbSet<UserConsent> UserConsents { get; set; }

protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
Expand All @@ -33,8 +35,12 @@ protected override void OnModelCreating(ModelBuilder builder)
.ToTable("Roles");

builder.Entity<Client>()
.HasIndex(c => c.ClientId)
.IsUnique();
.Property(c => c.RequireClientSecret)
.HasDefaultValue(true);

builder.Entity<Client>()
.Property(c => c.RequireConsent)
.HasDefaultValue(true);

builder.Entity<AuthorizationCode>()
.HasIndex(ac => ac.Code)
Expand All @@ -60,6 +66,13 @@ protected override void OnModelCreating(ModelBuilder builder)
.HasForeignKey(rt => rt.ClientId)
.OnDelete(DeleteBehavior.Cascade);

builder.Entity<RefreshToken>()
.HasIndex(r => r.FamilyId);

builder.Entity<UserConsent>()
.HasIndex(c => new { c.UserId, c.ClientId })
.IsUnique();

builder.Entity<ApiScope>()
.HasIndex(s => s.Name)
.IsUnique();
Expand Down
25 changes: 13 additions & 12 deletions Authorization.API/Controllers/AccountApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,25 +128,26 @@ public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest mod
}
}

var tokenIsValid = await _tokenService.ValidateRefreshToken(model.RefreshToken, model.UserId, model.ClientId);
if (!tokenIsValid)
var rotation = await _tokenService.RotateRefreshToken(model.RefreshToken, model.UserId, model.ClientId);
if (rotation.IsInvalid)
{
return Unauthorized("Invalid refresh token.");
return Unauthorized(rotation.ReuseDetected
? "Refresh token reuse detected."
: "Invalid refresh token.");
}

await _tokenService.RevokeRefreshToken(model.RefreshToken, model.UserId, model.ClientId);

var newAccessToken = user == null
? _tokenService.GenerateJwtToken(client!)
: _tokenService.GenerateJwtToken(user, await _userManager.GetRolesAsync(user));
var newRefreshToken = _tokenService.GenerateRefreshToken();

await _tokenService.StoreRefreshToken(newRefreshToken, user?.Id, client?.ClientId);
var newAccessToken = rotation.User == null && client != null
? _tokenService.GenerateJwtToken(client)
: rotation.User != null
? _tokenService.GenerateJwtToken(rotation.User, await _userManager.GetRolesAsync(rotation.User))
: user != null
? _tokenService.GenerateJwtToken(user, await _userManager.GetRolesAsync(user))
: _tokenService.GenerateJwtToken(client!);

return Ok(new TokenResponse
{
AccessToken = newAccessToken,
RefreshToken = newRefreshToken,
RefreshToken = rotation.NewRefreshToken,
TokenType = "Bearer",
ExpiresIn = _tokenService.GetAccessTokenExpirySeconds()
});
Expand Down
48 changes: 32 additions & 16 deletions Authorization.API/Controllers/AuthorizeController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,18 @@ public class AuthorizeController : Controller
{
private readonly IClientService _clientService;
private readonly ITokenService _tokenService;
private readonly IConsentService _consentService;
private readonly UserManager<ApplicationUser> _userManager;

public AuthorizeController(
IClientService clientService,
ITokenService tokenService,
IConsentService consentService,
UserManager<ApplicationUser> userManager)
{
_clientService = clientService;
_tokenService = tokenService;
_consentService = consentService;
_userManager = userManager;
}

Expand Down Expand Up @@ -54,7 +57,8 @@ public async Task<IActionResult> Authorize(
return BadRequest("Invalid redirect URI.");
}

if (client.RequirePkce && string.IsNullOrWhiteSpace(code_challenge))
var pkceRequired = client.RequirePkce || !client.RequireClientSecret;
if (pkceRequired && string.IsNullOrWhiteSpace(code_challenge))
{
return RedirectWithError(redirect_uri, "invalid_request", "PKCE is required.", state);
}
Expand All @@ -65,24 +69,24 @@ public async Task<IActionResult> Authorize(
return RedirectWithError(redirect_uri, "invalid_request", "Only S256 PKCE is supported.", state);
}

var authorizeQuery = new Dictionary<string, string?>
{
["response_type"] = response_type,
["client_id"] = client_id,
["redirect_uri"] = redirect_uri,
["scope"] = scope,
["state"] = state,
["code_challenge"] = code_challenge,
["code_challenge_method"] = code_challenge_method
};

var cookieAuth = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme);
if (!cookieAuth.Succeeded)
{
var challengeProperties = new AuthenticationProperties
return Challenge(new AuthenticationProperties
{
RedirectUri = QueryHelpers.AddQueryString("/authorize", new Dictionary<string, string?>
{
["response_type"] = response_type,
["client_id"] = client_id,
["redirect_uri"] = redirect_uri,
["scope"] = scope,
["state"] = state,
["code_challenge"] = code_challenge,
["code_challenge_method"] = code_challenge_method
})
};

return Challenge(challengeProperties, IdentityConstants.ApplicationScheme);
RedirectUri = QueryHelpers.AddQueryString("/authorize", authorizeQuery)
}, IdentityConstants.ApplicationScheme);
}

var user = await _userManager.GetUserAsync(cookieAuth.Principal);
Expand All @@ -91,12 +95,24 @@ public async Task<IActionResult> Authorize(
return Unauthorized("User is not authenticated.");
}

var requestedScopes = ConsentService.Split(scope);
if (requestedScopes.Count == 0)
{
requestedScopes = client.AllowedScopes.ToList();
}

if (client.RequireConsent
&& !await _consentService.HasConsentAsync(user.Id, client.ClientId, requestedScopes))
{
return Redirect(QueryHelpers.AddQueryString("/consent", authorizeQuery));
}

var authorizationCode = await _tokenService.GenerateAuthorizationCode(
client_id,
user.Id,
user.Id,
redirect_uri,
scope,
string.Join(' ', requestedScopes),
code_challenge,
code_challenge_method);

Expand Down
38 changes: 33 additions & 5 deletions Authorization.API/Controllers/ClientsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,22 @@ public async Task<ActionResult<ClientCreatedResponse>> Create(
return Conflict(new { error = "client_id already exists." });
}

var plaintextSecret = string.IsNullOrWhiteSpace(request.ClientSecret)
? TokenHelper.GenerateSecureCode(32)
: request.ClientSecret;
var plaintextSecret = request.RequireClientSecret
? (string.IsNullOrWhiteSpace(request.ClientSecret)
? TokenHelper.GenerateSecureCode(32)
: request.ClientSecret)
: null;

var client = new Client
{
ClientId = request.ClientId,
Description = request.Description,
ClientSecret = _secretHasher.Hash(plaintextSecret),
ClientSecret = plaintextSecret == null ? null : _secretHasher.Hash(plaintextSecret),
RedirectUri = request.RedirectUri,
PostLogoutRedirectUri = request.PostLogoutRedirectUri,
RequirePkce = request.RequirePkce,
RequirePkce = request.RequireClientSecret ? request.RequirePkce : true,
RequireClientSecret = request.RequireClientSecret,
RequireConsent = request.RequireConsent,
AllowRefreshToken = request.AllowRefreshToken,
AllowedScopes = request.AllowedScopes
};
Expand All @@ -75,6 +79,8 @@ public async Task<ActionResult<ClientCreatedResponse>> Create(
RedirectUri = client.RedirectUri,
PostLogoutRedirectUri = client.PostLogoutRedirectUri,
RequirePkce = client.RequirePkce,
RequireClientSecret = client.RequireClientSecret,
RequireConsent = client.RequireConsent,
AllowRefreshToken = client.AllowRefreshToken,
AllowedScopes = client.AllowedScopes.ToList(),
ClientSecret = plaintextSecret
Expand Down Expand Up @@ -115,6 +121,21 @@ public async Task<ActionResult<ClientResponse>> Update(
client.RequirePkce = request.RequirePkce.Value;
}

if (request.RequireClientSecret.HasValue)
{
client.RequireClientSecret = request.RequireClientSecret.Value;
if (!client.RequireClientSecret)
{
client.RequirePkce = true;
client.ClientSecret = null;
}
}

if (request.RequireConsent.HasValue)
{
client.RequireConsent = request.RequireConsent.Value;
}

if (request.AllowRefreshToken.HasValue)
{
client.AllowRefreshToken = request.AllowRefreshToken.Value;
Expand All @@ -140,6 +161,11 @@ public async Task<ActionResult<ClientCreatedResponse>> RotateSecret(
return NotFound();
}

if (!client.RequireClientSecret)
{
return BadRequest(new { error = "public_client", error_description = "Public clients do not have a secret." });
}

var plaintextSecret = TokenHelper.GenerateSecureCode(32);
client.ClientSecret = _secretHasher.Hash(plaintextSecret);
await _clients.UpdateAsync(client, cancellationToken);
Expand All @@ -152,6 +178,8 @@ public async Task<ActionResult<ClientCreatedResponse>> RotateSecret(
RedirectUri = response.RedirectUri,
PostLogoutRedirectUri = response.PostLogoutRedirectUri,
RequirePkce = response.RequirePkce,
RequireClientSecret = response.RequireClientSecret,
RequireConsent = response.RequireConsent,
AllowRefreshToken = response.AllowRefreshToken,
AllowedScopes = response.AllowedScopes,
ClientSecret = plaintextSecret
Expand Down
Loading