diff --git a/Authorization.API.Tests/ConsentServiceTests.cs b/Authorization.API.Tests/ConsentServiceTests.cs new file mode 100644 index 0000000..bbc8466 --- /dev/null +++ b/Authorization.API.Tests/ConsentServiceTests.cs @@ -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().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"])); + } +} diff --git a/Authorization.API.Tests/DatabaseInitializerTests.cs b/Authorization.API.Tests/DatabaseInitializerTests.cs index e276bdb..fff19af 100644 --- a/Authorization.API.Tests/DatabaseInitializerTests.cs +++ b/Authorization.API.Tests/DatabaseInitializerTests.cs @@ -43,9 +43,10 @@ public async Task SeedAsync_CreatesDemoUsersAndClient() Assert.NotNull(demo); Assert.True(await userManager.IsInRoleAsync(admin!, "Administrator")); var hasher = scope.ServiceProvider.GetRequiredService(); - 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)); } } diff --git a/Authorization.API.Tests/RefreshTokenRotationTests.cs b/Authorization.API.Tests/RefreshTokenRotationTests.cs new file mode 100644 index 0000000..5141d26 --- /dev/null +++ b/Authorization.API.Tests/RefreshTokenRotationTests.cs @@ -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() + .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 + { + ["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); + } +} diff --git a/Authorization.API/Context/ApplicationDbContext.cs b/Authorization.API/Context/ApplicationDbContext.cs index bf60a2e..8cfb288 100644 --- a/Authorization.API/Context/ApplicationDbContext.cs +++ b/Authorization.API/Context/ApplicationDbContext.cs @@ -22,6 +22,8 @@ public ApplicationDbContext(DbContextOptions options) public DbSet ApiResources { get; set; } + public DbSet UserConsents { get; set; } + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); @@ -33,8 +35,12 @@ protected override void OnModelCreating(ModelBuilder builder) .ToTable("Roles"); builder.Entity() - .HasIndex(c => c.ClientId) - .IsUnique(); + .Property(c => c.RequireClientSecret) + .HasDefaultValue(true); + + builder.Entity() + .Property(c => c.RequireConsent) + .HasDefaultValue(true); builder.Entity() .HasIndex(ac => ac.Code) @@ -60,6 +66,13 @@ protected override void OnModelCreating(ModelBuilder builder) .HasForeignKey(rt => rt.ClientId) .OnDelete(DeleteBehavior.Cascade); + builder.Entity() + .HasIndex(r => r.FamilyId); + + builder.Entity() + .HasIndex(c => new { c.UserId, c.ClientId }) + .IsUnique(); + builder.Entity() .HasIndex(s => s.Name) .IsUnique(); diff --git a/Authorization.API/Controllers/AccountApiController.cs b/Authorization.API/Controllers/AccountApiController.cs index 5e7d934..ba7a0f8 100644 --- a/Authorization.API/Controllers/AccountApiController.cs +++ b/Authorization.API/Controllers/AccountApiController.cs @@ -128,25 +128,26 @@ public async Task 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() }); diff --git a/Authorization.API/Controllers/AuthorizeController.cs b/Authorization.API/Controllers/AuthorizeController.cs index 3933b8e..ebd4a6f 100644 --- a/Authorization.API/Controllers/AuthorizeController.cs +++ b/Authorization.API/Controllers/AuthorizeController.cs @@ -13,15 +13,18 @@ public class AuthorizeController : Controller { private readonly IClientService _clientService; private readonly ITokenService _tokenService; + private readonly IConsentService _consentService; private readonly UserManager _userManager; public AuthorizeController( IClientService clientService, ITokenService tokenService, + IConsentService consentService, UserManager userManager) { _clientService = clientService; _tokenService = tokenService; + _consentService = consentService; _userManager = userManager; } @@ -54,7 +57,8 @@ public async Task 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); } @@ -65,24 +69,24 @@ public async Task Authorize( return RedirectWithError(redirect_uri, "invalid_request", "Only S256 PKCE is supported.", state); } + var authorizeQuery = new Dictionary + { + ["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 - { - ["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); @@ -91,12 +95,24 @@ public async Task 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); diff --git a/Authorization.API/Controllers/ClientsController.cs b/Authorization.API/Controllers/ClientsController.cs index 5b0faa2..15749e3 100644 --- a/Authorization.API/Controllers/ClientsController.cs +++ b/Authorization.API/Controllers/ClientsController.cs @@ -50,18 +50,22 @@ public async Task> 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 }; @@ -75,6 +79,8 @@ public async Task> 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 @@ -115,6 +121,21 @@ public async Task> 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; @@ -140,6 +161,11 @@ public async Task> 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); @@ -152,6 +178,8 @@ public async Task> RotateSecret( RedirectUri = response.RedirectUri, PostLogoutRedirectUri = response.PostLogoutRedirectUri, RequirePkce = response.RequirePkce, + RequireClientSecret = response.RequireClientSecret, + RequireConsent = response.RequireConsent, AllowRefreshToken = response.AllowRefreshToken, AllowedScopes = response.AllowedScopes, ClientSecret = plaintextSecret diff --git a/Authorization.API/Controllers/ConsentController.cs b/Authorization.API/Controllers/ConsentController.cs new file mode 100644 index 0000000..986faab --- /dev/null +++ b/Authorization.API/Controllers/ConsentController.cs @@ -0,0 +1,115 @@ +using Authorization.API.Models; +using Authorization.API.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.WebUtilities; + +namespace Authorization.API.Controllers; + +[Route("consent")] +public class ConsentController : Controller +{ + private readonly IClientService _clients; + private readonly IConsentService _consents; + private readonly UserManager _userManager; + + public ConsentController( + IClientService clients, + IConsentService consents, + UserManager userManager) + { + _clients = clients; + _consents = consents; + _userManager = userManager; + } + + [HttpGet] + [ApiExplorerSettings(IgnoreApi = true)] + public async Task Index( + [FromQuery] string client_id, + [FromQuery] string redirect_uri, + [FromQuery] string? scope, + [FromQuery] string? state, + [FromQuery] string? code_challenge, + [FromQuery] string? code_challenge_method) + { + var auth = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); + if (!auth.Succeeded) + { + return Challenge(IdentityConstants.ApplicationScheme); + } + + var client = await _clients.GetClientById(client_id); + if (client == null) + { + return BadRequest("Invalid client."); + } + + var scopes = ConsentService.Split(scope); + if (scopes.Count == 0) + { + scopes = client.AllowedScopes.ToList(); + } + + return View(new ConsentViewModel + { + ClientId = client.ClientId, + ClientName = client.Description ?? client.ClientId, + RedirectUri = redirect_uri, + Scope = string.Join(' ', scopes), + State = state, + CodeChallenge = code_challenge, + CodeChallengeMethod = code_challenge_method, + Scopes = scopes + }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [ApiExplorerSettings(IgnoreApi = true)] + public async Task Index( + string clientId, + string redirectUri, + string? scope, + string? state, + string? codeChallenge, + string? codeChallengeMethod, + string decision) + { + var auth = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); + if (!auth.Succeeded) + { + return Challenge(IdentityConstants.ApplicationScheme); + } + + var user = await _userManager.GetUserAsync(auth.Principal); + if (user == null) + { + return Unauthorized(); + } + + if (string.Equals(decision, "deny", StringComparison.OrdinalIgnoreCase)) + { + return Redirect(QueryHelpers.AddQueryString(redirectUri, new Dictionary + { + ["error"] = "access_denied", + ["state"] = state + })); + } + + var scopes = ConsentService.Split(scope); + await _consents.GrantAsync(user.Id, clientId, scopes); + + return Redirect(QueryHelpers.AddQueryString("/authorize", new Dictionary + { + ["response_type"] = "code", + ["client_id"] = clientId, + ["redirect_uri"] = redirectUri, + ["scope"] = scope, + ["state"] = state, + ["code_challenge"] = codeChallenge, + ["code_challenge_method"] = codeChallengeMethod + })); + } +} diff --git a/Authorization.API/Controllers/TokenController.cs b/Authorization.API/Controllers/TokenController.cs index 1c1b3b7..209795f 100644 --- a/Authorization.API/Controllers/TokenController.cs +++ b/Authorization.API/Controllers/TokenController.cs @@ -69,7 +69,7 @@ private async Task HandleAuthorizationCodeGrant(TokenRequest requ return BadRequest(new { error = "invalid_grant", error_description = "redirect_uri mismatch." }); } - if (client.RequirePkce || !string.IsNullOrEmpty(authCode.CodeChallenge)) + if (client.RequirePkce || !client.RequireClientSecret || !string.IsNullOrEmpty(authCode.CodeChallenge)) { if (!PkceHelper.Validate(request.CodeVerifier, authCode.CodeChallenge, authCode.CodeChallengeMethod)) { @@ -120,28 +120,21 @@ private async Task HandleRefreshTokenGrant(TokenRequest request) return BadRequest(new { error = "invalid_request", error_description = "refresh_token is required." }); } - var hashedToken = TokenHelper.HashToken(request.RefreshToken); - var storedToken = await _context.RefreshTokens - .Include(t => t.User) - .SingleOrDefaultAsync(t => t.Token == hashedToken && t.ClientId == request.ClientId && !t.IsRevoked); - - if (storedToken == null || storedToken.Expiry <= DateTime.UtcNow) + var rotation = await _tokenService.RotateRefreshToken(request.RefreshToken, clientId: request.ClientId); + if (rotation.IsInvalid) { - return BadRequest(new { error = "invalid_grant", error_description = "Invalid or expired refresh token." }); + var description = rotation.ReuseDetected + ? "Refresh token reuse detected." + : "Invalid or expired refresh token."; + return BadRequest(new { error = "invalid_grant", error_description = description }); } - storedToken.IsRevoked = true; - var scopes = request.Scope is null ? null : SplitScopes(request.Scope); - var subject = storedToken.User?.Id ?? storedToken.UserId ?? storedToken.ClientId ?? request.ClientId; - 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); - await _context.SaveChangesAsync(); + var subject = rotation.User?.Id ?? rotation.UserId ?? rotation.ClientId; + IList? roles = rotation.User == null ? null : await _userManager.GetRolesAsync(rotation.User); + var accessToken = _tokenService.GenerateAccessToken(subject, rotation.ClientId, scopes, rotation.User, roles); - return Ok(CreateTokenResponse(accessToken, newRefreshToken)); + return Ok(CreateTokenResponse(accessToken, rotation.NewRefreshToken)); } private async Task HandleClientCredentialsGrant(TokenRequest request) @@ -152,6 +145,11 @@ private async Task HandleClientCredentialsGrant(TokenRequest requ return Unauthorized(new { error = "invalid_client" }); } + if (!client.RequireClientSecret) + { + return Unauthorized(new { error = "invalid_client", error_description = "Public clients cannot use client_credentials." }); + } + IEnumerable? scopes = null; if (!string.IsNullOrWhiteSpace(request.Scope)) { @@ -179,11 +177,20 @@ private async Task HandleClientCredentialsGrant(TokenRequest requ } var client = await _context.Clients.SingleOrDefaultAsync(c => c.ClientId == request.ClientId); - if (client == null || !_secretHasher.Verify(client.ClientSecret, request.ClientSecret)) + if (client == null) { return null; } + if (client.RequireClientSecret) + { + if (string.IsNullOrEmpty(client.ClientSecret) + || !_secretHasher.Verify(client.ClientSecret, request.ClientSecret)) + { + return null; + } + } + return client; } diff --git a/Authorization.API/Data/DatabaseInitializer.cs b/Authorization.API/Data/DatabaseInitializer.cs index 8bae1b8..e58aea9 100644 --- a/Authorization.API/Data/DatabaseInitializer.cs +++ b/Authorization.API/Data/DatabaseInitializer.cs @@ -123,6 +123,24 @@ public static async Task SeedAsync( ClientSecret = secretHasher.Hash(options.DemoClientSecret), RedirectUri = options.DemoRedirectUri, RequirePkce = true, + RequireClientSecret = true, + RequireConsent = true, + AllowRefreshToken = true, + AllowedScopes = ["openid", "profile", "email", "api"] + }); + } + + if (!await db.Clients.AnyAsync(c => c.ClientId == "demo-spa", cancellationToken)) + { + db.Clients.Add(new Client + { + ClientId = "demo-spa", + Description = "Local demo public SPA client", + ClientSecret = null, + RedirectUri = options.DemoRedirectUri, + RequirePkce = true, + RequireClientSecret = false, + RequireConsent = true, AllowRefreshToken = true, AllowedScopes = ["openid", "profile", "email", "api"] }); diff --git a/Authorization.API/Migrations/20260826142311_ConsentPublicClientsAndTokenFamilies.Designer.cs b/Authorization.API/Migrations/20260826142311_ConsentPublicClientsAndTokenFamilies.Designer.cs new file mode 100644 index 0000000..d6fba66 --- /dev/null +++ b/Authorization.API/Migrations/20260826142311_ConsentPublicClientsAndTokenFamilies.Designer.cs @@ -0,0 +1,529 @@ +// +using System; +using Authorization.API.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Authorization.API.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260826142311_ConsentPublicClientsAndTokenFamilies")] + partial class ConsentPublicClientsAndTokenFamilies + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Authorization.API.Models.ApiResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ApiResources"); + }); + + modelBuilder.Entity("Authorization.API.Models.ApiScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApiResourceId") + .HasColumnType("int"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("ApiResourceId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiScopes"); + }); + + modelBuilder.Entity("Authorization.API.Models.ApplicationRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("Authorization.API.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FullName") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Authorization.API.Models.AuthorizationCode", b => + { + b.Property("Code") + .HasColumnType("nvarchar(450)"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("CodeChallenge") + .HasColumnType("nvarchar(max)"); + + b.Property("CodeChallengeMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsUsed") + .HasColumnType("bit"); + + b.Property("RedirectUri") + .HasColumnType("nvarchar(max)"); + + b.Property("Scopes") + .HasColumnType("nvarchar(max)"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Code"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ClientId", "UserId"); + + b.ToTable("AuthorizationCodes"); + }); + + modelBuilder.Entity("Authorization.API.Models.Client", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllowRefreshToken") + .HasColumnType("bit"); + + b.PrimitiveCollection("AllowedScopes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("PostLogoutRedirectUri") + .HasColumnType("nvarchar(max)"); + + b.Property("RedirectUri") + .HasColumnType("nvarchar(max)"); + + b.Property("RequireClientSecret") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("RequireConsent") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("RequirePkce") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("Clients"); + }); + + modelBuilder.Entity("Authorization.API.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("nvarchar(450)"); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("Expiry") + .HasColumnType("datetime2"); + + b.Property("FamilyId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("IsRevoked") + .HasColumnType("bit"); + + b.Property("Token") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Authorization.API.Models.UserConsent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Scopes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ClientId") + .IsUnique(); + + b.ToTable("UserConsents"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Authorization.API.Models.ApiScope", b => + { + b.HasOne("Authorization.API.Models.ApiResource", null) + .WithMany("Scopes") + .HasForeignKey("ApiResourceId"); + }); + + modelBuilder.Entity("Authorization.API.Models.RefreshToken", b => + { + b.HasOne("Authorization.API.Models.Client", "Client") + .WithMany() + .HasForeignKey("ClientId") + .HasPrincipalKey("ClientId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Authorization.API.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Client"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Authorization.API.Models.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Authorization.API.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Authorization.API.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Authorization.API.Models.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Authorization.API.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Authorization.API.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Authorization.API.Models.ApiResource", b => + { + b.Navigation("Scopes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Authorization.API/Migrations/20260826142311_ConsentPublicClientsAndTokenFamilies.cs b/Authorization.API/Migrations/20260826142311_ConsentPublicClientsAndTokenFamilies.cs new file mode 100644 index 0000000..4cbcd71 --- /dev/null +++ b/Authorization.API/Migrations/20260826142311_ConsentPublicClientsAndTokenFamilies.cs @@ -0,0 +1,117 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Authorization.API.Migrations +{ + /// + public partial class ConsentPublicClientsAndTokenFamilies : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Clients_ClientId", + table: "Clients"); + + migrationBuilder.AddColumn( + name: "FamilyId", + table: "RefreshTokens", + type: "nvarchar(450)", + nullable: false, + defaultValue: ""); + + migrationBuilder.Sql( + "UPDATE RefreshTokens SET FamilyId = REPLACE(CONVERT(nvarchar(36), NEWID()), '-', '') WHERE FamilyId = ''"); + + migrationBuilder.AlterColumn( + name: "ClientSecret", + table: "Clients", + type: "nvarchar(max)", + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + + migrationBuilder.AddColumn( + name: "RequireClientSecret", + table: "Clients", + type: "bit", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "RequireConsent", + table: "Clients", + type: "bit", + nullable: false, + defaultValue: true); + + migrationBuilder.CreateTable( + name: "UserConsents", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "nvarchar(450)", nullable: false), + ClientId = table.Column(type: "nvarchar(450)", nullable: false), + Scopes = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserConsents", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_FamilyId", + table: "RefreshTokens", + column: "FamilyId"); + + migrationBuilder.CreateIndex( + name: "IX_UserConsents_UserId_ClientId", + table: "UserConsents", + columns: new[] { "UserId", "ClientId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserConsents"); + + migrationBuilder.DropIndex( + name: "IX_RefreshTokens_FamilyId", + table: "RefreshTokens"); + + migrationBuilder.DropColumn( + name: "FamilyId", + table: "RefreshTokens"); + + migrationBuilder.DropColumn( + name: "RequireClientSecret", + table: "Clients"); + + migrationBuilder.DropColumn( + name: "RequireConsent", + table: "Clients"); + + migrationBuilder.AlterColumn( + name: "ClientSecret", + table: "Clients", + type: "nvarchar(max)", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Clients_ClientId", + table: "Clients", + column: "ClientId", + unique: true); + } + } +} diff --git a/Authorization.API/Migrations/ApplicationDbContextModelSnapshot.cs b/Authorization.API/Migrations/ApplicationDbContextModelSnapshot.cs index da4baa7..544039d 100644 --- a/Authorization.API/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Authorization.API/Migrations/ApplicationDbContextModelSnapshot.cs @@ -229,7 +229,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("nvarchar(450)"); b.Property("ClientSecret") - .IsRequired() .HasColumnType("nvarchar(max)"); b.Property("Description") @@ -241,14 +240,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RedirectUri") .HasColumnType("nvarchar(max)"); + b.Property("RequireClientSecret") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("RequireConsent") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + b.Property("RequirePkce") .HasColumnType("bit"); b.HasKey("Id"); - b.HasIndex("ClientId") - .IsUnique(); - b.ToTable("Clients"); }); @@ -269,6 +275,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Expiry") .HasColumnType("datetime2"); + b.Property("FamilyId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + b.Property("IsRevoked") .HasColumnType("bit"); @@ -283,6 +293,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ClientId"); + b.HasIndex("FamilyId"); + b.HasIndex("Token") .IsUnique(); @@ -291,6 +303,37 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("RefreshTokens"); }); + modelBuilder.Entity("Authorization.API.Models.UserConsent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Scopes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ClientId") + .IsUnique(); + + b.ToTable("UserConsents"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.Property("Id") diff --git a/Authorization.API/Models/Client.cs b/Authorization.API/Models/Client.cs index b29ef6c..b74569d 100644 --- a/Authorization.API/Models/Client.cs +++ b/Authorization.API/Models/Client.cs @@ -12,8 +12,7 @@ public class Client public string? Description { get; set; } - [Required] - public string ClientSecret { get; set; } = string.Empty; + public string? ClientSecret { get; set; } public string? RedirectUri { get; set; } @@ -21,6 +20,10 @@ public class Client public bool RequirePkce { get; set; } = true; + public bool RequireClientSecret { get; set; } = true; + + public bool RequireConsent { get; set; } = true; + public bool AllowRefreshToken { get; set; } = true; public ICollection AllowedScopes { get; set; } = new List(); diff --git a/Authorization.API/Models/ClientDtos.cs b/Authorization.API/Models/ClientDtos.cs index 4ae8092..bd0c9ee 100644 --- a/Authorization.API/Models/ClientDtos.cs +++ b/Authorization.API/Models/ClientDtos.cs @@ -17,6 +17,10 @@ public class ClientCreateRequest public bool RequirePkce { get; set; } = true; + public bool RequireClientSecret { get; set; } = true; + + public bool RequireConsent { get; set; } = true; + public bool AllowRefreshToken { get; set; } = true; public List AllowedScopes { get; set; } = []; @@ -32,6 +36,10 @@ public class ClientUpdateRequest public bool? RequirePkce { get; set; } + public bool? RequireClientSecret { get; set; } + + public bool? RequireConsent { get; set; } + public bool? AllowRefreshToken { get; set; } public List? AllowedScopes { get; set; } @@ -44,6 +52,8 @@ public class ClientResponse public string? RedirectUri { get; set; } public string? PostLogoutRedirectUri { get; set; } public bool RequirePkce { get; set; } + public bool RequireClientSecret { get; set; } + public bool RequireConsent { get; set; } public bool AllowRefreshToken { get; set; } public IReadOnlyList AllowedScopes { get; set; } = []; @@ -54,6 +64,8 @@ public class ClientResponse RedirectUri = client.RedirectUri, PostLogoutRedirectUri = client.PostLogoutRedirectUri, RequirePkce = client.RequirePkce, + RequireClientSecret = client.RequireClientSecret, + RequireConsent = client.RequireConsent, AllowRefreshToken = client.AllowRefreshToken, AllowedScopes = client.AllowedScopes.ToList() }; @@ -61,5 +73,6 @@ public class ClientResponse public class ClientCreatedResponse : ClientResponse { - public string ClientSecret { get; set; } = string.Empty; + [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + public string? ClientSecret { get; set; } } diff --git a/Authorization.API/Models/ConsentViewModel.cs b/Authorization.API/Models/ConsentViewModel.cs new file mode 100644 index 0000000..82390af --- /dev/null +++ b/Authorization.API/Models/ConsentViewModel.cs @@ -0,0 +1,29 @@ +using Authorization.API.Models; + +namespace Authorization.API.Models; + +public class ConsentViewModel +{ + public string ClientId { get; set; } = string.Empty; + public string ClientName { get; set; } = string.Empty; + public string RedirectUri { get; set; } = string.Empty; + public string? Scope { get; set; } + public string? State { get; set; } + public string? CodeChallenge { get; set; } + public string? CodeChallengeMethod { get; set; } + public IReadOnlyList Scopes { get; set; } = []; +} + +public sealed class RefreshTokenRotationResult +{ + public static RefreshTokenRotationResult Missing { get; } = new() { IsInvalid = true }; + public static RefreshTokenRotationResult Reused { get; } = new() { ReuseDetected = true, IsInvalid = true }; + + public bool IsInvalid { get; init; } + public bool ReuseDetected { get; init; } + public ApplicationUser? User { get; init; } + public string? UserId { get; init; } + public string ClientId { get; init; } = string.Empty; + public string FamilyId { get; init; } = string.Empty; + public string NewRefreshToken { get; init; } = string.Empty; +} diff --git a/Authorization.API/Models/RefreshToken.cs b/Authorization.API/Models/RefreshToken.cs index 2fd9046..d09ac50 100644 --- a/Authorization.API/Models/RefreshToken.cs +++ b/Authorization.API/Models/RefreshToken.cs @@ -18,6 +18,8 @@ public class RefreshToken public bool IsRevoked { get; set; } + public string FamilyId { get; set; } = Guid.NewGuid().ToString("N"); + public DateTime Created { get; set; } = DateTime.UtcNow; public ApplicationUser? User { get; set; } diff --git a/Authorization.API/Models/UserConsent.cs b/Authorization.API/Models/UserConsent.cs new file mode 100644 index 0000000..14fa301 --- /dev/null +++ b/Authorization.API/Models/UserConsent.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Authorization.API.Models; + +public class UserConsent +{ + public int Id { get; set; } + + [Required] + public string UserId { get; set; } = string.Empty; + + [Required] + public string ClientId { get; set; } = string.Empty; + + public string Scopes { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/Authorization.API/Program.cs b/Authorization.API/Program.cs index b284d67..10046c9 100644 --- a/Authorization.API/Program.cs +++ b/Authorization.API/Program.cs @@ -62,6 +62,7 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)); builder.Services.AddAuthentication(options => diff --git a/Authorization.API/Services/ConsentService.cs b/Authorization.API/Services/ConsentService.cs new file mode 100644 index 0000000..4a9aa07 --- /dev/null +++ b/Authorization.API/Services/ConsentService.cs @@ -0,0 +1,79 @@ +using Authorization.API.Context; +using Authorization.API.Models; +using Microsoft.EntityFrameworkCore; + +namespace Authorization.API.Services; + +public interface IConsentService +{ + Task HasConsentAsync(string userId, string clientId, IEnumerable requestedScopes, CancellationToken cancellationToken = default); + Task GrantAsync(string userId, string clientId, IEnumerable scopes, CancellationToken cancellationToken = default); +} + +public class ConsentService : IConsentService +{ + private readonly ApplicationDbContext _db; + + public ConsentService(ApplicationDbContext db) + { + _db = db; + } + + public async Task HasConsentAsync( + string userId, + string clientId, + IEnumerable requestedScopes, + CancellationToken cancellationToken = default) + { + var granted = await _db.UserConsents + .AsNoTracking() + .SingleOrDefaultAsync(c => c.UserId == userId && c.ClientId == clientId, cancellationToken); + + if (granted == null) + { + return false; + } + + var grantedScopes = Split(granted.Scopes); + return requestedScopes.All(scope => grantedScopes.Contains(scope)); + } + + public async Task GrantAsync( + string userId, + string clientId, + IEnumerable scopes, + CancellationToken cancellationToken = default) + { + var existing = await _db.UserConsents + .SingleOrDefaultAsync(c => c.UserId == userId && c.ClientId == clientId, cancellationToken); + + var scopeValue = string.Join(' ', scopes.Distinct(StringComparer.Ordinal)); + if (existing == null) + { + _db.UserConsents.Add(new UserConsent + { + UserId = userId, + ClientId = clientId, + Scopes = scopeValue, + CreatedAt = DateTime.UtcNow + }); + } + else + { + existing.Scopes = scopeValue; + existing.CreatedAt = DateTime.UtcNow; + } + + await _db.SaveChangesAsync(cancellationToken); + } + + public static List Split(string? scope) + { + if (string.IsNullOrWhiteSpace(scope)) + { + return []; + } + + return [.. scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; + } +} diff --git a/Authorization.API/Services/TokenService.cs b/Authorization.API/Services/TokenService.cs index ccb13f0..85346a9 100644 --- a/Authorization.API/Services/TokenService.cs +++ b/Authorization.API/Services/TokenService.cs @@ -34,7 +34,9 @@ Task GenerateAuthorizationCode( int GetAccessTokenExpirySeconds(); - Task StoreRefreshToken(string refreshToken, string? userId = null, string? clientId = null); + Task StoreRefreshToken(string refreshToken, string? userId = null, string? clientId = null, string? familyId = null); + + Task RotateRefreshToken(string refreshToken, string? userId = null, string? clientId = null); Task ValidateRefreshToken(string refreshToken, string? userId = null, string? clientId = null); @@ -210,7 +212,7 @@ public string GenerateRefreshToken() return TokenHelper.GenerateSecureCode(64); } - public async Task StoreRefreshToken(string refreshToken, string? userId = null, string? clientId = null) + public async Task StoreRefreshToken(string refreshToken, string? userId = null, string? clientId = null, string? familyId = null) { if (string.IsNullOrWhiteSpace(userId) && string.IsNullOrWhiteSpace(clientId)) { @@ -224,13 +226,79 @@ public async Task StoreRefreshToken(string refreshToken, string? userId = null, IsRevoked = false, Created = DateTime.UtcNow, UserId = userId, - ClientId = clientId + ClientId = clientId, + FamilyId = string.IsNullOrWhiteSpace(familyId) ? Guid.NewGuid().ToString("N") : familyId }; await _dbContext.RefreshTokens.AddAsync(refreshTokenEntity); await _dbContext.SaveChangesAsync(); } + public async Task RotateRefreshToken(string refreshToken, string? userId = null, string? clientId = null) + { + var hashed = TokenHelper.HashToken(refreshToken); + var query = _dbContext.RefreshTokens.Include(rt => rt.User).Where(rt => rt.Token == hashed); + + if (!string.IsNullOrWhiteSpace(userId)) + { + query = query.Where(rt => rt.UserId == userId); + } + + if (!string.IsNullOrWhiteSpace(clientId)) + { + query = query.Where(rt => rt.ClientId == clientId); + } + + var stored = await query.FirstOrDefaultAsync(); + if (stored == null) + { + return RefreshTokenRotationResult.Missing; + } + + if (stored.IsRevoked) + { + await RevokeFamilyAsync(stored.FamilyId); + return RefreshTokenRotationResult.Reused; + } + + if (stored.Expiry <= DateTime.UtcNow) + { + return RefreshTokenRotationResult.Missing; + } + + stored.IsRevoked = true; + var next = GenerateRefreshToken(); + await StoreRefreshToken(next, stored.UserId, stored.ClientId, stored.FamilyId); + + return new RefreshTokenRotationResult + { + User = stored.User, + UserId = stored.UserId, + ClientId = stored.ClientId ?? clientId ?? string.Empty, + FamilyId = stored.FamilyId, + NewRefreshToken = next + }; + } + + private async Task RevokeFamilyAsync(string familyId) + { + if (string.IsNullOrEmpty(familyId)) + { + return; + } + + var family = await _dbContext.RefreshTokens + .Where(rt => rt.FamilyId == familyId && !rt.IsRevoked) + .ToListAsync(); + + foreach (var token in family) + { + token.IsRevoked = true; + } + + await _dbContext.SaveChangesAsync(); + } + public async Task RevokeRefreshToken(string refreshToken, string? userId = null, string? clientId = null) { var hashed = TokenHelper.HashToken(refreshToken); diff --git a/Authorization.API/Views/Consent/Index.cshtml b/Authorization.API/Views/Consent/Index.cshtml new file mode 100644 index 0000000..e41ff71 --- /dev/null +++ b/Authorization.API/Views/Consent/Index.cshtml @@ -0,0 +1,83 @@ +@model Authorization.API.Models.ConsentViewModel +@{ + Layout = null; +} + + + + + + Authorize application + + + +
+

Authorize @Model.ClientName

+

This application is requesting access to:

+
    + @foreach (var scope in Model.Scopes) + { +
  • @scope
  • + } +
+
+ @Html.AntiForgeryToken() + + + + + + +
+ + +
+
+
+ + diff --git a/README.md b/README.md index b51fafc..1d484e3 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ The API applies EF Core migrations and seeds demo data on startup. | Admin user | `admin@localhost` / `Admin123!` | | Demo user | `demo@example.com` / `Password1!` | | Confidential client | `demo-client` / `demo-secret` | +| Public SPA client | `demo-spa` (no secret; PKCE required) | | Redirect URI | `http://localhost:3000/callback` | Change these before any shared or production use. @@ -103,6 +104,10 @@ curl -X POST http://localhost:8080/admin/clients \ Client secrets are stored with ASP.NET Identity's password hasher. Existing plaintext secrets are hashed automatically on the next startup seed. +Public clients (`requireClientSecret: false`) skip the secret, require PKCE, and cannot use the client credentials grant. After login, users see a consent screen the first time a client requests scopes. + +Refresh tokens rotate on every use. Presenting a previously rotated token revokes the entire token family. + ## Example: authorization code + PKCE ```text