Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
e111c15
feat: this commit introduces records for parameters and authorization…
https-richardy Jan 22, 2026
915c83b
feat: this commit introduces code and codeverifier properties in clie…
https-richardy Jan 22, 2026
298320f
feat: introduce supportedgranttype static class with authorizationcod…
https-richardy Jan 22, 2026
4c8d125
feat: support authorization_code grant in credentials validator with …
https-richardy Jan 22, 2026
1b1eef7
feat: define redirecturi value object representing a redirect uri add…
https-richardy Jan 22, 2026
47cae23
feat: introduce redirecturis collection in tenant entity to support m…
https-richardy Jan 22, 2026
637daf3
feat: introduce global using for vinder.federation.domain.concepts
https-richardy Jan 22, 2026
2bd54e3
feat: introduce redirecturi policy interface to validate oauth redire…
https-richardy Jan 22, 2026
f85c9df
feat: define authorization errors class with standardized authorizati…
https-richardy Jan 22, 2026
0075d25
feat: introduce metadata property in securitytoken to support additio…
https-richardy Jan 22, 2026
d584680
feat: introduce authorizationcode value in tokentype enum to support …
https-richardy Jan 22, 2026
af25139
feat: introduce global usings for domain policies and concepts
https-richardy Jan 22, 2026
c49fb07
feat: implement redirecturipolicy for redirect uri validation per rfc…
https-richardy Jan 22, 2026
e9cfc17
feat: introduce global using for payloads.authorization namespace
https-richardy Jan 22, 2026
faeaaff
feat: implement authorization handler for authorization parameters va…
https-richardy Jan 22, 2026
1a604e0
feat: support authorization flow mapping through authorization and re…
https-richardy Jan 22, 2026
4ccb060
feat: align redirect uri validation with rfc 6749 section 3.1.2.3
https-richardy Jan 22, 2026
540d6ba
feat: define global using for system.security.cryptography namespace
https-richardy Jan 22, 2026
6f8d2ee
feat: introduce supported pkce methods class defining supported pkce …
https-richardy Jan 22, 2026
49bbb8f
feat: introduce pkcecodeverifier utility for pkce code verifier valid…
https-richardy Jan 22, 2026
cb23bab
feat: implement authorization parameters validator enforcing oauth2 p…
https-richardy Jan 22, 2026
af6612f
feat: introduce global usings for authorization payloads and validators
https-richardy Jan 22, 2026
241c91f
feat: register authorization parameters validator for authorization p…
https-richardy Jan 22, 2026
2878a2b
feat: define global usings for federation policies and identity handl…
https-richardy Jan 22, 2026
303dc2f
feat: enable redirect uri policy resolution through dependency injection
https-richardy Jan 22, 2026
df89cf3
feat: enable razor pages support in application pipeline
https-richardy Jan 22, 2026
907424d
feat: introduce global using for payloads.authorization
https-richardy Jan 22, 2026
2018e0e
feat: introduce global using for microsoft.aspnetcore.mvc.razorpages
https-richardy Jan 22, 2026
d4d75fc
feat: rework authorize page with async handlers and oauth openid auth…
https-richardy Jan 22, 2026
dd7623a
fix: fix global using syntax for vinder.federation.application.payloa…
https-richardy Jan 22, 2026
7cf33d8
feat: refactor authorization page with tailwind styling and authentic…
https-richardy Jan 22, 2026
18b3319
feat: expand authorization page error handling for tenant and redirec…
https-richardy Jan 22, 2026
ec2f4d1
feat: redesign authorization page with modern two column layout and r…
https-richardy Jan 22, 2026
6cbd9e9
feat: simplify login page by removing header and auxiliary options
https-richardy Jan 22, 2026
4255bda
this commit introduces suppression of CS8509 warnings in the project …
https-richardy Jan 23, 2026
5d08467
this commit introduces new global usings in Usings.cs to simplify acc…
https-richardy Jan 23, 2026
12ac0f6
this commit introduces a refactor of the OAuth authentication flow by…
https-richardy Jan 23, 2026
b62d441
this commit introduces the removal of an unused import to keep the co…
https-richardy Jan 23, 2026
3774258
this commit introduces a null-safe access to tenant identifiers by us…
https-richardy Jan 23, 2026
87336ad
this commit introduces explicit handling for invalid authorization co…
https-richardy Jan 23, 2026
59cad9a
this commit introduces null-safety when accessing tenantid in filters…
https-richardy Jan 24, 2026
6b80ca7
this commit introduces an end-to-end integration test for the openid …
https-richardy Jan 24, 2026
0c0237b
this commit refactors the token test to use a dynamic and isolated fl…
https-richardy Jan 24, 2026
2c6cb72
this commit introduces improvements to the oauth2 authorization code …
https-richardy Jan 24, 2026
0c96ccf
this commit introduces the [stability] attribute on api controller en…
https-richardy Jan 24, 2026
0caca96
this commit removes the biometric authentication card from the author…
https-richardy Jan 24, 2026
b015029
this commit updates the authorization screen title and description to…
https-richardy Jan 24, 2026
02ef382
this commit updates the authorize page route from /federation/authori…
https-richardy Jan 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Vinder.Federation.Application.Contracts;

public interface IAuthorizationFlowHandler
{
public Task<Result<ClientAuthenticationResult>> HandleAsync(
ClientAuthenticationCredentials parameters,
CancellationToken cancellation = default
);
}
Original file line number Diff line number Diff line change
@@ -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<Result<ClientAuthenticationResult>> 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<ClientAuthenticationResult>.Failure(AuthorizationErrors.InvalidAuthorizationCode);
}

if (token.IsExpired)
{
return Result<ClientAuthenticationResult>.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<ClientAuthenticationResult>.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<ClientAuthenticationResult>.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<ClientAuthenticationResult>.Failure(AuthenticationErrors.UserNotFound);
}

var tokenResult = await tokenService.GenerateAccessTokenAsync(user, cancellation);
if (tokenResult.IsFailure || tokenResult.Data is null)
{
return Result<ClientAuthenticationResult>.Failure(tokenResult.Error);
}

return Result<ClientAuthenticationResult>.Success(new()
{
AccessToken = tokenResult.Data.Value
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace Vinder.Federation.Application.Handlers.Authorization;

public sealed class AuthorizationHandler(ITenantCollection tenantCollection, IRedirectUriPolicy redirectUriPolicy) :
IMessageHandler<AuthorizationParameters, Result<AuthorizationScheme>>
{
public async Task<Result<AuthorizationScheme>> 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<AuthorizationScheme>.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<AuthorizationScheme>.Failure(redirectProof.Error);
}

return Result<AuthorizationScheme>.Success(parameters.AsReponse());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
namespace Vinder.Federation.Application.Handlers.Authorization;

public sealed class ClientCredentialsGrantHandler(ITenantCollection tenantCollection, ISecurityTokenService tokenService) : IAuthorizationFlowHandler
{
public async Task<Result<ClientAuthenticationResult>> 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<ClientAuthenticationResult>.Failure(AuthenticationErrors.ClientNotFound);
}

if (parameters.ClientSecret != tenant.SecretHash)
{
return Result<ClientAuthenticationResult>.Failure(AuthenticationErrors.InvalidClientCredentials);
}

var tokenResult = await tokenService.GenerateAccessTokenAsync(tenant, cancellation);
if (tokenResult.IsFailure)
{
return Result<ClientAuthenticationResult>.Failure(tokenResult.Error);
}

var response = new ClientAuthenticationResult
{
AccessToken = tokenResult.Data!.Value
};

return Result<ClientAuthenticationResult>.Success(response);
}
}
Original file line number Diff line number Diff line change
@@ -1,41 +1,17 @@
namespace Vinder.Federation.Application.Handlers.Identity;

public sealed class ClientAuthenticationHandler(
ITenantCollection tenantCollection,
ISecurityTokenService tokenService
) : IMessageHandler<ClientAuthenticationCredentials, Result<ClientAuthenticationResult>>
public sealed class ClientAuthenticationHandler(ITenantCollection tenantCollection, IUserCollection userCollection, ITokenCollection tokenCollection, ISecurityTokenService tokenService) :
IMessageHandler<ClientAuthenticationCredentials, Result<ClientAuthenticationResult>>
{
public async Task<Result<ClientAuthenticationResult>> 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<ClientAuthenticationResult>.Failure(AuthenticationErrors.ClientNotFound);
}

if (parameters.ClientSecret != tenant.SecretHash)
{
return Result<ClientAuthenticationResult>.Failure(AuthenticationErrors.InvalidClientCredentials);
}

var tokenResult = await tokenService.GenerateAccessTokenAsync(tenant, cancellation);
if (tokenResult.IsFailure)
{
return Result<ClientAuthenticationResult>.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<ClientAuthenticationResult>.Success(response);
return await handler.HandleAsync(parameters, cancellation);
}
}
}
Original file line number Diff line number Diff line change
@@ -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,
};
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
using Microsoft.IdentityModel.Tokens;
using Vinder.Federation.Application.Payloads.Connect;

namespace Vinder.Federation.Application.Mappers;

public static class JsonWebKeysMapper
Expand All @@ -25,4 +22,4 @@ public static JsonWebKeySetScheme AsJsonWebKeySetScheme(Secret secret)
Keys = [JsonWebKeysMapper.AsJsonWebKeys(secret)]
};
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Vinder.Federation.Application.Mappers;

public static class RedirectUriMapper
{
public static RedirectUri AsUri(this string uri) =>
new(uri);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Vinder.Federation.Application.Payloads.Authorization;

public sealed record AuthorizationParameters : IMessage<Result<AuthorizationScheme>>
{
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!;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ namespace Vinder.Federation.Application.Payloads.Identity;
public sealed record ClientAuthenticationCredentials : IMessage<Result<ClientAuthenticationResult>>
{
public string GrantType { get; init; } = default!;

// for client_credentials grant type
public string ClientId { get; init; } = default!;
public string ClientSecret { get; init; } = default!;
}

// for authorization_code grant type
public string Code { get; init; } = default!;
public string CodeVerifier { get; init; } = default!;
}
17 changes: 17 additions & 0 deletions Source/Vinder.Federation.Application/Policies/RedirectUriPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Vinder.Federation.Application.Policies;

public sealed class RedirectUriPolicy : IRedirectUriPolicy
{
public async Task<Result> 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);
}
}
7 changes: 7 additions & 0 deletions Source/Vinder.Federation.Application/Usings.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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;
31 changes: 31 additions & 0 deletions Source/Vinder.Federation.Application/Utilities/PkceCodeVerifier.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading