Skip to content

Commit

Permalink
feat: refactored and using latest eav with more universal provider ab…
Browse files Browse the repository at this point in the history
…straction
  • Loading branch information
pksorensen committed Aug 7, 2024
1 parent b52095a commit f7c0f79
Show file tree
Hide file tree
Showing 5 changed files with 145 additions and 55 deletions.
4 changes: 2 additions & 2 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<Project>
<Import Condition="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../')) != ''" Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
<PropertyGroup>
<LangVersion>10.0</LangVersion>
<EAVFrameworkVersion Condition="'$(EAVFrameworkVersion)' == ''">4.2.8</EAVFrameworkVersion>
<LangVersion>12.0</LangVersion>
<EAVFrameworkVersion Condition="'$(EAVFrameworkVersion)' == ''">4.5.0-dev.2</EAVFrameworkVersion>
<UseEAVFromNuget Condition="'$(UseEAVFromNuget)' == ''">true</UseEAVFromNuget>
<LocalEAVFrameworkPath Condition="'$(LocalEAVFrameworkPath)' == ''">$(MSBuildThisFileDirectory)/external/EAVFramework</LocalEAVFrameworkPath>
<LocalExternalpath Condition="'$(LocalExternalpath)' == ''">$(MSBuildThisFileDirectory)/external</LocalExternalpath>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
</ItemGroup>
<ItemGroup Condition="$(UseEAVFromNuget) != 'false'">
<PackageReference Include="EAVFramework" Version="$(EAVFrameworkVersion)" />
<PackageReference Include="EAVFW.Extensions.SecurityModel" Version="2.1.0" />
<PackageReference Include="EAVFW.Extensions.SecurityModel" Version="2.2.0" />
</ItemGroup>

<ItemGroup>
Expand All @@ -28,5 +28,7 @@
<ItemGroup>

<PackageReference Include="IdentityModel" Version="7.0.0" />

<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.0.1" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
using EAVFramework;
using EAVFramework.Authentication;
using EAVFramework.Authentication.Passwordless;
using EAVFramework.Configuration;
using EAVFramework.Endpoints;
using EAVFramework.Extensions;
using EAVFW.Extensions.SecurityModel;
using IdentityModel.Client;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.JsonWebTokens;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IdentityModel.Tokens.Jwt;
using System.IO;
using System.Linq;
Expand All @@ -20,99 +28,148 @@
namespace EAVFW.Extensions.EasyAuth.MicrosoftEntraId
{

public class MicrosoftEntraEasyAuthProvider<TSecurityGroup, TSecurityGroupMember> : IEasyAuthProvider
public class MicrosoftEntraEasyAuthProvider<TContext,TSecurityGroup, TSecurityGroupMember,TIdentity> : DefaultAuthProvider
where TContext : DynamicContext
where TSecurityGroup : DynamicEntity, IEntraIDSecurityGroup
where TSecurityGroupMember : DynamicEntity, ISecurityGroupMember, new()
where TIdentity : DynamicEntity, IIdentity
{
private readonly IOptions<MicrosoftEntraIdEasyAuthOptions> _options;
private readonly IHttpClientFactory _clientFactory;


public string AuthenticationName => "MicrosoftEntraId";

public HttpMethod CallbackHttpMethod => HttpMethod.Post;

public bool AutoGenerateRoutes { get; set; } = true;

public MicrosoftEntraEasyAuthProvider() { }
public MicrosoftEntraEasyAuthProvider() :base("MicrosoftEntraId", HttpMethod.Post) { }

public MicrosoftEntraEasyAuthProvider(
IOptions<MicrosoftEntraIdEasyAuthOptions> options,
IHttpClientFactory clientFactory)
IHttpClientFactory clientFactory) : this()
{
_options = options ?? throw new System.ArgumentNullException(nameof(options));
_clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory));
}

public async Task OnAuthenticate(HttpContext httpcontext, string handleId, string redirectUrl)
public override async Task<OnAuthenticateResult> OnAuthenticate(OnAuthenticateRequest authenticateRequest)
{
var email = httpcontext.Request.Query["email"].FirstOrDefault();
var redirectUri = httpcontext.Request.Query["redirectUri"].FirstOrDefault();
var callbackUri = $"{httpcontext.Request.Scheme}://{httpcontext.Request.Host}{httpcontext.Request.Path}/callback";


var ru = new RequestUrl(_options.Value.GetMicrosoftAuthorizationUrl(httpcontext));
var callbackurl = new Uri(authenticateRequest.CallbackUrl);

var ru = new RequestUrl(_options.Value.GetMicrosoftAuthorizationUrl(authenticateRequest.HttpContext));
var authUri = ru.CreateAuthorizeUrl(
clientId: _options.Value.ClientId,
redirectUri: callbackUri,
redirectUri: callbackurl.GetLeftPart(UriPartial.Path),
responseType: ResponseTypes.Code,
responseMode: ResponseModes.FormPost,
scope: _options.Value.Scope,
loginHint: String.IsNullOrEmpty(email) || email == "undefined" ? null : email,
state: handleId + "&" + redirectUri);
httpcontext.Response.Redirect(authUri);
loginHint: authenticateRequest.IdentityId.HasValue ?
await authenticateRequest.Options.FindEmailFromIdentity(
new EmailDiscoveryRequest
{
HttpContext = authenticateRequest.HttpContext,
IdentityId = authenticateRequest.IdentityId.Value,
ServiceProvider = authenticateRequest.ServiceProvider
}):null,
state: callbackurl.GetLeftPart(UriPartial.Path));

authenticateRequest.HttpContext.Response.Redirect(authUri);

return new OnAuthenticateResult { Success = true };
}

private async Task<ClaimsPrincipal> ValidateMicrosoftEntraIdUser(OnCallbackRequest request, Guid handleid, JsonWebToken jwtSecurityToken)
{





var user = await _options.Value.FindIdentityAsync(request, jwtSecurityToken.Claims);



var identity = new ClaimsIdentity(new[]
{

new Claim(IdentityModel.JwtClaimTypes.Subject, user.ToString()),
}, "MicrosoftEntraId");

return new ClaimsPrincipal(identity);


}

public async Task<(ClaimsPrincipal, string, string)> OnCallback(HttpContext httpcontext)
public override async Task PopulateCallbackRequest(OnCallbackRequest request)
{
var m = new IdentityModel.Client.AuthorizeResponse(await new StreamReader(httpcontext.Request.Body).ReadToEndAsync());
var state = m.State.Split(new char[] { '&' }, 2);
var handleId = state[0];
var redirectUri = state[1];
var callbackUri = $"{httpcontext.Request.Scheme}://{httpcontext.Request.Host}{httpcontext.Request.Path}";
var m = new IdentityModel.Client.AuthorizeResponse(await new StreamReader(request.HttpContext.Request.Body).ReadToEndAsync());

var query = QueryHelpers.ParseNullableQuery(m.State);

if (query.TryGetValue("token", out var handleid))
{
request.HandleId = new Guid(handleid);
}
request.Props.Add("code", m.Code);
request.Props.Add("state", m.State);


}
public override async Task<OnCallBackResult> OnCallback(OnCallbackRequest callbackRequest)
{
var httpcontext= callbackRequest.HttpContext;



var http = _clientFactory.CreateClient();
var response = await http.RequestAuthorizationCodeTokenAsync(new AuthorizationCodeTokenRequest
{
Address = _options.Value.GetMicrosoftTokenEndpoint(httpcontext),
ClientId = _options.Value.ClientId,
ClientSecret = _options.Value.ClientSecret,
Code = m.Code,
RedirectUri = callbackUri,
Code = callbackRequest.Props["code"],
RedirectUri = callbackRequest.Props["state"],
});

ClaimsPrincipal identity = await _options.Value.ValidateUserAsync(httpcontext, handleId, response);
var handler = new JsonWebTokenHandler();

var jwtSecurityToken = handler.ReadJsonWebToken(response.IdentityToken);


ClaimsPrincipal identity = await ValidateMicrosoftEntraIdUser(callbackRequest, callbackRequest.HandleId, jwtSecurityToken);

if (identity == null)
{
httpcontext.Response.Redirect($"{httpcontext.Request.Scheme}://{httpcontext.Request.Host}callback?error=access_denied&error_subcode=user_not_found");
//return;
return new OnCallBackResult { ErrorCode = "access_denied", ErrorSubCode = "user_validation_failed", ErrorMessage = "User could not be validated", Success = false };

}

var handler = new JwtSecurityTokenHandler();
var jwtSecurityToken = handler.ReadJwtToken(response.IdentityToken);

// Get string of group claims from the token

var groupClaims = jwtSecurityToken.Claims.Where(c => c.Type == "groups");
if (!groupClaims.Any())

if (!string.IsNullOrEmpty(_options.Value.GroupId))
{
httpcontext.Response.Redirect($"{httpcontext.Request.Scheme}://{httpcontext.Request.Host}callback?error=access_denied&error_subcode=group_not_found");
//return;

if (!groupClaims.Any(x=>x.Value == _options.Value.GroupId))
{
return new OnCallBackResult { ErrorCode = "access_denied", ErrorSubCode = "user_access_group_missing", ErrorMessage = "User does not have access", Success = false };

}
}
// Get the group ids from the claims
var groupIds = groupClaims.Select(c => new Guid(c.Value)).ToList();
var db = httpcontext.RequestServices.GetRequiredService<EAVDBContext<DynamicContext>>();

await SyncUserGroup(identity, groupIds, db);

return (identity, redirectUri, handleId);
return new OnCallBackResult { Principal = identity, Success = true };
}

private async Task SyncUserGroup(ClaimsPrincipal identity, List<Guid> groupIds, EAVDBContext<DynamicContext> db)
{
var claimDict = identity.Claims.ToDictionary(c => c.Type, c => c.Value);
var userId = new Guid(claimDict["sub"]);
var identityId = Guid.Parse(identity.FindFirstValue("sub"));

// Fetch all security group members for user
var groupMembersQuery = db.Set<TSecurityGroupMember>()
.Where(sgm => sgm.IdentityId == userId);
.Where(sgm => sgm.IdentityId == identityId);
// Fetch in memory
var groupMembersDict = await groupMembersQuery.ToDictionaryAsync(sgm => sgm.Id);

Expand All @@ -134,7 +191,7 @@ private async Task SyncUserGroup(ClaimsPrincipal identity, List<Guid> groupIds,
if (!sgmGroupSpecific.Any(sgm => sgm.SecurityGroupId == sg.Id))
{
var sgm = new TSecurityGroupMember();
sgm.IdentityId = userId;
sgm.IdentityId = identityId;
sgm.SecurityGroupId = sg.Id;
db.Add(sgm);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using EAVFramework;
using EAVFramework.Configuration;
using EAVFramework.Extensions;
using EAVFW.Extensions.SecurityModel;
using IdentityModel.Client;
using Microsoft.AspNetCore.Builder;
Expand All @@ -8,6 +9,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;

Expand All @@ -23,17 +25,20 @@ public static class MicrosoftEntraIdEasyAuthExtensions
{


public static AuthenticatedEAVFrameworkBuilder AddMicrosoftEntraIdEasyAuth<TSecurityGroup,TSecurityGroupMemeber>(
public static AuthenticatedEAVFrameworkBuilder AddMicrosoftEntraIdEasyAuth<TContext,TIdentity,TSecurityGroup,TSecurityGroupMemeber>(
this AuthenticatedEAVFrameworkBuilder builder,
Func<HttpContext, string, TokenResponse, Task<ClaimsPrincipal>> validateUserAsync,
Func<OnCallbackRequest, IEnumerable<Claim>, Task<Guid>> findIdentityAsync,
Func<HttpContext, string> getMicrosoftAuthorizationUrl , Func<HttpContext, string> getMicrosoftTokenEndpoint)
where TContext : DynamicContext
where TIdentity: DynamicEntity,IIdentity
where TSecurityGroup : DynamicEntity, IEntraIDSecurityGroup
where TSecurityGroupMemeber : DynamicEntity, ISecurityGroupMember, new()
{
builder.AddAuthenticationProvider<MicrosoftEntraEasyAuthProvider<TSecurityGroup,TSecurityGroupMemeber>, MicrosoftEntraIdEasyAuthOptions,IConfiguration>((options, config) =>
builder.AddAuthenticationProvider<MicrosoftEntraEasyAuthProvider<TContext,TSecurityGroup,TSecurityGroupMemeber,TIdentity>,
MicrosoftEntraIdEasyAuthOptions,IConfiguration>((options, config) =>
{
config.GetSection("EAVEasyAuth:MicrosoftEntraId").Bind(options);
options.ValidateUserAsync = validateUserAsync;
options.FindIdentityAsync = findIdentityAsync;
options.GetMicrosoftAuthorizationUrl = getMicrosoftAuthorizationUrl;
options.GetMicrosoftTokenEndpoint = getMicrosoftTokenEndpoint;
Expand All @@ -43,16 +48,18 @@ public static AuthenticatedEAVFrameworkBuilder AddMicrosoftEntraIdEasyAuth<TSecu
return builder;
}

public static AuthenticatedEAVFrameworkBuilder AddMicrosoftEntraIdEasyAuth<TSecurityGroup, TSecurityGroupMemeber>(
public static AuthenticatedEAVFrameworkBuilder AddMicrosoftEntraIdEasyAuth<TContext, TIdentity, TSecurityGroup, TSecurityGroupMemeber>(
this AuthenticatedEAVFrameworkBuilder builder,
Func<HttpContext, string, TokenResponse, Task<ClaimsPrincipal>> validateUserAsync)
Func<OnCallbackRequest, IEnumerable<Claim>, Task<Guid>> findIdentityAsync)
where TContext : DynamicContext
where TIdentity : DynamicEntity, IIdentity
where TSecurityGroup : DynamicEntity, IEntraIDSecurityGroup
where TSecurityGroupMemeber : DynamicEntity, ISecurityGroupMember,new()
{
builder.AddAuthenticationProvider<MicrosoftEntraEasyAuthProvider<TSecurityGroup, TSecurityGroupMemeber>, MicrosoftEntraIdEasyAuthOptions, IConfiguration>((options, config) =>
builder.AddAuthenticationProvider<MicrosoftEntraEasyAuthProvider<TContext,TSecurityGroup, TSecurityGroupMemeber, TIdentity>, MicrosoftEntraIdEasyAuthOptions, IConfiguration>((options, config) =>
{
config.GetSection("EAVEasyAuth:MicrosoftEntraId").Bind(options);
options.ValidateUserAsync = validateUserAsync;
options.FindIdentityAsync = findIdentityAsync;
});
builder.Services.AddScoped<GroupMatcherService<TSecurityGroup>>();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,46 @@
using EAVFramework;
using EAVFramework.Endpoints;
using EAVFramework.Extensions;
using EAVFW.Extensions.SecurityModel;
using IdentityModel.Client;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;

namespace EAVFW.Extensions.EasyAuth.MicrosoftEntraId
{
public class MicrosoftEntraIdEasyAuthOptions

{
/// <summary>
/// The Entra Client ID (applicationid) used to authenticate with EntraID
/// </summary>
public string ClientId { get; set; }
/// <summary>
/// The Entra Client Secret used to authenticate with EntraID
/// </summary>
public string ClientSecret { get; set; }
/// <summary>
/// The Entra Tenant ID used to authenticate with EntraID, if not provided the common tenant is used (multitenant signin)
/// </summary>
public string TenantId { get; set; }= "common";
/// <summary>
/// If provided the user should be part of this groupid to be given access
/// </summary>
public string GroupId { get; set; }
public string Scope { get; set; } = "openid email";


public string Scope { get; set; } = "openid email profile";

public Func<HttpContext, string> GetMicrosoftAuthorizationUrl { get; set; } = DefaultGetMicrosoftAuthorizationUrl;
public Func<HttpContext, string> GetMicrosoftTokenEndpoint { get; set; } = DefaultGetMicrosoftTokenEndpoint;
public Func<HttpContext, string, TokenResponse, Task<ClaimsPrincipal>> ValidateUserAsync { get; set; }
public Func<OnCallbackRequest, IEnumerable<Claim>, Task<Guid>> FindIdentityAsync { get; set; }

// public Func<HttpContext, string, TokenResponse, Task<ClaimsPrincipal>> ValidateUserAsync { get; set; }

private static string DefaultGetMicrosoftAuthorizationUrl(HttpContext context)
{
Expand All @@ -32,5 +54,7 @@ private static string DefaultGetMicrosoftTokenEndpoint(HttpContext context)
if (options.Value.TenantId == null) throw new Exception("TenantId is not configured");
return $"https://login.microsoftonline.com/{options.Value.TenantId}/oauth2/v2.0/token";
}


}
}

0 comments on commit f7c0f79

Please sign in to comment.