diff --git a/Source/Vinder.Federation.Application/Contracts/IAuthorizationFlowHandler.cs b/Source/Vinder.Federation.Application/Contracts/IAuthorizationFlowHandler.cs new file mode 100644 index 0000000..f7f51b1 --- /dev/null +++ b/Source/Vinder.Federation.Application/Contracts/IAuthorizationFlowHandler.cs @@ -0,0 +1,9 @@ +namespace Vinder.Federation.Application.Contracts; + +public interface IAuthorizationFlowHandler +{ + public Task> HandleAsync( + ClientAuthenticationCredentials parameters, + CancellationToken cancellation = default + ); +} diff --git a/Source/Vinder.Federation.Application/Handlers/Authorization/AuthorizationCodeGrantHandler.cs b/Source/Vinder.Federation.Application/Handlers/Authorization/AuthorizationCodeGrantHandler.cs new file mode 100644 index 0000000..8b6380e --- /dev/null +++ b/Source/Vinder.Federation.Application/Handlers/Authorization/AuthorizationCodeGrantHandler.cs @@ -0,0 +1,69 @@ +namespace Vinder.Federation.Application.Handlers.Authorization; + +public sealed class AuthorizationCodeGrantHandler(ITenantCollection tenantCollection, IUserCollection userCollection, ISecurityTokenService tokenService, ITokenCollection tokenCollection) : IAuthorizationFlowHandler +{ + public async Task> HandleAsync( + ClientAuthenticationCredentials parameters, CancellationToken cancellation = default) + { + var filters = new TokenFiltersBuilder() + .WithValue(parameters.Code) + .WithType(TokenType.AuthorizationCode) + .Build(); + + var tokens = await tokenCollection.GetTokensAsync(filters, cancellation: cancellation); + var token = tokens.FirstOrDefault(); + + if (token is null) + { + return Result.Failure(AuthorizationErrors.InvalidAuthorizationCode); + } + + if (token.IsExpired) + { + return Result.Failure(AuthorizationErrors.AuthorizationCodeExpired); + } + + var tenantFilters = new TenantFiltersBuilder() + .WithIdentifier(token.TenantId) + .Build(); + + var tenants = await tenantCollection.GetTenantsAsync(tenantFilters, cancellation: cancellation); + var tenant = tenants.FirstOrDefault(); + + if (tenant is null) + { + return Result.Failure(AuthenticationErrors.ClientNotFound); + } + + var codeChallenge = token.Metadata.GetValueOrDefault("code.challenge")!; + var codeChallengeMethod = token.Metadata.GetValueOrDefault("code.challenge.method")!; + + if (!PkceCodeVerifier.Validate(parameters.CodeVerifier, codeChallenge, codeChallengeMethod)) + { + return Result.Failure(AuthorizationErrors.InvalidCodeVerifier); + } + + var userFilters = new UserFiltersBuilder() + .WithIdentifier(token.UserId) + .Build(); + + var users = await userCollection.GetUsersAsync(userFilters, cancellation: cancellation); + var user = users.FirstOrDefault(); + + if (user is null) + { + return Result.Failure(AuthenticationErrors.UserNotFound); + } + + var tokenResult = await tokenService.GenerateAccessTokenAsync(user, cancellation); + if (tokenResult.IsFailure || tokenResult.Data is null) + { + return Result.Failure(tokenResult.Error); + } + + return Result.Success(new() + { + AccessToken = tokenResult.Data.Value + }); + } +} diff --git a/Source/Vinder.Federation.Application/Handlers/Authorization/AuthorizationHandler.cs b/Source/Vinder.Federation.Application/Handlers/Authorization/AuthorizationHandler.cs new file mode 100644 index 0000000..641bbaf --- /dev/null +++ b/Source/Vinder.Federation.Application/Handlers/Authorization/AuthorizationHandler.cs @@ -0,0 +1,34 @@ +namespace Vinder.Federation.Application.Handlers.Authorization; + +public sealed class AuthorizationHandler(ITenantCollection tenantCollection, IRedirectUriPolicy redirectUriPolicy) : + IMessageHandler> +{ + public async Task> HandleAsync( + AuthorizationParameters parameters, CancellationToken cancellation = default) + { + var filters = new TenantFiltersBuilder() + .WithClientId(parameters.ClientId) + .Build(); + + var clients = await tenantCollection.GetTenantsAsync(filters, cancellation); + var client = clients.FirstOrDefault(); + + if (client is null) + { + return Result.Failure(TenantErrors.TenantDoesNotExist); + } + + var redirectUri = parameters.RedirectUri.AsUri(); + + // according to oauth 2.0 spec (RFC 6749, section 3.1.2.3): + // https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2.3 + + var redirectProof = await redirectUriPolicy.EnsureRedirectUriIsAllowedAsync(client, redirectUri, cancellation); + if (redirectProof.IsFailure) + { + return Result.Failure(redirectProof.Error); + } + + return Result.Success(parameters.AsReponse()); + } +} diff --git a/Source/Vinder.Federation.Application/Handlers/Authorization/ClientCredentialsGrantHandler.cs b/Source/Vinder.Federation.Application/Handlers/Authorization/ClientCredentialsGrantHandler.cs new file mode 100644 index 0000000..f7d6192 --- /dev/null +++ b/Source/Vinder.Federation.Application/Handlers/Authorization/ClientCredentialsGrantHandler.cs @@ -0,0 +1,37 @@ +namespace Vinder.Federation.Application.Handlers.Authorization; + +public sealed class ClientCredentialsGrantHandler(ITenantCollection tenantCollection, ISecurityTokenService tokenService) : IAuthorizationFlowHandler +{ + public async Task> HandleAsync(ClientAuthenticationCredentials parameters, CancellationToken cancellation = default) + { + var filters = new TenantFiltersBuilder() + .WithClientId(parameters.ClientId) + .Build(); + + var tenants = await tenantCollection.GetTenantsAsync(filters, cancellation: cancellation); + var tenant = tenants.FirstOrDefault(); + + if (tenant is null) + { + return Result.Failure(AuthenticationErrors.ClientNotFound); + } + + if (parameters.ClientSecret != tenant.SecretHash) + { + return Result.Failure(AuthenticationErrors.InvalidClientCredentials); + } + + var tokenResult = await tokenService.GenerateAccessTokenAsync(tenant, cancellation); + if (tokenResult.IsFailure) + { + return Result.Failure(tokenResult.Error); + } + + var response = new ClientAuthenticationResult + { + AccessToken = tokenResult.Data!.Value + }; + + return Result.Success(response); + } +} diff --git a/Source/Vinder.Federation.Application/Handlers/Identity/ClientAuthenticationHandler.cs b/Source/Vinder.Federation.Application/Handlers/Identity/ClientAuthenticationHandler.cs index b6d6dd0..d0099a1 100644 --- a/Source/Vinder.Federation.Application/Handlers/Identity/ClientAuthenticationHandler.cs +++ b/Source/Vinder.Federation.Application/Handlers/Identity/ClientAuthenticationHandler.cs @@ -1,41 +1,17 @@ namespace Vinder.Federation.Application.Handlers.Identity; -public sealed class ClientAuthenticationHandler( - ITenantCollection tenantCollection, - ISecurityTokenService tokenService -) : IMessageHandler> +public sealed class ClientAuthenticationHandler(ITenantCollection tenantCollection, IUserCollection userCollection, ITokenCollection tokenCollection, ISecurityTokenService tokenService) : + IMessageHandler> { public async Task> HandleAsync( ClientAuthenticationCredentials parameters, CancellationToken cancellation = default) { - var filters = TenantFilters.WithSpecifications() - .WithClientId(parameters.ClientId) - .Build(); - - var tenants = await tenantCollection.GetTenantsAsync(filters, cancellation: cancellation); - var tenant = tenants.FirstOrDefault(); - - if (tenant is null) - { - return Result.Failure(AuthenticationErrors.ClientNotFound); - } - - if (parameters.ClientSecret != tenant.SecretHash) - { - return Result.Failure(AuthenticationErrors.InvalidClientCredentials); - } - - var tokenResult = await tokenService.GenerateAccessTokenAsync(tenant, cancellation); - if (tokenResult.IsFailure) - { - return Result.Failure(tokenResult.Error); - } - - var response = new ClientAuthenticationResult + IAuthorizationFlowHandler handler = parameters.GrantType switch { - AccessToken = tokenResult.Data!.Value + SupportedGrantType.AuthorizationCode => new AuthorizationCodeGrantHandler(tenantCollection, userCollection, tokenService, tokenCollection), + SupportedGrantType.ClientCredentials => new ClientCredentialsGrantHandler(tenantCollection, tokenService), }; - return Result.Success(response); + return await handler.HandleAsync(parameters, cancellation); } -} \ No newline at end of file +} diff --git a/Source/Vinder.Federation.Application/Mappers/AuthorizationMapper.cs b/Source/Vinder.Federation.Application/Mappers/AuthorizationMapper.cs new file mode 100644 index 0000000..58ef42a --- /dev/null +++ b/Source/Vinder.Federation.Application/Mappers/AuthorizationMapper.cs @@ -0,0 +1,13 @@ +namespace Vinder.Federation.Application.Mappers; + +public static class AuthorizationMapper +{ + public static AuthorizationScheme AsReponse(this AuthorizationParameters parameters) => new() + { + ClientId = parameters.ClientId, + RedirectUri = parameters.RedirectUri, + State = parameters.State, + CodeChallenge = parameters.CodeChallenge, + CodeChallengeMethod = parameters.CodeChallengeMethod, + }; +} diff --git a/Source/Vinder.Federation.Application/Mappers/JsonWebKeysMapper.cs b/Source/Vinder.Federation.Application/Mappers/JsonWebKeysMapper.cs index d22a720..e4d6681 100644 --- a/Source/Vinder.Federation.Application/Mappers/JsonWebKeysMapper.cs +++ b/Source/Vinder.Federation.Application/Mappers/JsonWebKeysMapper.cs @@ -1,6 +1,3 @@ -using Microsoft.IdentityModel.Tokens; -using Vinder.Federation.Application.Payloads.Connect; - namespace Vinder.Federation.Application.Mappers; public static class JsonWebKeysMapper @@ -25,4 +22,4 @@ public static JsonWebKeySetScheme AsJsonWebKeySetScheme(Secret secret) Keys = [JsonWebKeysMapper.AsJsonWebKeys(secret)] }; } -} \ No newline at end of file +} diff --git a/Source/Vinder.Federation.Application/Mappers/RedirectUriMapper.cs b/Source/Vinder.Federation.Application/Mappers/RedirectUriMapper.cs new file mode 100644 index 0000000..afdbadc --- /dev/null +++ b/Source/Vinder.Federation.Application/Mappers/RedirectUriMapper.cs @@ -0,0 +1,7 @@ +namespace Vinder.Federation.Application.Mappers; + +public static class RedirectUriMapper +{ + public static RedirectUri AsUri(this string uri) => + new(uri); +} diff --git a/Source/Vinder.Federation.Application/Payloads/Authorization/AuthorizationParameters.cs b/Source/Vinder.Federation.Application/Payloads/Authorization/AuthorizationParameters.cs new file mode 100644 index 0000000..b57a3c3 --- /dev/null +++ b/Source/Vinder.Federation.Application/Payloads/Authorization/AuthorizationParameters.cs @@ -0,0 +1,15 @@ +namespace Vinder.Federation.Application.Payloads.Authorization; + +public sealed record AuthorizationParameters : IMessage> +{ + public string ResponseType { get; init; } = default!; + public string RedirectUri { get; init; } = default!; + + public string ClientId { get; init; } = default!; + + public string CodeChallenge { get; init; } = default!; + public string CodeChallengeMethod { get; init; } = default!; + + public string? Scope { get; init; } = default!; + public string? State { get; init; } = default!; +} diff --git a/Source/Vinder.Federation.Application/Payloads/Authorization/AuthorizationScheme.cs b/Source/Vinder.Federation.Application/Payloads/Authorization/AuthorizationScheme.cs new file mode 100644 index 0000000..3ee6905 --- /dev/null +++ b/Source/Vinder.Federation.Application/Payloads/Authorization/AuthorizationScheme.cs @@ -0,0 +1,10 @@ +namespace Vinder.Federation.Application.Payloads.Authorization; + +public sealed record AuthorizationScheme +{ + public string ClientId { get; init; } = default!; + public string RedirectUri { get; init; } = default!; + public string CodeChallenge { get; init; } = default!; + public string CodeChallengeMethod { get; init; } = default!; + public string? State { get; init; } = default; +} diff --git a/Source/Vinder.Federation.Application/Payloads/Identity/ClientAuthenticationCredentials.cs b/Source/Vinder.Federation.Application/Payloads/Identity/ClientAuthenticationCredentials.cs index 012f574..80a9909 100644 --- a/Source/Vinder.Federation.Application/Payloads/Identity/ClientAuthenticationCredentials.cs +++ b/Source/Vinder.Federation.Application/Payloads/Identity/ClientAuthenticationCredentials.cs @@ -3,6 +3,12 @@ namespace Vinder.Federation.Application.Payloads.Identity; public sealed record ClientAuthenticationCredentials : IMessage> { public string GrantType { get; init; } = default!; + + // for client_credentials grant type public string ClientId { get; init; } = default!; public string ClientSecret { get; init; } = default!; -} \ No newline at end of file + + // for authorization_code grant type + public string Code { get; init; } = default!; + public string CodeVerifier { get; init; } = default!; +} diff --git a/Source/Vinder.Federation.Application/Policies/RedirectUriPolicy.cs b/Source/Vinder.Federation.Application/Policies/RedirectUriPolicy.cs new file mode 100644 index 0000000..aad408a --- /dev/null +++ b/Source/Vinder.Federation.Application/Policies/RedirectUriPolicy.cs @@ -0,0 +1,17 @@ +namespace Vinder.Federation.Application.Policies; + +public sealed class RedirectUriPolicy : IRedirectUriPolicy +{ + public async Task EnsureRedirectUriIsAllowedAsync( + Tenant tenant, RedirectUri redirectUri, CancellationToken cancellation = default) + { + // according to oauth 2.0 spec (RFC 6749, section 3.1.2.3): + // https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2.3 + + var isAllowed = tenant.RedirectUris.Contains(redirectUri); + + return isAllowed ? + Result.Success() : + Result.Failure(AuthorizationErrors.RedirectUriNotAllowed); + } +} diff --git a/Source/Vinder.Federation.Application/Usings.cs b/Source/Vinder.Federation.Application/Usings.cs index bcc8d94..815f25c 100644 --- a/Source/Vinder.Federation.Application/Usings.cs +++ b/Source/Vinder.Federation.Application/Usings.cs @@ -1,9 +1,12 @@ global using System.Text.Json.Serialization; +global using System.Security.Cryptography; global using Vinder.Internal.Essentials.Patterns; global using Vinder.Internal.Essentials.Filtering; global using Vinder.Federation.Domain.Errors; +global using Vinder.Federation.Domain.Policies; +global using Vinder.Federation.Domain.Concepts; global using Vinder.Federation.Common.Constants; global using Vinder.Federation.Domain.Aggregates; @@ -13,6 +16,7 @@ global using Vinder.Federation.Application.Payloads.Common; global using Vinder.Federation.Application.Payloads.Identity; +global using Vinder.Federation.Application.Payloads.Authorization; global using Vinder.Federation.Application.Payloads.Group; global using Vinder.Federation.Application.Payloads.Permission; global using Vinder.Federation.Application.Payloads.Tenant; @@ -22,8 +26,11 @@ global using Vinder.Federation.Application.Payloads.Connect; global using Vinder.Federation.Application.Services; +global using Vinder.Federation.Application.Contracts; global using Vinder.Federation.Application.Providers; global using Vinder.Federation.Application.Mappers; +global using Vinder.Federation.Application.Utilities; +global using Vinder.Federation.Application.Handlers.Authorization; global using FluentValidation; global using Vinder.Dispatcher.Contracts; diff --git a/Source/Vinder.Federation.Application/Utilities/PkceCodeVerifier.cs b/Source/Vinder.Federation.Application/Utilities/PkceCodeVerifier.cs new file mode 100644 index 0000000..a4b2429 --- /dev/null +++ b/Source/Vinder.Federation.Application/Utilities/PkceCodeVerifier.cs @@ -0,0 +1,31 @@ +namespace Vinder.Federation.Application.Utilities; + +public static class PkceCodeVerifier +{ + public static bool Validate(string codeVerifier, string codeChallenge, string method) + { + if (string.IsNullOrWhiteSpace(codeVerifier) || string.IsNullOrWhiteSpace(codeChallenge)) + { + return false; + } + + // according to pkce spec (RFC 7636, section 4.6): + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.6 + + return method switch + { + SupportedPkceMethods.PkceS256 => PkceCodeVerifier.ValidateS256(codeVerifier, codeChallenge), + SupportedPkceMethods.PkcePlain => codeVerifier == codeChallenge, + + _ => false + }; + } + + private static bool ValidateS256(string codeVerifier, string codeChallenge) + { + var bytes = SHA256.HashData(System.Text.Encoding.ASCII.GetBytes(codeVerifier)); + var hashed = Base64UrlEncoder.Encode(bytes); + + return hashed == codeChallenge; + } +} diff --git a/Source/Vinder.Federation.Application/Validators/Authorization/AuthorizationParametersValidator.cs b/Source/Vinder.Federation.Application/Validators/Authorization/AuthorizationParametersValidator.cs new file mode 100644 index 0000000..ebc464b --- /dev/null +++ b/Source/Vinder.Federation.Application/Validators/Authorization/AuthorizationParametersValidator.cs @@ -0,0 +1,36 @@ +namespace Vinder.Federation.Application.Validators.Authorization; + +public sealed class AuthorizationParametersValidator : AbstractValidator +{ + public AuthorizationParametersValidator() + { + RuleFor(parameters => parameters.ResponseType) + .NotNull() + .NotEmpty() + .Equal("code") + .WithMessage("response type must be 'code'."); + + RuleFor(parameters => parameters.ClientId) + .NotNull() + .NotEmpty(); + + RuleFor(parameters => parameters.RedirectUri) + .NotNull() + .NotEmpty() + .Must(uri => uri is not null && Uri.IsWellFormedUriString(uri, UriKind.Absolute)) + .WithMessage("redirect uri must be a valid absolute uri."); + + RuleFor(parameters => parameters.CodeChallenge) + .NotNull() + .NotEmpty() + .MinimumLength(43).WithMessage("code challenge must be at least 43 characters for S256.") + .MaximumLength(128).WithMessage("code challenge must be at most 128 characters for S256.") + .Matches("^[a-zA-Z0-9_-]+$").WithMessage("code challenge must be base64url encoded."); + + RuleFor(parameters => parameters.CodeChallengeMethod) + .NotNull() + .NotEmpty() + .Must(method => method is not null && (method == SupportedPkceMethods.PkcePlain || method == SupportedPkceMethods.PkceS256)) + .WithMessage("code challenge method must be 'S256' or 'plain'."); + } +} diff --git a/Source/Vinder.Federation.Application/Validators/Identity/ClientAuthenticationCredentialsValidator.cs b/Source/Vinder.Federation.Application/Validators/Identity/ClientAuthenticationCredentialsValidator.cs index 3849f89..2fa88ce 100644 --- a/Source/Vinder.Federation.Application/Validators/Identity/ClientAuthenticationCredentialsValidator.cs +++ b/Source/Vinder.Federation.Application/Validators/Identity/ClientAuthenticationCredentialsValidator.cs @@ -6,20 +6,40 @@ public ClientAuthenticationCredentialsValidator() { RuleFor(credential => credential.GrantType) .NotEmpty() - .WithMessage("Grant type must not be empty.") - .Equal("client_credentials") - .WithMessage("Grant type must be 'client_credentials'."); + .WithMessage("grant type must not be empty.") + .Must(grant => grant == SupportedGrantType.ClientCredentials || grant == SupportedGrantType.AuthorizationCode) + .WithMessage("grant type must be either 'client_credentials' or 'authorization_code'."); - RuleFor(credential => credential.ClientId) + When(credential => credential.GrantType == SupportedGrantType.ClientCredentials, () => + { + RuleFor(credential => credential.ClientId) .NotEmpty() - .WithMessage("Client ID must not be empty.") + .WithMessage("client identifier must not be empty.") .MaximumLength(200) - .WithMessage("Client ID must be at most 200 characters long."); + .WithMessage("client identifier must be at most 200 characters long."); - RuleFor(credential => credential.ClientSecret) + RuleFor(credential => credential.ClientSecret) .NotEmpty() - .WithMessage("Client secret must not be empty.") + .WithMessage("client secret must not be empty.") .MaximumLength(500) - .WithMessage("Client secret must be at most 500 characters long."); + .WithMessage("client secret must be at most 500 characters long."); + }); + + When(credential => credential.GrantType == SupportedGrantType.AuthorizationCode, () => + { + RuleFor(credential => credential.Code) + .NotEmpty() + .WithMessage("code must not be empty."); + + RuleFor(credential => credential.CodeVerifier) + .NotEmpty() + .WithMessage("code verifier must not be empty.") + .MinimumLength(43) + .WithMessage("code verifier must be at least 43 characters.") + .MaximumLength(128) + .WithMessage("code verifier must be at most 128 characters.") + .Matches("^[a-zA-Z0-9_-]+$") + .WithMessage("code verifier must be base64url encoded."); + }); } -} \ No newline at end of file +} diff --git a/Source/Vinder.Federation.Application/Vinder.Federation.Application.csproj b/Source/Vinder.Federation.Application/Vinder.Federation.Application.csproj index dde9e82..4908909 100644 --- a/Source/Vinder.Federation.Application/Vinder.Federation.Application.csproj +++ b/Source/Vinder.Federation.Application/Vinder.Federation.Application.csproj @@ -4,6 +4,11 @@ net9.0 enable enable + + CS8509 diff --git a/Source/Vinder.Federation.Common/Constants/SupportedGrantType.cs b/Source/Vinder.Federation.Common/Constants/SupportedGrantType.cs new file mode 100644 index 0000000..0568c42 --- /dev/null +++ b/Source/Vinder.Federation.Common/Constants/SupportedGrantType.cs @@ -0,0 +1,7 @@ +namespace Vinder.Federation.Common.Constants; + +public static class SupportedGrantType +{ + public const string AuthorizationCode = "authorization_code"; + public const string ClientCredentials = "client_credentials"; +} diff --git a/Source/Vinder.Federation.Common/Constants/SupportedPkceMethods.cs b/Source/Vinder.Federation.Common/Constants/SupportedPkceMethods.cs new file mode 100644 index 0000000..b5a9ce7 --- /dev/null +++ b/Source/Vinder.Federation.Common/Constants/SupportedPkceMethods.cs @@ -0,0 +1,7 @@ +namespace Vinder.Federation.Common.Constants; + +public sealed class SupportedPkceMethods +{ + public const string PkcePlain = "plain"; + public const string PkceS256 = "S256"; +} diff --git a/Source/Vinder.Federation.Domain/Aggregates/SecurityToken.cs b/Source/Vinder.Federation.Domain/Aggregates/SecurityToken.cs index dc0ec60..f58460b 100644 --- a/Source/Vinder.Federation.Domain/Aggregates/SecurityToken.cs +++ b/Source/Vinder.Federation.Domain/Aggregates/SecurityToken.cs @@ -5,12 +5,13 @@ public sealed class SecurityToken : Aggregate public string Value { get; set; } = default!; public bool Revoked { get; set; } - public TokenType Type { get; set; } - public DateTime ExpiresAt { get; set; } - public string UserId { get; set; } = default!; public string TenantId { get; set; } = default!; + public TokenType Type { get; set; } + public DateTime ExpiresAt { get; set; } + public Dictionary Metadata { get; set; } = []; + public bool IsExpired => DateTime.UtcNow >= ExpiresAt; public bool IsActive => !Revoked && !IsExpired; -} \ No newline at end of file +} diff --git a/Source/Vinder.Federation.Domain/Aggregates/Tenant.cs b/Source/Vinder.Federation.Domain/Aggregates/Tenant.cs index cda7038..f1121b4 100644 --- a/Source/Vinder.Federation.Domain/Aggregates/Tenant.cs +++ b/Source/Vinder.Federation.Domain/Aggregates/Tenant.cs @@ -9,5 +9,6 @@ public sealed class Tenant : Aggregate public string SecretHash { get; set; } = default!; public ICollection Permissions { get; set; } = []; + public ICollection RedirectUris { get; set; } = []; public ICollection Scopes { get; set; } = []; } diff --git a/Source/Vinder.Federation.Domain/Aggregates/TokenType.cs b/Source/Vinder.Federation.Domain/Aggregates/TokenType.cs index 90c83e5..2105a07 100644 --- a/Source/Vinder.Federation.Domain/Aggregates/TokenType.cs +++ b/Source/Vinder.Federation.Domain/Aggregates/TokenType.cs @@ -4,5 +4,6 @@ public enum TokenType { Refresh, EmailVerification, + AuthorizationCode, PasswordReset -} \ No newline at end of file +} diff --git a/Source/Vinder.Federation.Domain/Concepts/RedirectUri.cs b/Source/Vinder.Federation.Domain/Concepts/RedirectUri.cs new file mode 100644 index 0000000..676e39a --- /dev/null +++ b/Source/Vinder.Federation.Domain/Concepts/RedirectUri.cs @@ -0,0 +1,7 @@ +namespace Vinder.Federation.Domain.Concepts; + +public sealed record RedirectUri(string Address) : + IValueObject +{ + public string Address { get; init; } = Address; +} diff --git a/Source/Vinder.Federation.Domain/Errors/AuthorizationErrors.cs b/Source/Vinder.Federation.Domain/Errors/AuthorizationErrors.cs new file mode 100644 index 0000000..b80d636 --- /dev/null +++ b/Source/Vinder.Federation.Domain/Errors/AuthorizationErrors.cs @@ -0,0 +1,24 @@ +namespace Vinder.Federation.Domain.Errors; + +public sealed class AuthorizationErrors +{ + public static readonly Error RedirectUriNotAllowed = new( + Code: "#VINDER-IDP-ERR-AUT-421", + Description: "The specified redirect URI is not registered or allowed for this tenant. See https://bit.ly/errors-reference for more details." + ); + + public static readonly Error InvalidAuthorizationCode = new( + Code: "#VINDER-IDP-ERR-AUT-406", + Description: "The provided authorization code is invalid, expired, or has already been used. See https://bit.ly/errors-reference for more details." + ); + + public static readonly Error AuthorizationCodeExpired = new( + Code: "#VINDER-IDP-ERR-AUT-409", + Description: "The authorization code has expired. See https://bit.ly/errors-reference for more details." + ); + + public static readonly Error InvalidCodeVerifier = new( + Code: "#VINDER-IDP-ERR-AUT-407", + Description: "The provided code verifier does not match the code challenge. See https://bit.ly/errors-reference for more details." + ); +} diff --git a/Source/Vinder.Federation.Domain/Policies/IRedirectUriPolicy.cs b/Source/Vinder.Federation.Domain/Policies/IRedirectUriPolicy.cs new file mode 100644 index 0000000..e403e8c --- /dev/null +++ b/Source/Vinder.Federation.Domain/Policies/IRedirectUriPolicy.cs @@ -0,0 +1,15 @@ +namespace Vinder.Federation.Domain.Policies; + +// according to oauth 2.0 spec (RFC 6749, section 3.1.2.3): +// this interface defines a policy to ensure that a given redirect URI is allowed for a specific tenant + +// https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2.3 + +public interface IRedirectUriPolicy +{ + public Task EnsureRedirectUriIsAllowedAsync( + Tenant tenant, + RedirectUri redirectUri, + CancellationToken cancellation = default + ); +} diff --git a/Source/Vinder.Federation.Domain/Usings.cs b/Source/Vinder.Federation.Domain/Usings.cs index 6de7345..fb536f2 100644 --- a/Source/Vinder.Federation.Domain/Usings.cs +++ b/Source/Vinder.Federation.Domain/Usings.cs @@ -1,4 +1,5 @@ global using Vinder.Federation.Domain.Aggregates; +global using Vinder.Federation.Domain.Concepts; global using Vinder.Federation.Domain.Filtering; global using Vinder.Federation.Domain.Filtering.Builders; diff --git a/Source/Vinder.Federation.Infrastructure.IoC/Extensions/ApplicationServicesExtension.cs b/Source/Vinder.Federation.Infrastructure.IoC/Extensions/ApplicationServicesExtension.cs index b2d4525..17511e8 100644 --- a/Source/Vinder.Federation.Infrastructure.IoC/Extensions/ApplicationServicesExtension.cs +++ b/Source/Vinder.Federation.Infrastructure.IoC/Extensions/ApplicationServicesExtension.cs @@ -9,8 +9,9 @@ public static void AddServices(this IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddSingleton(); services.AddSingleton(); } -} \ No newline at end of file +} diff --git a/Source/Vinder.Federation.Infrastructure.IoC/Extensions/ValidationExtension.cs b/Source/Vinder.Federation.Infrastructure.IoC/Extensions/ValidationExtension.cs index 7f03cf1..ca849f1 100644 --- a/Source/Vinder.Federation.Infrastructure.IoC/Extensions/ValidationExtension.cs +++ b/Source/Vinder.Federation.Infrastructure.IoC/Extensions/ValidationExtension.cs @@ -11,6 +11,7 @@ public static void AddValidators(this IServiceCollection services) }); services.AddTransient, AuthenticationCredentialsValidator>(); + services.AddTransient, AuthorizationParametersValidator>(); services.AddTransient, ClientAuthenticationCredentialsValidator>(); services.AddTransient, IdentityEnrollmentCredentialsValidator>(); @@ -28,4 +29,4 @@ public static void AddValidators(this IServiceCollection services) services.AddTransient, AssignTenantPermissionValidator>(); services.AddTransient, ScopeCreationValidator>(); } -} \ No newline at end of file +} diff --git a/Source/Vinder.Federation.Infrastructure.IoC/Usings.cs b/Source/Vinder.Federation.Infrastructure.IoC/Usings.cs index 3e769aa..9d5a28c 100644 --- a/Source/Vinder.Federation.Infrastructure.IoC/Usings.cs +++ b/Source/Vinder.Federation.Infrastructure.IoC/Usings.cs @@ -8,10 +8,12 @@ global using Vinder.Federation.Common.Configuration; global using Vinder.Federation.Domain.Collections; global using Vinder.Federation.Domain.Aggregates; +global using Vinder.Federation.Domain.Policies; global using Vinder.Federation.Application.Services; global using Vinder.Federation.Application.Providers; global using Vinder.Federation.Application.Payloads.Identity; +global using Vinder.Federation.Application.Payloads.Authorization; global using Vinder.Federation.Application.Payloads.Group; global using Vinder.Federation.Application.Payloads.Permission; global using Vinder.Federation.Application.Payloads.Tenant; @@ -21,10 +23,13 @@ global using Vinder.Federation.Application.Validators.Permission; global using Vinder.Federation.Application.Validators.Group; global using Vinder.Federation.Application.Validators.Identity; +global using Vinder.Federation.Application.Validators.Authorization; global using Vinder.Federation.Application.Validators.Tenant; global using Vinder.Federation.Application.Validators.User; global using Vinder.Federation.Application.Validators.Scope; + global using Vinder.Federation.Application.Handlers.Identity; +global using Vinder.Federation.Application.Policies; global using Vinder.Federation.Infrastructure.Providers; global using Vinder.Federation.Infrastructure.Persistence; @@ -33,4 +38,4 @@ global using MongoDB.Driver; global using FluentValidation; -global using FluentValidation.AspNetCore; \ No newline at end of file +global using FluentValidation.AspNetCore; diff --git a/Source/Vinder.Federation.Infrastructure/Pipelines/GroupFiltersStage.cs b/Source/Vinder.Federation.Infrastructure/Pipelines/GroupFiltersStage.cs index 378e11e..5fb2e80 100644 --- a/Source/Vinder.Federation.Infrastructure/Pipelines/GroupFiltersStage.cs +++ b/Source/Vinder.Federation.Infrastructure/Pipelines/GroupFiltersStage.cs @@ -10,7 +10,7 @@ public static PipelineDefinition FilterGroups(this Pipeline { FilterDefinitions.MatchIfNotEmpty(Documents.Group.Id, filters.Id), FilterDefinitions.MatchIfNotEmpty(Documents.Group.Name, filters.Name), - FilterDefinitions.MatchIfNotEmpty(Documents.Group.TenantId, tenant.Id), + FilterDefinitions.MatchIfNotEmpty(Documents.Group.TenantId, tenant?.Id), FilterDefinitions.MatchBool(Documents.Group.IsDeleted, filters.IsDeleted), }; diff --git a/Source/Vinder.Federation.Infrastructure/Pipelines/PermissionFiltersStage.cs b/Source/Vinder.Federation.Infrastructure/Pipelines/PermissionFiltersStage.cs index 41cc429..4fa29c9 100644 --- a/Source/Vinder.Federation.Infrastructure/Pipelines/PermissionFiltersStage.cs +++ b/Source/Vinder.Federation.Infrastructure/Pipelines/PermissionFiltersStage.cs @@ -8,7 +8,7 @@ public static PipelineDefinition FilterPermissions(thi var tenant = tenantProvider.GetCurrentTenant(); var definitions = new List> { - FilterDefinitions.MatchIfNotEmpty(Documents.Permission.TenantId, tenant.Id), + FilterDefinitions.MatchIfNotEmpty(Documents.Permission.TenantId, tenant?.Id), FilterDefinitions.MatchIfNotEmpty(Documents.Permission.Id, filters.Id), FilterDefinitions.MatchIfNotEmpty(Documents.Permission.Name, filters.Name), FilterDefinitions.MatchBool(Documents.Permission.IsDeleted, filters.IsDeleted), diff --git a/Source/Vinder.Federation.Infrastructure/Pipelines/TokenFiltersStage.cs b/Source/Vinder.Federation.Infrastructure/Pipelines/TokenFiltersStage.cs index 58a34cb..087c995 100644 --- a/Source/Vinder.Federation.Infrastructure/Pipelines/TokenFiltersStage.cs +++ b/Source/Vinder.Federation.Infrastructure/Pipelines/TokenFiltersStage.cs @@ -12,10 +12,10 @@ public static PipelineDefinition FilterTokens(this FilterDefinitions.MatchIfNotEmpty(Documents.SecurityToken.Value, filters.Value), FilterDefinitions.MatchIfNotEmpty(Documents.SecurityToken.UserId, filters.UserId), - FilterDefinitions.MatchIfNotEmpty(Documents.SecurityToken.TenantId, tenant.Id), + FilterDefinitions.MatchIfNotEmpty(Documents.SecurityToken.TenantId, tenant?.Id), FilterDefinitions.MatchBool(Documents.SecurityToken.IsDeleted, filters.IsDeleted) }; return pipeline.Match(Builders.Filter.And(definitions)); } -} \ No newline at end of file +} diff --git a/Source/Vinder.Federation.Infrastructure/Pipelines/UserFiltersStage.cs b/Source/Vinder.Federation.Infrastructure/Pipelines/UserFiltersStage.cs index b055986..b12734f 100644 --- a/Source/Vinder.Federation.Infrastructure/Pipelines/UserFiltersStage.cs +++ b/Source/Vinder.Federation.Infrastructure/Pipelines/UserFiltersStage.cs @@ -10,7 +10,7 @@ public static PipelineDefinition FilterUsers(this PipelineDe { FilterDefinitions.MatchIfNotEmpty(Documents.User.Id, filters.Id), FilterDefinitions.MatchIfNotEmpty(Documents.User.Username, filters.Username), - FilterDefinitions.MatchIfNotEmpty(Documents.User.TenantId, tenant.Id), + FilterDefinitions.MatchIfNotEmpty(Documents.User.TenantId, tenant?.Id), FilterDefinitions.MatchBool(Documents.User.IsDeleted, filters.IsDeleted) }; diff --git a/Source/Vinder.Federation.WebApi/Controllers/ConnectController.cs b/Source/Vinder.Federation.WebApi/Controllers/ConnectController.cs index a415cb1..d1b02ea 100644 --- a/Source/Vinder.Federation.WebApi/Controllers/ConnectController.cs +++ b/Source/Vinder.Federation.WebApi/Controllers/ConnectController.cs @@ -5,6 +5,7 @@ namespace Vinder.Federation.WebApi.Controllers; public sealed class ConnectController(IDispatcher dispatcher) : ControllerBase { [HttpPost("token")] + [Stability(Stability.Stable)] public async Task AuthenticateClientAsync( [FromSnakeCaseForm] ClientAuthenticationCredentials request, CancellationToken cancellation) { @@ -20,6 +21,12 @@ public async Task AuthenticateClientAsync( { IsFailure: true } when result.Error == AuthenticationErrors.InvalidClientCredentials => StatusCode(StatusCodes.Status401Unauthorized, result.Error), + + { IsFailure: true } when result.Error == AuthorizationErrors.InvalidAuthorizationCode => + StatusCode(StatusCodes.Status400BadRequest, result.Error), + + { IsFailure: true } when result.Error == AuthorizationErrors.InvalidCodeVerifier => + StatusCode(StatusCodes.Status400BadRequest, result.Error) }; } -} \ No newline at end of file +} diff --git a/Source/Vinder.Federation.WebApi/Controllers/GroupsController.cs b/Source/Vinder.Federation.WebApi/Controllers/GroupsController.cs index b021772..0b9a406 100644 --- a/Source/Vinder.Federation.WebApi/Controllers/GroupsController.cs +++ b/Source/Vinder.Federation.WebApi/Controllers/GroupsController.cs @@ -7,6 +7,7 @@ public sealed class GroupsController(IDispatcher dispatcher) : ControllerBase { [HttpGet] [Authorize(Roles = Permissions.ViewGroups)] + [Stability(Stability.Stable)] public async Task GetGroupsAsync( [FromQuery] GroupsFetchParameters request, CancellationToken cancellation) { @@ -22,6 +23,7 @@ public async Task GetGroupsAsync( [HttpPost] [Authorize(Roles = Permissions.CreateGroup)] + [Stability(Stability.Stable)] public async Task CreateGroupAsync(GroupCreationScheme request, CancellationToken cancellation) { var result = await dispatcher.DispatchAsync(request, cancellation); @@ -38,6 +40,7 @@ public async Task CreateGroupAsync(GroupCreationScheme request, C [HttpPut("{id}")] [Authorize(Roles = Permissions.EditGroup)] + [Stability(Stability.Stable)] public async Task UpdateGroupAsync(string id, GroupUpdateScheme request, CancellationToken cancellation) { var result = await dispatcher.DispatchAsync(request with { GroupId = id }, cancellation); @@ -54,6 +57,7 @@ public async Task UpdateGroupAsync(string id, GroupUpdateScheme r [HttpDelete("{id}")] [Authorize(Roles = Permissions.DeleteGroup)] + [Stability(Stability.Stable)] public async Task DeleteGroupAsync(string id, CancellationToken cancellation) { var result = await dispatcher.DispatchAsync(new GroupDeletionScheme { GroupId = id }, cancellation); @@ -70,6 +74,7 @@ public async Task DeleteGroupAsync(string id, CancellationToken c [HttpGet("{id}/permissions")] [Authorize(Roles = Permissions.ViewPermissions)] + [Stability(Stability.Stable)] public async Task GetGroupsPermissionsAsync( [FromRoute] string id, [FromQuery] ListGroupAssignedPermissionsParameters request, CancellationToken cancellation @@ -88,6 +93,7 @@ public async Task GetGroupsPermissionsAsync( [HttpPost("{id}/permissions")] [Authorize(Roles = Permissions.AssignPermissions)] + [Stability(Stability.Stable)] public async Task AssignPermissionAsync( string id, AssignGroupPermissionScheme request, CancellationToken cancellation) { @@ -111,6 +117,7 @@ public async Task AssignPermissionAsync( [HttpDelete("{id}/permissions/{permissionId}")] [Authorize(Roles = Permissions.RevokePermissions)] + [Stability(Stability.Stable)] public async Task RevokePermissionAsync(string id, string permissionId, CancellationToken cancellation) { var request = new RevokeGroupPermissionScheme { GroupId = id, PermissionId = permissionId }; @@ -131,4 +138,4 @@ public async Task RevokePermissionAsync(string id, string permiss StatusCode(StatusCodes.Status409Conflict, result.Error) }; } -} \ No newline at end of file +} diff --git a/Source/Vinder.Federation.WebApi/Controllers/IdentityController.cs b/Source/Vinder.Federation.WebApi/Controllers/IdentityController.cs index ede4293..0352818 100644 --- a/Source/Vinder.Federation.WebApi/Controllers/IdentityController.cs +++ b/Source/Vinder.Federation.WebApi/Controllers/IdentityController.cs @@ -7,6 +7,7 @@ public sealed class IdentityController(IDispatcher dispatcher) : ControllerBase [HttpGet("principal")] [Authorize] [TenantRequired] + [Stability(Stability.Stable)] public async Task GetPrincipalAsync([FromQuery] InspectPrincipalParameters request, CancellationToken cancellation) { var result = await dispatcher.DispatchAsync(request, cancellation); @@ -23,6 +24,7 @@ public async Task GetPrincipalAsync([FromQuery] InspectPrincipalP [HttpPost] [TenantRequired] + [Stability(Stability.Stable)] public async Task EnrollIdentityAsync(IdentityEnrollmentCredentials request, CancellationToken cancellation) { var result = await dispatcher.DispatchAsync(request, cancellation); @@ -39,6 +41,7 @@ public async Task EnrollIdentityAsync(IdentityEnrollmentCredentia [HttpPost("authenticate")] [TenantRequired] + [Stability(Stability.Deprecated)] public async Task AuthenticateAsync(AuthenticationCredentials request, CancellationToken cancellation) { var result = await dispatcher.DispatchAsync(request, cancellation); @@ -58,6 +61,7 @@ public async Task AuthenticateAsync(AuthenticationCredentials req [HttpPost("refresh-token")] [TenantRequired] + [Stability(Stability.Stable)] public async Task RefreshTokenAsync(SessionTokenRenewalScheme request, CancellationToken cancellation) { var result = await dispatcher.DispatchAsync(request, cancellation); @@ -86,6 +90,7 @@ public async Task RefreshTokenAsync(SessionTokenRenewalScheme req [HttpPost("invalidate-session")] [TenantRequired] + [Stability(Stability.Stable)] public async Task InvalidateSessionAsync(SessionInvalidationScheme request, CancellationToken cancellation) { var result = await dispatcher.DispatchAsync(request, cancellation); @@ -111,4 +116,4 @@ public async Task InvalidateSessionAsync(SessionInvalidationSchem StatusCode(StatusCodes.Status401Unauthorized, result.Error), }; } -} \ No newline at end of file +} diff --git a/Source/Vinder.Federation.WebApi/Controllers/PermissionsController.cs b/Source/Vinder.Federation.WebApi/Controllers/PermissionsController.cs index 4223e4c..3e043d7 100644 --- a/Source/Vinder.Federation.WebApi/Controllers/PermissionsController.cs +++ b/Source/Vinder.Federation.WebApi/Controllers/PermissionsController.cs @@ -7,6 +7,7 @@ public sealed class PermissionsController(IDispatcher dispatcher) : ControllerBa { [HttpGet] [Authorize] + [Stability(Stability.Stable)] public async Task GetPermissionsAsync([FromQuery] PermissionsFetchParameters request, CancellationToken cancellation) { var result = await dispatcher.DispatchAsync(request, cancellation); @@ -21,6 +22,7 @@ public async Task GetPermissionsAsync([FromQuery] PermissionsFetc [HttpPost] [Authorize(Roles = Permissions.CreatePermission)] + [Stability(Stability.Stable)] public async Task CreatePermissionAsync(PermissionCreationScheme request, CancellationToken cancellation) { var result = await dispatcher.DispatchAsync(request, cancellation); @@ -37,6 +39,7 @@ public async Task CreatePermissionAsync(PermissionCreationScheme [HttpPut("{id}")] [Authorize(Roles = Permissions.EditPermission)] + [Stability(Stability.Stable)] public async Task UpdatePermissionAsync(string id, PermissionUpdateScheme request, CancellationToken cancellation) { var result = await dispatcher.DispatchAsync(request with { PermissionId = id }, cancellation); @@ -53,6 +56,7 @@ public async Task UpdatePermissionAsync(string id, PermissionUpda [HttpDelete("{id}")] [Authorize(Roles = Permissions.DeletePermission)] + [Stability(Stability.Stable)] public async Task DeletePermissionAsync(string id, CancellationToken cancellation) { var result = await dispatcher.DispatchAsync(new PermissionDeletionScheme { PermissionId = id }, cancellation); @@ -66,4 +70,4 @@ public async Task DeletePermissionAsync(string id, CancellationTo StatusCode(StatusCodes.Status404NotFound, result.Error), }; } -} \ No newline at end of file +} diff --git a/Source/Vinder.Federation.WebApi/Controllers/ScopesController.cs b/Source/Vinder.Federation.WebApi/Controllers/ScopesController.cs index 8624b2e..a6c3478 100644 --- a/Source/Vinder.Federation.WebApi/Controllers/ScopesController.cs +++ b/Source/Vinder.Federation.WebApi/Controllers/ScopesController.cs @@ -7,6 +7,7 @@ public sealed class ScopesController(IDispatcher dispatcher) : ControllerBase { [HttpPost] [Authorize(Roles = Permissions.CreateScope)] + [Stability(Stability.Experimental)] public async Task CreateScopeAsync(ScopeCreationScheme request, CancellationToken cancellation) { var result = await dispatcher.DispatchAsync(request, cancellation); @@ -20,4 +21,4 @@ public async Task CreateScopeAsync(ScopeCreationScheme request, C StatusCode(StatusCodes.Status409Conflict, result.Error), }; } -} \ No newline at end of file +} diff --git a/Source/Vinder.Federation.WebApi/Controllers/TenantsController.cs b/Source/Vinder.Federation.WebApi/Controllers/TenantsController.cs index 544a41d..6927b0c 100644 --- a/Source/Vinder.Federation.WebApi/Controllers/TenantsController.cs +++ b/Source/Vinder.Federation.WebApi/Controllers/TenantsController.cs @@ -7,6 +7,7 @@ public sealed class TenantsController(IDispatcher dispatcher) : ControllerBase { [HttpGet] [Authorize(Roles = Permissions.ViewTenants)] + [Stability(Stability.Stable)] public async Task GetTenantsAsync([FromQuery] TenantFetchParameters request, CancellationToken cancellation) { var result = await dispatcher.DispatchAsync(request, cancellation); @@ -21,6 +22,7 @@ public async Task GetTenantsAsync([FromQuery] TenantFetchParamete [HttpPost] [Authorize(Roles = Permissions.CreateTenant)] + [Stability(Stability.Stable)] public async Task CreateTenantAsync(TenantCreationScheme request, CancellationToken cancellation) { var result = await dispatcher.DispatchAsync(request, cancellation); @@ -37,6 +39,7 @@ public async Task CreateTenantAsync(TenantCreationScheme request, [HttpPut("{id}")] [Authorize(Roles = Permissions.EditTenant)] + [Stability(Stability.Stable)] public async Task UpdateTenantAsync( string id, TenantUpdateScheme request, CancellationToken cancellation) { @@ -54,6 +57,7 @@ public async Task UpdateTenantAsync( [HttpDelete("{id}")] [Authorize(Roles = Permissions.DeleteTenant)] + [Stability(Stability.Stable)] public async Task DeleteTenantAsync(string id, CancellationToken cancellation) { var result = await dispatcher.DispatchAsync(new TenantDeletionScheme { TenantId = id }, cancellation); @@ -70,6 +74,7 @@ public async Task DeleteTenantAsync(string id, CancellationToken [HttpGet("{id}/permissions")] [Authorize(Roles = Permissions.ViewPermissions)] + [Stability(Stability.Stable)] public async Task GetTenantPermissionsAsync( [FromRoute] string id, [FromQuery] ListTenantAssignedPermissionsParameters request, CancellationToken cancellation ) @@ -87,6 +92,7 @@ public async Task GetTenantPermissionsAsync( [HttpPost("{id}/permissions")] [Authorize(Roles = Permissions.AssignPermissions)] + [Stability(Stability.Stable)] public async Task AssignPermissionAsync( [FromRoute] string id, [FromBody] AssignTenantPermissionScheme request, CancellationToken cancellation) { @@ -110,6 +116,7 @@ public async Task AssignPermissionAsync( [HttpDelete("{id}/permissions/{permissionId}")] [Authorize(Roles = Permissions.RevokePermissions)] + [Stability(Stability.Stable)] public async Task RevokePermissionAsync([FromRoute] string id, [FromRoute] string permissionId, CancellationToken cancellation) { var request = new RevokeTenantPermissionScheme { TenantId = id, PermissionId = permissionId }; @@ -130,4 +137,4 @@ public async Task RevokePermissionAsync([FromRoute] string id, [F StatusCode(StatusCodes.Status409Conflict, result.Error) }; } -} \ No newline at end of file +} diff --git a/Source/Vinder.Federation.WebApi/Controllers/UsersController.cs b/Source/Vinder.Federation.WebApi/Controllers/UsersController.cs index 7095e18..2b8b9ac 100644 --- a/Source/Vinder.Federation.WebApi/Controllers/UsersController.cs +++ b/Source/Vinder.Federation.WebApi/Controllers/UsersController.cs @@ -7,6 +7,7 @@ public sealed class UsersController(IDispatcher dispatcher) : ControllerBase { [HttpGet] [Authorize(Roles = Permissions.ViewUsers)] + [Stability(Stability.Stable)] public async Task GetUsersAsync([FromQuery] UsersFetchParameters request, CancellationToken cancellation) { var result = await dispatcher.DispatchAsync(request, cancellation); @@ -21,6 +22,7 @@ public async Task GetUsersAsync([FromQuery] UsersFetchParameters [HttpDelete("{id}")] [Authorize(Roles = Permissions.EditUser)] + [Stability(Stability.Stable)] public async Task DeleteUserAsync(string id, CancellationToken cancellation) { var request = new UserDeletionScheme { UserId = id }; @@ -37,6 +39,7 @@ public async Task DeleteUserAsync(string id, CancellationToken ca [HttpGet("{id}/permissions")] [Authorize(Roles = Permissions.ViewPermissions)] + [Stability(Stability.Stable)] public async Task GetUserPermissionsAsync( [FromRoute] string id, [FromQuery] ListUserAssignedPermissionsParameters request, CancellationToken cancellation @@ -55,6 +58,7 @@ public async Task GetUserPermissionsAsync( [HttpGet("{id}/groups")] [Authorize(Roles = Permissions.ViewGroups)] + [Stability(Stability.Stable)] public async Task GetUserGroupsAsync( [FromRoute] string id, [FromQuery] ListUserAssignedGroupsParameters request, CancellationToken cancellation @@ -73,6 +77,7 @@ public async Task GetUserGroupsAsync( [HttpPost("{id}/groups")] [Authorize(Roles = Permissions.EditUser)] + [Stability(Stability.Stable)] public async Task AssignUserToGroupAsync( string id, AssignUserToGroupScheme request, CancellationToken cancellation) { @@ -96,6 +101,7 @@ public async Task AssignUserToGroupAsync( [HttpPost("{id}/permissions")] [Authorize(Roles = Permissions.AssignPermissions)] + [Stability(Stability.Stable)] public async Task AssignUserPermissionAsync( string id, AssignUserPermissionScheme request, CancellationToken cancellation) { @@ -119,6 +125,7 @@ public async Task AssignUserPermissionAsync( [HttpDelete("{id}/permissions/{permissionId}")] [Authorize(Roles = Permissions.RevokePermissions)] + [Stability(Stability.Stable)] public async Task RevokeUserPermissionAsync( string id, string permissionId, CancellationToken cancellation) { @@ -143,6 +150,7 @@ public async Task RevokeUserPermissionAsync( [HttpDelete("{id}/groups/{groupId}")] [Authorize(Roles = Permissions.EditUser)] + [Stability(Stability.Stable)] public async Task RemoveUserFromGroupAsync(string id, string groupId, CancellationToken cancellation) { var request = new RemoveUserFromGroupScheme { UserId = id, GroupId = groupId }; @@ -163,4 +171,4 @@ public async Task RemoveUserFromGroupAsync(string id, string grou StatusCode(StatusCodes.Status409Conflict, result.Error) }; } -} \ No newline at end of file +} diff --git a/Source/Vinder.Federation.WebApi/Controllers/WellKnownController.cs b/Source/Vinder.Federation.WebApi/Controllers/WellKnownController.cs index 6170a0f..e1bc017 100644 --- a/Source/Vinder.Federation.WebApi/Controllers/WellKnownController.cs +++ b/Source/Vinder.Federation.WebApi/Controllers/WellKnownController.cs @@ -5,6 +5,7 @@ namespace Vinder.Federation.WebApi.Controllers; public sealed class WellKnownController(IDispatcher dispatcher) : ControllerBase { [HttpGet("openid-configuration")] + [Stability(Stability.Stable)] public async Task GetConfigurationAsync( [FromQuery] FetchOpenIDConfigurationParameters request, CancellationToken cancellation) { @@ -19,6 +20,7 @@ public async Task GetConfigurationAsync( } [HttpGet("jwks.json")] + [Stability(Stability.Stable)] public async Task GetJsonWebKeysAsync( [FromQuery] FetchJsonWebKeysParameters request, CancellationToken cancellation) { diff --git a/Source/Vinder.Federation.WebApi/Extensions/HttpPipelineExtension.cs b/Source/Vinder.Federation.WebApi/Extensions/HttpPipelineExtension.cs index e7d9a48..f430f27 100644 --- a/Source/Vinder.Federation.WebApi/Extensions/HttpPipelineExtension.cs +++ b/Source/Vinder.Federation.WebApi/Extensions/HttpPipelineExtension.cs @@ -18,6 +18,7 @@ public static void UseHttpPipeline(this IApplicationBuilder app) app.UseEndpoints(endpoints => { endpoints.MapControllers(); + endpoints.MapRazorPages(); }); } -} \ No newline at end of file +} diff --git a/Source/Vinder.Federation.WebApi/Extensions/WebInfrastructureExtension.cs b/Source/Vinder.Federation.WebApi/Extensions/WebInfrastructureExtension.cs index d2ea6ce..407ded6 100644 --- a/Source/Vinder.Federation.WebApi/Extensions/WebInfrastructureExtension.cs +++ b/Source/Vinder.Federation.WebApi/Extensions/WebInfrastructureExtension.cs @@ -13,5 +13,6 @@ public static void AddWebComposition(this IServiceCollection services, IWebHostE services.AddMemoryCache(); services.AddProviders(); services.AddOpenApiSpecification(); + services.AddRazorPages(); } -} \ No newline at end of file +} diff --git a/Source/Vinder.Federation.WebApi/Pages/Authorize.cshtml b/Source/Vinder.Federation.WebApi/Pages/Authorize.cshtml new file mode 100644 index 0000000..4feb41f --- /dev/null +++ b/Source/Vinder.Federation.WebApi/Pages/Authorize.cshtml @@ -0,0 +1,97 @@ +@page "/oauth2/authorize" +@model Vinder.Federation.WebApi.Pages.AuthorizePage + + + +
+
+
+ @if ( + ViewData.ModelState.ContainsKey(TenantErrors.TenantDoesNotExist.Code) || + ViewData.ModelState.ContainsKey(AuthorizationErrors.RedirectUriNotAllowed.Code) + ) + { +
+ + + +

Unable to connect

+

We couldn't connect with the partner application.

+

Please check the link or contact support if the problem persists.

+
+ } + else + { +
+

Hello,
Welcome Back

+

Hey, welcome back to your special place

+
+ +
+ @Html.AntiForgeryToken() + + + + +
+ +
+ +
+ +
+ + +
+ + + +

+ + + + protected by OAuth 2.0 +

+ } +
+
+ + +
diff --git a/Source/Vinder.Federation.WebApi/Pages/Authorize.cshtml.cs b/Source/Vinder.Federation.WebApi/Pages/Authorize.cshtml.cs new file mode 100644 index 0000000..ef387d2 --- /dev/null +++ b/Source/Vinder.Federation.WebApi/Pages/Authorize.cshtml.cs @@ -0,0 +1,114 @@ +namespace Vinder.Federation.WebApi.Pages; + +public sealed class AuthorizePage : PageModel +{ + private readonly IDispatcher _dispatcher; + private readonly IUserCollection _userCollection; + + private readonly ITokenCollection _tokenCollection; + private readonly ITenantCollection _tenantCollection; + private readonly ITenantProvider _tenantProvider; + + #region constructors + public AuthorizePage( + IDispatcher dispatcher, + IUserCollection userCollection, + ITenantProvider tenantProvider, + ITenantCollection tenantCollection, + ITokenCollection tokenCollection) + { + _dispatcher = dispatcher; + _userCollection = userCollection; + _tenantCollection = tenantCollection; + _tenantProvider = tenantProvider; + _tokenCollection = tokenCollection; + } + #endregion + + [property: BindProperty(SupportsGet = true)] + public AuthorizationParameters Parameters { get; set; } = new(); + + [property: BindProperty] + public AuthenticationCredentials Credentials { get; set; } = new(); + + public async Task OnGetAsync() + { + var filters = TenantFilters.WithSpecifications() + .WithClientId(Parameters.ClientId) + .Build(); + + var tenants = await _tenantCollection.GetTenantsAsync(filters); + var tenant = tenants.FirstOrDefault(); + + if (tenant is null) + { + ModelState.AddModelError( + key: TenantErrors.TenantDoesNotExist.Code, + errorMessage: TenantErrors.TenantDoesNotExist.Description + ); + + return Page(); + } + + _tenantProvider.SetTenant(tenant); + + var result = await _dispatcher.DispatchAsync(Parameters); + if (result.IsFailure) + { + ModelState.AddModelError( + key: result.Error.Code, + errorMessage: result.Error.Description + ); + + return Page(); + } + + return Page(); + } + + public async Task OnPostAsync() + { + var result = await _dispatcher.DispatchAsync(Credentials); + if (result.IsFailure) + { + ModelState.AddModelError(result.Error.Code, result.Error.Description); + return Page(); + } + + var tenant = _tenantProvider.GetCurrentTenant(); + var filters = UserFilters.WithSpecifications() + .WithUsername(Credentials.Username) + .WithTenantId(tenant.Id) + .Build(); + + var users = await _userCollection.GetUsersAsync(filters); + var user = users.FirstOrDefault(); + + if (user is null) + { + ModelState.AddModelError(AuthenticationErrors.UserNotFound.Code, AuthenticationErrors.UserNotFound.Description); + return Page(); + } + + var code = Guid.NewGuid().ToString("N").ToUpperInvariant(); + var metadata = new Dictionary + { + { "code.challenge", Parameters.CodeChallenge ?? string.Empty }, + { "code.challenge.method", Parameters.CodeChallengeMethod ?? string.Empty } + }; + + var token = new Domain.Aggregates.SecurityToken + { + UserId = user.Id, + TenantId = tenant.Id, + Metadata = metadata, + Value = code, + Type = TokenType.AuthorizationCode, + ExpiresAt = DateTime.UtcNow.AddMinutes(5), + }; + + await _tokenCollection.InsertAsync(token); + + return Redirect($"{Parameters.RedirectUri}?code={code}&state={Parameters.State}"); + } +} diff --git a/Source/Vinder.Federation.WebApi/Usings.cs b/Source/Vinder.Federation.WebApi/Usings.cs index 9319a24..4a7b57e 100644 --- a/Source/Vinder.Federation.WebApi/Usings.cs +++ b/Source/Vinder.Federation.WebApi/Usings.cs @@ -6,11 +6,14 @@ global using Microsoft.AspNetCore.Mvc; global using Microsoft.AspNetCore.Mvc.ModelBinding; +global using Microsoft.AspNetCore.Mvc.RazorPages; + global using Microsoft.AspNetCore.Authorization; global using Microsoft.AspNetCore.Authentication.JwtBearer; global using Microsoft.Extensions.Caching.Memory; -global using Microsoft.IdentityModel.Tokens; + global using Microsoft.OpenApi.Models; +global using Microsoft.IdentityModel.Tokens; global using Vinder.Federation.Domain.Aggregates; global using Vinder.Federation.Domain.Filtering; @@ -22,6 +25,7 @@ global using Vinder.Federation.Application.Payloads.Group; global using Vinder.Federation.Application.Payloads.Identity; +global using Vinder.Federation.Application.Payloads.Authorization; global using Vinder.Federation.Application.Payloads.Permission; global using Vinder.Federation.Application.Payloads.Tenant; global using Vinder.Federation.Application.Payloads.User; diff --git a/Tests/Integration/Endpoints/ConnectEndpointTests.cs b/Tests/Integration/Endpoints/ConnectEndpointTests.cs index ca64641..1f9ddae 100644 --- a/Tests/Integration/Endpoints/ConnectEndpointTests.cs +++ b/Tests/Integration/Endpoints/ConnectEndpointTests.cs @@ -103,7 +103,7 @@ public async Task WhenPostTokenWithInvalidClientSecret_ShouldReturnUnauthorized( /* arrange: create a tenant */ var payload = _fixture.Build() .With(tenant => tenant.Name, $"test-tenant-{Guid.NewGuid()}") - .With(tenant => tenant.Description, $"test-description-{Guid.NewGuid()}.com") + .With(tenant => tenant.Description, $"test-description-{Guid.NewGuid()}") .Create(); var httpResponse = await httpClient.PostAsJsonAsync("api/v1/tenants", payload); @@ -193,4 +193,123 @@ public async Task WhenPostTokenWithMissingClientSecret_ShouldReturnBadRequest() /* assert: response should be 400 Bad Request */ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } -} \ No newline at end of file + + [Fact(DisplayName = "[e2e] - when POST /openid/connect/token with valid authorization_code should return access token")] + public async Task WhenPostTokenWithValidAuthorizationCode_ShouldReturnAccessToken() + { + // arrange: resolve required dependencies + var tokenCollection = factory.Services.GetRequiredService(); + var userCollection = factory.Services.GetRequiredService(); + + // arrange: authenticate as master to create tenant + var masterClient = factory.HttpClient.WithTenantHeader("master"); + var masterCredentials = new AuthenticationCredentials + { + Username = "vinder.testing.user", + Password = "vinder.testing.password" + }; + var authentication = await masterClient.PostAsJsonAsync("api/v1/identity/authenticate", masterCredentials); + var grantedToken = await authentication.Content.ReadFromJsonAsync(); + + Assert.NotNull(grantedToken); + Assert.NotEmpty(grantedToken.AccessToken); + + masterClient.WithAuthorization(grantedToken.AccessToken); + + // arrange: create tenant + var payload = _fixture.Build() + .With(tenant => tenant.Name, $"test-tenant-{Guid.NewGuid()}") + .With(tenant => tenant.Description, $"test-description-{Guid.NewGuid()}") + .Create(); + + var tenantResponse = await masterClient.PostAsJsonAsync("api/v1/tenants", payload); + var tenant = await tenantResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(tenant); + Assert.Equal(HttpStatusCode.Created, tenantResponse.StatusCode); + + // arrange: create user for tenant + var credentials = new IdentityEnrollmentCredentials + { + Username = $"user.{Guid.NewGuid()}@email.com", + Password = "TestPassword123!" + }; + + var tenantClient = factory.HttpClient.WithTenantHeader(tenant.Name); + + var enrollment = await tenantClient.PostAsJsonAsync("api/v1/identity", credentials); + var identity = await enrollment.Content.ReadFromJsonAsync(); + + Assert.NotNull(identity); + Assert.Equal(HttpStatusCode.Created, enrollment.StatusCode); + + // arrange: authenticate new user + var authenticationCredentials = new AuthenticationCredentials + { + Username = credentials.Username, + Password = credentials.Password + }; + + var authenticationResponse = await tenantClient.PostAsJsonAsync("api/v1/identity/authenticate", authenticationCredentials); + var authenticationResult = await authenticationResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(authenticationResult); + Assert.NotEmpty(authenticationResult.AccessToken); + + tenantClient.WithAuthorization(authenticationResult.AccessToken); + + // arrange: generate PKCE + var codeVerifier = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"); + var codeChallenge = Application.Utilities.Base64UrlEncoder.Encode(SHA256.HashData(System.Text.Encoding.ASCII.GetBytes(codeVerifier))); + var codeChallengeMethod = "S256"; + + // arrange: get user from db + var filters = UserFilters.WithSpecifications() + .WithUsername(credentials.Username) + .Build(); + + var users = await userCollection.GetUsersAsync(filters); + var user = users.FirstOrDefault(); + + Assert.NotEmpty(users); + Assert.NotNull(user); + + // arrange: create authorization code token + var authorizationCode = Guid.NewGuid().ToString("N"); + var token = new Domain.Aggregates.SecurityToken + { + Value = authorizationCode, + UserId = user.Id, + TenantId = tenant.Id, + Type = TokenType.AuthorizationCode, + ExpiresAt = DateTime.UtcNow.AddMinutes(5), + Metadata = new Dictionary + { + ["code.challenge"] = codeChallenge, + ["code.challenge.method"] = codeChallengeMethod + } + }; + + await tokenCollection.InsertAsync(token); + + // arrange: prepare authorization_code grant request + var parameters = new Dictionary + { + { "grant_type", "authorization_code" }, + { "code", authorizationCode }, + { "client_id", tenant.ClientId }, + { "code_verifier", codeVerifier } + }; + + var content = new FormUrlEncodedContent(parameters); + var connectClient = factory.HttpClient.WithTenantHeader(tenant.Name); + + // act: send POST request to token endpoint + var response = await connectClient.PostAsync("api/v1/protocol/open-id/connect/token", content); + var grant = await response.Content.ReadFromJsonAsync(); + + // assert: response should be 200 OK + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(grant); + } +}