diff --git a/src/Gemstone.Web/APIController/AuthorizationInfoControllerBase.cs b/src/Gemstone.Web/APIController/AuthorizationInfoControllerBase.cs
new file mode 100644
index 0000000..dd4d8c9
--- /dev/null
+++ b/src/Gemstone.Web/APIController/AuthorizationInfoControllerBase.cs
@@ -0,0 +1,268 @@
+//******************************************************************************************************
+// AuthorizationInfoControllerBase.cs - Gbtc
+//
+// Copyright © 2025, Grid Protection Alliance. All Rights Reserved.
+//
+// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See
+// the NOTICE file distributed with this work for additional information regarding copyright ownership.
+// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at:
+//
+// http://opensource.org/licenses/MIT
+//
+// Unless agreed to in writing, the subject software distributed under the License is distributed on an
+// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the
+// License for the specific language governing permissions and limitations.
+//
+// Code Modification History:
+// ----------------------------------------------------------------------------------------------------
+// 07/15/2025 - Stephen C. Wills
+// Generated original version of source code.
+//
+//******************************************************************************************************
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Gemstone.Collections.CollectionExtensions;
+using Gemstone.Security.AccessControl;
+using Gemstone.Security.AuthenticationProviders;
+using Gemstone.Web.Security;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Controllers;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Gemstone.Web.APIController;
+
+///
+/// Base class for a controller that provides information about claims to client applications.
+///
+public abstract partial class AuthorizationInfoControllerBase : ControllerBase
+{
+ #region [ Members ]
+
+ // Nested Types
+
+ ///
+ /// Represents an entry in a resource access list.
+ ///
+ public class ResourceAccessEntry
+ {
+ ///
+ /// Gets or sets the type of the resource.
+ ///
+ public string ResourceType { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the name of the resource.
+ ///
+ public string ResourceName { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the level of access needed.
+ ///
+ public ResourceAccessType Access { get; set; }
+ }
+
+ #endregion
+
+ #region [ Methods ]
+
+ ///
+ /// Gets all the claims associated with the authenticated user.
+ ///
+ /// All of the authenticated user's claims.
+ [HttpGet, Route("user/claims")]
+ public virtual IActionResult GetAllClaims()
+ {
+ var claims = HttpContext.User.Claims
+ .Select(claim => new { claim.Type, claim.Value });
+
+ return Ok(claims);
+ }
+
+ ///
+ /// Gets the claims of a given type associated with the authenticated user.
+ ///
+ /// The type of the claims to be returned
+ /// The authenticated user's claims of the given type.
+ [HttpGet, Route("user/claims/{**claimType}")]
+ public virtual IEnumerable GetClaims(string claimType)
+ {
+ return HttpContext.User
+ .FindAll(claim => claim.Type == claimType)
+ .Select(claim => claim.Value);
+ }
+
+ ///
+ /// Gets the types of claims that the authentication provider associates with its users.
+ ///
+ /// Provides injected dependencies
+ /// Identity of the authentication provider
+ /// Text used to narrow the list of results
+ /// The list of claim types used by the authentication provider.
+ [HttpGet, Route("provider/{providerIdentity}/claimTypes")]
+ public virtual IActionResult FindClaimTypes(IServiceProvider serviceProvider, string providerIdentity, string? searchText)
+ {
+ IAuthenticationProvider? claimsProvider = serviceProvider
+ .GetKeyedService(providerIdentity);
+
+ if (claimsProvider is null)
+ return NotFound();
+
+ Regex? searchPattern = ToSearchPattern(searchText);
+
+ var claimTypes = claimsProvider
+ .GetClaimTypes()
+ .Where(type => searchPattern is null || searchPattern.IsMatch(type.Type))
+ .Select(type => new { Value = type.Type, Label = type.Alias, LongLabel = type.Description });
+
+ return Ok(claimTypes);
+ }
+
+ ///
+ /// Gets a list of claims that can be assigned to users who authenticate with the provider.
+ ///
+ /// Provides injected dependencies
+ /// Identity of the authentication provider
+ /// The type of claims to search for
+ /// Text used to narrow the list of results
+ /// A list of claims from the authentication provider.
+ [HttpGet, Route("provider/{providerIdentity}/claims/{**claimType}")]
+ public virtual IActionResult FindClaims(IServiceProvider serviceProvider, string providerIdentity, string claimType, string? searchText)
+ {
+ IAuthenticationProvider? claimsProvider = serviceProvider
+ .GetKeyedService(providerIdentity);
+
+ if (claimsProvider is null)
+ return NotFound();
+
+ if (!isSupported(claimType))
+ return BadRequest($"Claim type not supported");
+
+ var claims = claimsProvider
+ .FindClaims(claimType, searchText ?? "*")
+ .Select(claim => new { Label = claim.Description, claim.Value, LongLabel = claim.LongDescription });
+
+ return Ok(claims);
+
+ bool isSupported(string claimType) => claimsProvider
+ .GetClaimTypes()
+ .Any(type => type.Type == claimType);
+ }
+
+ ///
+ /// Gets a list of resources available for which permissions can be granted within the application.
+ ///
+ /// Provides authorization policies defined within the application
+ /// Source for endpoint data used to look up controller and action metadata
+ /// A list of resources within the application.
+ [HttpGet, Route("resources")]
+ public virtual async Task GetResources(IAuthorizationPolicyProvider policyProvider, EndpointDataSource endpointDataSource)
+ {
+ Dictionary> resourceAccessLookup = [];
+
+ foreach (Endpoint endpoint in endpointDataSource.Endpoints)
+ {
+ ControllerActionDescriptor? descriptor = endpoint.Metadata
+ .GetMetadata();
+
+ if (descriptor is null)
+ continue;
+
+ IReadOnlyList authorizeData = endpoint.Metadata.GetOrderedMetadata() ?? [];
+ IReadOnlyList policies = endpoint.Metadata.GetOrderedMetadata() ?? [];
+ IReadOnlyList requirementData = endpoint.Metadata.GetOrderedMetadata() ?? [];
+ AuthorizationPolicy? policy = await AuthorizationPolicy.CombineAsync(policyProvider, authorizeData, policies);
+
+ bool hasControllerAccessRequirement = requirementData
+ .SelectMany(datum => datum.GetRequirements())
+ .Concat(policy?.Requirements ?? [])
+ .Any(requirement => requirement is ControllerAccessRequirement);
+
+ if (!hasControllerAccessRequirement)
+ continue;
+
+ ResourceAccessAttribute? accessAttribute = endpoint.Metadata
+ .GetMetadata();
+
+ string resourceName = accessAttribute.GetResourceName(descriptor);
+ IEnumerable accessTypes = ToAccessTypes(endpoint, accessAttribute);
+ HashSet access = resourceAccessLookup.GetOrAdd(resourceName, _ => []);
+ access.UnionWith(accessTypes);
+ }
+
+ var resources = resourceAccessLookup
+ .OrderBy(kvp => kvp.Key)
+ .Select(kvp => new
+ {
+ Type = "Controller",
+ Name = kvp.Key,
+ AccessTypes = kvp.Value.OrderBy(type => type)
+ });
+
+ return Ok(resources);
+
+ static IEnumerable ToAccessTypes(Endpoint endpoint, ResourceAccessAttribute? accessAttribute)
+ {
+ if (accessAttribute is not null)
+ return [accessAttribute.Access];
+
+ HttpMethodMetadata? httpMethodMetadata = endpoint.Metadata
+ .GetMetadata();
+
+ IReadOnlyList httpMethods = httpMethodMetadata?.HttpMethods
+ ?? [];
+
+ return httpMethods
+ .Select(accessAttribute.GetAccessType)
+ .Where(type => type is not null)
+ .Select(type => type.GetValueOrDefault());
+ }
+ }
+
+ ///
+ /// Checks whether the user has permission to access each of the resources listed in the access list.
+ ///
+ /// List of resources the user is attempting to access
+ /// List of boolean values indicating whether the user has access to each requested resource.
+ [HttpPost, Route("access")]
+ public virtual IEnumerable CheckAccess([FromBody] ResourceAccessEntry[] accessList)
+ {
+ return accessList.Select(entry => User.HasAccessTo(entry.ResourceType, entry.ResourceName, entry.Access));
+ }
+
+ #endregion
+
+ #region [ Static ]
+
+ // Static Methods
+
+ private static Regex? ToSearchPattern(string? searchText)
+ {
+ if (searchText is null)
+ return null;
+
+ Regex conversionPattern = SearchTextConversionPattern();
+
+ string searchPattern = conversionPattern.Replace(searchText, match => match.Value switch
+ {
+ "*" => ".*",
+ @"\\" => @"\\",
+ string v when v.StartsWith('\\') => Regex.Escape(v[1..]),
+ string v => Regex.Escape(v)
+ });
+
+ return new(searchPattern);
+ }
+
+ [GeneratedRegex(@"\\.|\*|[^\\*]+")]
+ private static partial Regex SearchTextConversionPattern();
+
+ #endregion
+}
diff --git a/src/Gemstone.Web/APIController/ClaimsControllerBase.cs b/src/Gemstone.Web/APIController/ClaimsControllerBase.cs
deleted file mode 100644
index d7aac93..0000000
--- a/src/Gemstone.Web/APIController/ClaimsControllerBase.cs
+++ /dev/null
@@ -1,117 +0,0 @@
-//******************************************************************************************************
-// ClaimsControllerBase.cs - Gbtc
-//
-// Copyright © 2025, Grid Protection Alliance. All Rights Reserved.
-//
-// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See
-// the NOTICE file distributed with this work for additional information regarding copyright ownership.
-// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this
-// file except in compliance with the License. You may obtain a copy of the License at:
-//
-// http://opensource.org/licenses/MIT
-//
-// Unless agreed to in writing, the subject software distributed under the License is distributed on an
-// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the
-// License for the specific language governing permissions and limitations.
-//
-// Code Modification History:
-// ----------------------------------------------------------------------------------------------------
-// 07/15/2025 - Stephen C. Wills
-// Generated original version of source code.
-//
-//******************************************************************************************************
-
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using Gemstone.Security.AuthenticationProviders;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace Gemstone.Web.APIController;
-
-///
-/// Base class for a controller that provides information about claims to client applications.
-///
-public abstract class ClaimsControllerBase : ControllerBase
-{
- ///
- /// Gets all the claims associated with the authenticated user.
- ///
- /// All of the authenticated user's claims.
- [HttpGet, Route("claims")]
- public IActionResult GetAllClaims()
- {
- var claims = HttpContext.User.Claims
- .Select(claim => new { claim.Type, claim.Value });
-
- return Ok(claims);
- }
-
- ///
- /// Gets the claims of a given type associated with the authenticated user.
- ///
- /// The type of the claims to be returned
- /// The authenticated user's claims of the given type.
- [HttpGet, Route("claims/{**claimType}")]
- public IEnumerable GetClaims(string claimType)
- {
- return HttpContext.User
- .FindAll(claim => claim.Type == claimType)
- .Select(claim => claim.Value);
- }
-
- ///
- /// Gets the types of claims that the authentication provider associates with its users.
- ///
- /// Provides injected dependencies
- /// Identity of the authentication provider
- /// The list of claim types used by the authentication provider.
- [HttpGet, Route("provider/{providerIdentity}/claimTypes")]
- public IActionResult GetClaimTypes(IServiceProvider serviceProvider, string providerIdentity)
- {
- IAuthenticationClaimsProvider? claimsProvider = serviceProvider
- .GetKeyedService(providerIdentity);
-
- return claimsProvider is not null
- ? Ok(claimsProvider.GetClaimTypes())
- : NotFound();
- }
-
- ///
- /// Gets a list of users that can be authorized via the authentication provider.
- ///
- /// Provides injected dependencies
- /// Identity of the authentication provider
- /// Text used to narrow the list of results
- /// A list of users from the authentication provider.
- [HttpGet, Route("provider/{providerIdentity}/users")]
- public IActionResult FindUsers(IServiceProvider serviceProvider, string providerIdentity, string? searchText)
- {
- IAuthenticationClaimsProvider? claimsProvider = serviceProvider
- .GetKeyedService(providerIdentity);
-
- return claimsProvider is not null
- ? Ok(claimsProvider.FindUsers(searchText ?? "*"))
- : NotFound();
- }
-
- ///
- /// Gets a list of claims that can be assigned to users who authenticate with the provider.
- ///
- /// Provides injected dependencies
- /// Identity of the authentication provider
- /// The type of claims to search for
- /// Text used to narrow the list of results
- /// A list of claims from the authentication provider.
- [HttpGet, Route("provider/{providerIdentity}/claims/{**claimType}")]
- public IActionResult FindClaims(IServiceProvider serviceProvider, string providerIdentity, string claimType, string? searchText)
- {
- IAuthenticationClaimsProvider? claimsProvider = serviceProvider
- .GetKeyedService(providerIdentity);
-
- return claimsProvider is not null
- ? Ok(claimsProvider.FindClaims(claimType, searchText ?? "*"))
- : NotFound();
- }
-}
diff --git a/src/Gemstone.Web/APIController/ModelController.cs b/src/Gemstone.Web/APIController/ModelController.cs
index 542c280..b2e096a 100644
--- a/src/Gemstone.Web/APIController/ModelController.cs
+++ b/src/Gemstone.Web/APIController/ModelController.cs
@@ -44,33 +44,10 @@ namespace Gemstone.Web.APIController
///
/// Creates a new
///
- public ModelController()
- {
- DeleteRoles = typeof(T).GetCustomAttribute()?.Roles ?? "";
- PostRoles = typeof(T).GetCustomAttribute()?.Roles ?? "";
- PatchRoles = typeof(T).GetCustomAttribute()?.Roles ?? "";
- }
-
+ public ModelController() { }
+
#endregion
- #region [ Properties ]
-
- ///
- /// Gets the roles required for PATCH requests.
- ///
- protected string PatchRoles { get; }
-
- ///
- /// Gets the roles required for POST requests.
- ///
- protected string PostRoles { get; }
-
- ///
- /// Gets the roles required for DELETE requests.
- ///
- protected string DeleteRoles { get; }
-
- #endregion
///
/// Updates a record from associated table.
@@ -81,9 +58,6 @@ public ModelController()
[HttpPatch, Route("")]
public virtual async Task Patch([FromBody] T record, CancellationToken cancellationToken)
{
- if (!PatchAuthCheck())
- return Unauthorized();
-
await using AdoDataConnection connection = CreateConnection();
TableOperations tableOperations = new(connection);
await tableOperations.UpdateRecordAsync(record, cancellationToken);
@@ -100,9 +74,6 @@ public virtual async Task Patch([FromBody] T record, Cancellation
[HttpPost, Route("")]
public virtual async Task Post([FromBody]T record, CancellationToken cancellationToken)
{
- if (!PostAuthCheck())
- return Unauthorized();
-
await using AdoDataConnection connection = CreateConnection();
TableOperations tableOperations = new(connection);
await tableOperations.AddNewRecordAsync(record, cancellationToken);
@@ -120,9 +91,6 @@ public virtual async Task Post([FromBody]T record, CancellationTo
[HttpDelete, Route("")]
public virtual async Task Delete([FromBody] T record, CancellationToken cancellationToken)
{
- if (!DeleteAuthCheck())
- return Unauthorized();
-
await using AdoDataConnection connection = CreateConnection();
TableOperations tableOperations = new(connection);
await tableOperations.DeleteRecordAsync(record, cancellationToken);
@@ -139,9 +107,6 @@ public virtual async Task Delete([FromBody] T record, Cancellatio
[HttpDelete, Route("{id}")]
public virtual async Task Delete(string id, CancellationToken cancellationToken)
{
- if(!DeleteAuthCheck())
- return Unauthorized();
-
await using AdoDataConnection connection = CreateConnection();
TableOperations tableOperations = new(connection);
await tableOperations.DeleteRecordWhereAsync($"{PrimaryKeyField} = {{0}}", cancellationToken, id);
@@ -149,36 +114,7 @@ public virtual async Task Delete(string id, CancellationToken can
return Ok(1);
}
- #region [ Methods ]
-
- ///
- /// Check if current user is authorized for POST Requests.
- ///
- /// true if User is authorized for POST requests; otherwise, false.
- protected virtual bool PostAuthCheck()
- {
- return PostRoles == string.Empty || User.IsInRole(PostRoles);
- }
-
- ///
- /// Check if current user is authorized for DELETE Requests.
- ///
- /// true if User is authorized for DELETE requests; otherwise, false.
- protected virtual bool DeleteAuthCheck()
- {
- return DeleteRoles == string.Empty || User.IsInRole(DeleteRoles);
- }
-
- ///
- /// Check if current user is authorized for PATCH Requests.
- ///
- /// true if User is authorized for PATCH requests; otherwise, false.
- protected virtual bool PatchAuthCheck()
- {
- return PatchRoles == string.Empty || User.IsInRole(PatchRoles);
- }
-
- #endregion
+
#region [ Static ]
diff --git a/src/Gemstone.Web/APIController/ReadOnlyModelController.cs b/src/Gemstone.Web/APIController/ReadOnlyModelController.cs
index b7fcb70..ba5c6fb 100644
--- a/src/Gemstone.Web/APIController/ReadOnlyModelController.cs
+++ b/src/Gemstone.Web/APIController/ReadOnlyModelController.cs
@@ -33,6 +33,7 @@
using Gemstone.Configuration;
using Gemstone.Data;
using Gemstone.Data.Model;
+using Gemstone.Security.AccessControl;
using Microsoft.AspNetCore.Mvc;
namespace Gemstone.Web.APIController
@@ -174,9 +175,6 @@ public ReadOnlyModelController()
[HttpGet, Route("Open/{filterExpression}/{parameters}/{expiration:double?}")]
public Task Open(string? filterExpression, object?[] parameters, double? expiration, CancellationToken cancellationToken)
{
- if (!GetAuthCheck())
- return Task.FromResult(Unauthorized());
-
ConnectionCache cache = ConnectionCache.Create(expiration ?? 1.0D);
cache.Records = cache.Table.QueryRecordsWhereAsync(filterExpression, cancellationToken, parameters).GetAsyncEnumerator(cancellationToken);
@@ -199,9 +197,6 @@ public Task Open(string? filterExpression, object?[] parameters,
[HttpGet, Route("Next/{token}/{count:int?}")]
public async Task Next(string token, int? count, CancellationToken cancellationToken)
{
- if (!GetAuthCheck())
- return Unauthorized();
-
if (!ConnectionCache.TryGet(token, out ConnectionCache? cache) || cache is null)
return NotFound();
@@ -228,9 +223,6 @@ public async Task Next(string token, int? count, CancellationToke
[HttpGet, Route("Close/{token}")]
public IActionResult Close(string token)
{
- if (!GetAuthCheck())
- return Unauthorized();
-
return ConnectionCache.Close(token) ? Ok() : NotFound();
}
@@ -244,9 +236,6 @@ public IActionResult Close(string token)
[HttpGet, Route("{page:min(0)}/{parentID?}")]
public virtual async Task Get(string? parentID, int page, CancellationToken cancellationToken)
{
- if (!GetAuthCheck())
- return Unauthorized();
-
await using AdoDataConnection connection = CreateConnection();
TableOperations tableOperations = new(connection);
RecordFilter? filter = null;
@@ -277,9 +266,6 @@ public virtual async Task Get(string? parentID, int page, Cancell
[HttpGet, Route("{page:min(0)}/{sort}/{ascending:bool}")]
public virtual async Task Get(string sort, bool ascending, int page, CancellationToken cancellationToken)
{
- if (!GetAuthCheck())
- return Unauthorized();
-
await using AdoDataConnection connection = CreateConnection();
TableOperations tableOperations = new(connection);
RecordFilter? filter = null;
@@ -301,9 +287,6 @@ public virtual async Task Get(string sort, bool ascending, int pa
[HttpGet, Route("{page:min(0)}/{parentID}/{sort}/{ascending:bool}")]
public virtual async Task Get(string parentID, string sort, bool ascending, int page, CancellationToken cancellationToken)
{
- if (!GetAuthCheck())
- return Unauthorized();
-
await using AdoDataConnection connection = CreateConnection();
TableOperations tableOperations = new(connection);
RecordFilter filter = new()
@@ -327,9 +310,6 @@ public virtual async Task Get(string parentID, string sort, bool
[HttpGet, Route("One/{id}")]
public virtual async Task GetOne(string id, CancellationToken cancellationToken)
{
- if (!GetAuthCheck())
- return Unauthorized();
-
await using AdoDataConnection connection = CreateConnection();
TableOperations tableOperations = new(connection);
T? result = await tableOperations.QueryRecordAsync(new RecordRestriction($"{PrimaryKeyField} = {{0}}", id), cancellationToken).ConfigureAwait(false);
@@ -348,11 +328,9 @@ public virtual async Task GetOne(string id, CancellationToken can
/// Propagates notification that operations should be canceled.
/// An containing or .
[HttpPost, Route("Search/{page:min(0)}/{parentID?}")]
+ [ResourceAccess(ResourceAccessType.Read)]
public virtual async Task Search([FromBody] SearchPost postData, int page, string? parentID, CancellationToken cancellationToken)
{
- if (!GetAuthCheck())
- return Unauthorized();
-
await using AdoDataConnection connection = CreateConnection();
TableOperations tableOperations = new(connection);
RecordFilter[] filters = postData.Searches.ToArray();
@@ -380,11 +358,9 @@ public virtual async Task Search([FromBody] SearchPost postDat
/// Propagates notification that operations should be canceled.
/// A object containing the pagination information or .
[HttpPost, Route("PageInfo/{parentID?}")]
+ [ResourceAccess(ResourceAccessType.Read)]
public virtual async Task GetPageInfo(SearchPost postData, string? parentID, CancellationToken cancellationToken)
{
- if (!GetAuthCheck())
- return Unauthorized();
-
await using AdoDataConnection connection = CreateConnection();
TableOperations tableOperations = new(connection);
RecordFilter[] filters = postData.Searches.ToArray();
@@ -419,9 +395,6 @@ public virtual async Task GetPageInfo(SearchPost postData, str
[HttpGet, Route("PageInfo/{parentID?}")]
public virtual async Task GetPageInfo(string? parentID, CancellationToken cancellationToken)
{
- if (!GetAuthCheck())
- return Unauthorized();
-
await using AdoDataConnection connection = CreateConnection();
TableOperations tableOperations = new(connection);
RecordFilter[] filters = [];
@@ -454,9 +427,6 @@ public virtual async Task GetPageInfo(string? parentID, Cancellat
[HttpGet, Route("New")]
public virtual async Task New(CancellationToken cancellationToken)
{
- if (!GetAuthCheck())
- return Unauthorized();
-
await using AdoDataConnection connection = CreateConnection();
TableOperations tableOperations = new(connection);
@@ -473,9 +443,6 @@ public virtual async Task New(CancellationToken cancellationToken
[HttpGet, Route("Max/{fieldName}")]
public virtual async Task GetMaxValue(string fieldName, CancellationToken cancellationToken)
{
- if (!GetAuthCheck())
- return Unauthorized();
-
// Validate that the field exists on the model T using reflection
PropertyInfo? property = typeof(T).GetProperty(fieldName);
if (property is null)
@@ -492,15 +459,6 @@ public virtual async Task GetMaxValue(string fieldName, Cancellat
return Ok(maxValue ?? 0);
}
- ///
- /// Check if current User is authorized for GET Requests.
- ///
- /// true if User is authorized for GET requests; otherwise, false.
- protected virtual bool GetAuthCheck()
- {
- return GetRoles == string.Empty || User.IsInRole(GetRoles);
- }
-
///
/// Create the for the controller.
///
diff --git a/src/Gemstone.Web/Gemstone.Web.csproj b/src/Gemstone.Web/Gemstone.Web.csproj
index a8d1e9b..dd9f46d 100644
--- a/src/Gemstone.Web/Gemstone.Web.csproj
+++ b/src/Gemstone.Web/Gemstone.Web.csproj
@@ -63,6 +63,7 @@
+
diff --git a/src/Gemstone.Web/Hosting/AuthenticationSessionMiddleware.cs b/src/Gemstone.Web/Hosting/AuthenticationSessionMiddleware.cs
deleted file mode 100644
index ec712c8..0000000
--- a/src/Gemstone.Web/Hosting/AuthenticationSessionMiddleware.cs
+++ /dev/null
@@ -1,280 +0,0 @@
-//******************************************************************************************************
-// AuthenticationSessionMiddleware.cs - Gbtc
-//
-// Copyright © 2025, Grid Protection Alliance. All Rights Reserved.
-//
-// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See
-// the NOTICE file distributed with this work for additional information regarding copyright ownership.
-// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this
-// file except in compliance with the License. You may obtain a copy of the License at:
-//
-// http://opensource.org/licenses/MIT
-//
-// Unless agreed to in writing, the subject software distributed under the License is distributed on an
-// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the
-// License for the specific language governing permissions and limitations.
-//
-// Code Modification History:
-// ----------------------------------------------------------------------------------------------------
-// 04/18/2025 - Stephen C. Wills
-// Generated original version of source code.
-//
-//******************************************************************************************************
-
-using System;
-using System.Security.Claims;
-using System.Security.Cryptography;
-using System.Threading.Tasks;
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.Caching.Memory;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Options;
-
-namespace Gemstone.Web.Hosting;
-
-///
-/// Represents configuration options for the .
-///
-public class AuthenticationSessionOptions
-{
- ///
- /// Gets or sets the base path of the application to which the cookie will be scoped.
- ///
- public string ApplicationBasePath { get; set; } = "/";
-
- ///
- /// Gets or sets the name of the cookie used for storing the authentication session token.
- ///
- public string AuthenticationSessionCookie { get; set; } = "x-gemstone-auth";
-
- ///
- /// Gets or sets the amount of time the server will continue to store
- /// the authentication session since the last request made by the user.
- ///
- public double IdleAuthenticationTokenExpirationMinutes { get; set; } = 15.0D;
-
- ///
- /// Gets or sets the amount of time after the authenticated
- /// session is established before the session cookie expires.
- ///
- public double AuthenticationSessionExpirationHours { get; set; } = 24.0D;
-
- ///
- /// Gets the amount of time the server will continue to store the
- /// authentication session since the last request made by the user.
- ///
- public TimeSpan AuthenticationTokenExpiration =>
- TimeSpan.FromMinutes(IdleAuthenticationTokenExpirationMinutes);
-
- ///
- /// Gets the amount of time after the authenticated session
- /// is established before the session cookie expires.
- ///
- public DateTimeOffset AuthenticationCookieExpiration =>
- DateTime.UtcNow.AddHours(AuthenticationSessionExpirationHours);
-}
-
-///
-/// Represents a middleware used to establish an authenticated session between the client and server.
-///
-/// Delegate used to invoke the next middleware in the pipeline.
-/// Cache used to store session data.
-/// Options used to configure how the session is managed.
-public class AuthenticationSessionMiddleware(RequestDelegate next, IMemoryCache memoryCache, IOptionsMonitor options)
-{
- #region [ Properties ]
-
- private RequestDelegate Next { get; } = next;
- private IMemoryCache MemoryCache { get; } = memoryCache;
- private AuthenticationSessionOptions Options => options.CurrentValue;
-
- #endregion
-
- #region [ Methods ]
-
- ///
- /// Handles an HTTP request.
- ///
- /// The object providing context about the request.
- /// Task that indicates when the middleware is finished processing the request.
- public async Task Invoke(HttpContext httpContext)
- {
- AuthenticationSessionOptions options = Options;
-
- if (httpContext.Request.Headers.ContainsKey("Authorization"))
- await HandleRequestWithCredentialsAsync(httpContext, options);
- else
- await HandleRequestAsync(httpContext, options);
- }
-
- // If a request with credentials is authenticated, create a new session and provide a session cookie.
- // If a request with credentials is not authenticated, flush session data and expire the session cookie.
- private async Task HandleRequestWithCredentialsAsync(HttpContext httpContext, AuthenticationSessionOptions options)
- {
- await Next(httpContext);
-
- string? oldAuthenticationToken = ReadAuthenticationCookie(httpContext, options);
-
- if (oldAuthenticationToken is not null)
- FlushSessionPrincipal(oldAuthenticationToken);
-
- if (httpContext.User.Identity?.IsAuthenticated == true)
- HandleAuthenticatedRequest(httpContext, options);
- else
- ExpireAuthenticationCookie(httpContext, options);
- }
-
- // Request without credentials will attempt to use the session to authenticate the request.
- private async Task HandleRequestAsync(HttpContext httpContext, AuthenticationSessionOptions options)
- {
- string? authenticationToken = ReadAuthenticationCookie(httpContext, options);
-
- if (authenticationToken is null)
- {
- await Next(httpContext);
- return;
- }
-
- ClaimsPrincipal? sessionPrincipal = FetchSessionPrincipal(authenticationToken);
-
- if (sessionPrincipal is null)
- {
- await Next(httpContext);
- return;
- }
-
- httpContext.User = sessionPrincipal;
- await Next(httpContext);
- UpdateSessionPrincipal(authenticationToken, sessionPrincipal, options);
- }
-
- // Establishes a new session with the client.
- private void HandleAuthenticatedRequest(HttpContext httpContext, AuthenticationSessionOptions options)
- {
- string newAuthenticationToken = GenerateNewAuthenticationToken();
- ClaimsPrincipal sessionPrincipal = BuildSessionPrincipal(httpContext.User);
- UpdateSessionPrincipal(newAuthenticationToken, sessionPrincipal, options);
- UpdateAuthenticationCookie(httpContext, newAuthenticationToken, options);
- }
-
- // Retrieve session data from the server-side cache.
- private ClaimsPrincipal? FetchSessionPrincipal(string authenticationToken)
- {
- return MemoryCache.TryGetValue(authenticationToken, out ClaimsPrincipal? principal)
- ? principal : null;
- }
-
- // Updates the server-side cache entry and resets the expiration window.
- private void UpdateSessionPrincipal(string authenticationToken, ClaimsPrincipal sessionPrincipal, AuthenticationSessionOptions options)
- {
- MemoryCache
- .CreateEntry(authenticationToken)
- .SetValue(sessionPrincipal)
- .SetSlidingExpiration(options.AuthenticationTokenExpiration)
- .Dispose();
- }
-
- // Removes session data from the server-side cache.
- private void FlushSessionPrincipal(string authenticationToken)
- {
- MemoryCache.Remove(authenticationToken);
- }
-
- // Prepares the cookie with the session token in the response to send it to the client.
- private void UpdateAuthenticationCookie(HttpContext httpContext, string authenticationToken, AuthenticationSessionOptions options)
- {
- CookieOptions cookieOptions = new()
- {
- Path = Options.ApplicationBasePath,
- HttpOnly = true,
- Secure = true,
- IsEssential = true,
- Expires = options.AuthenticationCookieExpiration
- };
-
- httpContext.Response.Cookies.Append(options.AuthenticationSessionCookie, authenticationToken, cookieOptions);
- }
-
- #endregion
-
- #region [ Static ]
-
- // Static Methods
-
- // Reads the session token from the cookie provided by the client.
- private static string? ReadAuthenticationCookie(HttpContext httpContext, AuthenticationSessionOptions options)
- {
- return httpContext.Request.Cookies.TryGetValue(options.AuthenticationSessionCookie, out string? token)
- ? token : null;
- }
-
- // Generates a token the server can use to look up the session principal.
- private static string GenerateNewAuthenticationToken()
- {
- byte[] tokenData = new byte[128];
- RandomNumberGenerator.Fill(tokenData);
- return Convert.ToBase64String(tokenData);
- }
-
- // Copies the claims from the authenticated request's principal into a new
- // principal that will persist across requests within the same session.
- private static ClaimsPrincipal BuildSessionPrincipal(ClaimsPrincipal httpPrincipal)
- {
- ClaimsIdentity sessionIdentity = new(httpPrincipal.Identity, httpPrincipal.Claims);
- ClaimsPrincipal sessionPrincipal = new(sessionIdentity);
- sessionIdentity.AddClaim(new(ClaimTypes.Role, "Session"));
- return sessionPrincipal;
- }
-
- // If the client provided a cookie, indicate to the client that the
- // cookie is no longer valid by sending an expired cookie to replace it.
- private static void ExpireAuthenticationCookie(HttpContext httpContext, AuthenticationSessionOptions options)
- {
- string authenticationCookie = options.AuthenticationSessionCookie;
-
- if (httpContext.Request.Cookies.ContainsKey(authenticationCookie))
- httpContext.Response.Cookies.Delete(authenticationCookie);
- }
-
- #endregion
-}
-
-///
-/// Extension methods for setting up the .
-///
-public static class AuthenticationSessionMiddlewareExtensions
-{
- ///
- /// Registers the services required by the .
- ///
- /// Collection of services used by the app.
- /// Collection of services used by the app.
- public static IServiceCollection AddAuthenticationCache(this IServiceCollection services)
- {
- return services.AddMemoryCache();
- }
-
- ///
- /// Registers the services required by the .
- ///
- /// Collection of services used by the app.
- /// The action used to configure the options.
- /// Collection of services used by the app.
- public static IServiceCollection AddAuthenticationCache(this IServiceCollection services, Action configureOptions)
- {
- return services
- .Configure(configureOptions)
- .AddAuthenticationCache();
- }
-
- ///
- /// Registers the in the app pipeline.
- ///
- /// The app builder with which to register the middleware.
- /// The app builder with middleware registered.
- public static IApplicationBuilder UseAuthenticationCache(this IApplicationBuilder app)
- {
- return app.UseMiddleware();
- }
-}
diff --git a/src/Gemstone.Web/Security/AuthenticationRuntimeMiddleware.cs b/src/Gemstone.Web/Security/AuthenticationRuntimeMiddleware.cs
new file mode 100644
index 0000000..d099685
--- /dev/null
+++ b/src/Gemstone.Web/Security/AuthenticationRuntimeMiddleware.cs
@@ -0,0 +1,57 @@
+//******************************************************************************************************
+// AuthenticationRuntimeMiddleware.cs - Gbtc
+//
+// Copyright © 2025, Grid Protection Alliance. All Rights Reserved.
+//
+// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See
+// the NOTICE file distributed with this work for additional information regarding copyright ownership.
+// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at:
+//
+// http://opensource.org/licenses/MIT
+//
+// Unless agreed to in writing, the subject software distributed under the License is distributed on an
+// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the
+// License for the specific language governing permissions and limitations.
+//
+// Code Modification History:
+// ----------------------------------------------------------------------------------------------------
+// 07/23/2025 - Stephen C. Wills
+// Generated original version of source code.
+//
+//******************************************************************************************************
+
+using System.Collections.Generic;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using Gemstone.Security.AuthenticationProviders;
+using Microsoft.AspNetCore.Http;
+
+namespace Gemstone.Web.Security;
+
+///
+/// Represents a middleware used to assign claims to authenticated users.
+///
+/// Delegate used to invoke the next middleware in the pipeline
+/// The authentication runtime used to query assigned claims
+/// The identity of the provider that authenticated the user
+public class AuthenticationRuntimeMiddleware(RequestDelegate next, IAuthenticationRuntime runtime, string providerIdentity)
+{
+ private RequestDelegate Next { get; } = next;
+ private IAuthenticationRuntime Runtime { get; } = runtime;
+ private string ProviderIdentity { get; } = providerIdentity;
+
+ ///
+ /// Assigns claims from the authentication runtime to the authenticated user.
+ ///
+ /// The context of the HTTP request
+ public async Task Invoke(HttpContext httpContext)
+ {
+ ClaimsPrincipal user = httpContext.User;
+ ClaimsIdentity newIdentity = new(user.Identity, user.FindAll(ClaimTypes.Name));
+ IEnumerable assignedClaims = Runtime.GetAssignedClaims(ProviderIdentity, user);
+ newIdentity.AddClaims(assignedClaims);
+ httpContext.User = new(newIdentity);
+ await Next(httpContext);
+ }
+}
diff --git a/src/Gemstone.Web/Security/ControllerAccessHandler.cs b/src/Gemstone.Web/Security/ControllerAccessHandler.cs
new file mode 100644
index 0000000..1006838
--- /dev/null
+++ b/src/Gemstone.Web/Security/ControllerAccessHandler.cs
@@ -0,0 +1,195 @@
+//******************************************************************************************************
+// ControllerAccessHandler.cs - Gbtc
+//
+// Copyright © 2025, Grid Protection Alliance. All Rights Reserved.
+//
+// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See
+// the NOTICE file distributed with this work for additional information regarding copyright ownership.
+// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at:
+//
+// http://opensource.org/licenses/MIT
+//
+// Unless agreed to in writing, the subject software distributed under the License is distributed on an
+// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the
+// License for the specific language governing permissions and limitations.
+//
+// Code Modification History:
+// ----------------------------------------------------------------------------------------------------
+// 07/29/2025 - Stephen C. Wills
+// Generated original version of source code.
+//
+//******************************************************************************************************
+
+using System.Security.Claims;
+using System.Threading.Tasks;
+using Gemstone.Security.AccessControl;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.AspNetCore.Mvc.Controllers;
+using Microsoft.AspNetCore.Routing;
+
+namespace Gemstone.Web.Security;
+
+///
+/// Authorization handler for access to controller actions.
+///
+public class ControllerAccessHandler : AuthorizationHandler
+{
+ #region [ Members ]
+
+ // Nested Types
+ private enum Permission
+ {
+ Allow,
+ Deny,
+ Neither
+ }
+
+ private class ContextWrapper(AuthorizationHandlerContext context, ControllerAccessRequirement requirement, HttpContext httpContext, Endpoint endpoint, ControllerActionDescriptor descriptor)
+ {
+ private AuthorizationHandlerContext Context { get; } = context;
+ private ControllerAccessRequirement Requirement { get; } = requirement;
+
+ public ClaimsPrincipal User { get; } = context.User;
+ public Endpoint Endpoint { get; } = endpoint;
+ public ControllerActionDescriptor Descriptor { get; } = descriptor;
+ public string HttpMethod => httpContext.Request.Method;
+
+ public bool Succeed()
+ {
+ Context.Succeed(Requirement);
+ return true;
+ }
+
+ public bool Fail(AuthorizationFailureReason reason)
+ {
+ Context.Fail(reason);
+ return true;
+ }
+ }
+
+ #endregion
+
+ #region [ Methods ]
+
+ ///
+ protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, ControllerAccessRequirement requirement)
+ {
+ if (context.Resource is not HttpContext httpContext)
+ return Task.CompletedTask;
+
+ IEndpointFeature? endpointFeature = httpContext.Features.Get();
+ Endpoint? endpoint = endpointFeature?.Endpoint;
+
+ if (endpoint is null)
+ return Task.CompletedTask;
+
+ ControllerActionDescriptor? descriptor = endpoint.Metadata
+ .GetMetadata();
+
+ if (descriptor is null)
+ return Task.CompletedTask;
+
+ ContextWrapper wrapper = new(context, requirement, httpContext, endpoint, descriptor);
+
+ if (HandleResourceActionPermission(wrapper))
+ return Task.CompletedTask;
+
+ HandleResourceAccessPermission(wrapper);
+ return Task.CompletedTask;
+ }
+
+ private bool HandleResourceActionPermission(ContextWrapper wrapper)
+ {
+ IRouteNameMetadata? routeNameMetadata = wrapper.Endpoint.Metadata
+ .GetMetadata();
+
+ string? routeName = routeNameMetadata?.RouteName;
+
+ string resource = wrapper.Descriptor.ControllerName;
+ string action = routeName ?? wrapper.Descriptor.ActionName;
+ string claimValue = $"Controller {resource} {action}";
+ Permission permission = GetResourceActionPermission(wrapper.User, claimValue);
+
+ return
+ (permission == Permission.Deny && fail()) ||
+ (permission == Permission.Allow && succeed());
+
+ bool succeed() =>
+ wrapper.Succeed();
+
+ bool fail()
+ {
+ AuthorizationFailureReason reason = ToFailureReason(claimValue);
+ return wrapper.Fail(reason);
+ }
+ }
+
+ private AuthorizationFailureReason ToFailureReason(string claim)
+ {
+ return new AuthorizationFailureReason(this, $"{claim} permission denied");
+ }
+
+ #endregion
+
+ #region [ Static ]
+
+ // Static Methods
+
+ private static Permission GetResourceActionPermission(ClaimsPrincipal user, string claimValue)
+ {
+ string allowClaim = $"Gemstone.ResourceAction.Allow";
+ string denyClaim = $"Gemstone.ResourceAction.Deny";
+
+ if (user.HasClaim(denyClaim, claimValue))
+ return Permission.Deny;
+
+ return user.HasClaim(allowClaim, claimValue)
+ ? Permission.Allow
+ : Permission.Neither;
+ }
+
+ private static void HandleResourceAccessPermission(ContextWrapper wrapper)
+ {
+ IResourceAccessAttribute? accessAttribute = wrapper.Endpoint.Metadata
+ .GetMetadata();
+
+ if (accessAttribute is NoResourceAccessAttribute)
+ return;
+
+ string resourceName = accessAttribute.GetResourceName(wrapper.Descriptor);
+ ResourceAccessType? access = accessAttribute.GetAccessType(wrapper.HttpMethod);
+
+ if (access.HasValue && wrapper.User.HasAccessTo("Controller", resourceName, access.GetValueOrDefault()))
+ wrapper.Succeed();
+ }
+
+ #endregion
+}
+
+///
+/// Requirement to be handled by the .
+///
+public class ControllerAccessRequirement : IAuthorizationRequirement
+{
+}
+
+///
+/// Defines extension methods for the controller access handler.
+///
+public static class ControllerAccessHandlerExtensions
+{
+ private static ControllerAccessRequirement Requirement { get; } = new();
+
+ ///
+ /// Adds the to the policy.
+ ///
+ /// The policy builder
+ /// The policy builder.
+ public static AuthorizationPolicyBuilder RequireControllerAccess(this AuthorizationPolicyBuilder builder)
+ {
+ return builder.AddRequirements(Requirement);
+ }
+}
diff --git a/src/Gemstone.Web/Security/DefaultTicketStore.cs b/src/Gemstone.Web/Security/DefaultTicketStore.cs
new file mode 100644
index 0000000..b31acdb
--- /dev/null
+++ b/src/Gemstone.Web/Security/DefaultTicketStore.cs
@@ -0,0 +1,107 @@
+//******************************************************************************************************
+// DefaultTicketStore.cs - Gbtc
+//
+// Copyright © 2025, Grid Protection Alliance. All Rights Reserved.
+//
+// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See
+// the NOTICE file distributed with this work for additional information regarding copyright ownership.
+// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at:
+//
+// http://opensource.org/licenses/MIT
+//
+// Unless agreed to in writing, the subject software distributed under the License is distributed on an
+// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the
+// License for the specific language governing permissions and limitations.
+//
+// Code Modification History:
+// ----------------------------------------------------------------------------------------------------
+// 08/05/2025 - Stephen C. Wills
+// Generated original version of source code.
+//
+//******************************************************************************************************
+
+using System;
+using System.Security.Cryptography;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
+
+namespace Gemstone.Web.Security;
+
+///
+/// Options to configure the memory cache used by the authentication ticket store.
+///
+public class SessionCacheOptions
+{
+ ///
+ /// Gets or sets the amount of time a ticket should
+ /// remain valid since the last time it was accessed.
+ ///
+ public TimeSpan SlidingExpiration { get; set; } = TimeSpan.FromMinutes(15);
+}
+
+///
+/// Represents an in-memory storage mechanism for authentication tickets.
+///
+/// The cache in which tickets will be stored
+public class DefaultTicketStore(IMemoryCache memoryCache, IOptionsMonitor optionsMonitor) : ITicketStore
+{
+ private IMemoryCache MemoryCache { get; } = memoryCache;
+ private SessionCacheOptions Options { get; } = optionsMonitor.CurrentValue;
+
+ ///
+ public Task StoreAsync(AuthenticationTicket ticket)
+ {
+ string key = GenerateNewSessionToken();
+ UpdateEntry(key, ticket);
+ return Task.FromResult(key);
+ }
+
+ ///
+ public Task RenewAsync(string key, AuthenticationTicket ticket)
+ {
+ UpdateEntry(key, ticket);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task RetrieveAsync(string key)
+ {
+ if (!MemoryCache.TryGetValue(key, out AuthenticationTicket? ticket))
+ ticket = null;
+
+ return Task.FromResult(ticket);
+ }
+
+ ///
+ public Task RemoveAsync(string key)
+ {
+ MemoryCache.Remove(key);
+ return Task.CompletedTask;
+ }
+
+ private void UpdateEntry(string key, AuthenticationTicket ticket)
+ {
+ DateTimeOffset? expiration = ticket.Properties.ExpiresUtc;
+ MemoryCacheEntryOptions options = new() { SlidingExpiration = Options.SlidingExpiration };
+
+ if (expiration is not null)
+ options.AbsoluteExpiration = ticket.Properties.ExpiresUtc;
+
+ MemoryCache
+ .CreateEntry(key)
+ .SetOptions(options)
+ .SetValue(ticket)
+ .Dispose();
+ }
+
+ private static string GenerateNewSessionToken()
+ {
+ byte[] tokenData = new byte[128];
+ RandomNumberGenerator.Fill(tokenData);
+ return Convert.ToBase64String(tokenData);
+ }
+}
diff --git a/src/Gemstone.Web/Security/IAuthenticationWebBuilder.cs b/src/Gemstone.Web/Security/IAuthenticationWebBuilder.cs
new file mode 100644
index 0000000..3898fa9
--- /dev/null
+++ b/src/Gemstone.Web/Security/IAuthenticationWebBuilder.cs
@@ -0,0 +1,233 @@
+//******************************************************************************************************
+// IAuthenticationWebBuilder.cs - Gbtc
+//
+// Copyright © 2025, Grid Protection Alliance. All Rights Reserved.
+//
+// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See
+// the NOTICE file distributed with this work for additional information regarding copyright ownership.
+// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at:
+//
+// http://opensource.org/licenses/MIT
+//
+// Unless agreed to in writing, the subject software distributed under the License is distributed on an
+// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the
+// License for the specific language governing permissions and limitations.
+//
+// Code Modification History:
+// ----------------------------------------------------------------------------------------------------
+// 07/25/2025 - Stephen C. Wills
+// Generated original version of source code.
+//
+//******************************************************************************************************
+
+using System;
+using System.Threading.Tasks;
+using Gemstone.Security.AuthenticationProviders;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
+using Microsoft.Net.Http.Headers;
+
+namespace Gemstone.Web.Security;
+
+///
+/// Represents a builder for Gemstone authentication web components.
+///
+public interface IAuthenticationWebBuilder
+{
+ ///
+ /// Adds the default middleware type for the given provider identity to the application's request pipeline.
+ ///
+ /// The identity of the authentication provider
+ /// The authentication web builder.
+ IAuthenticationWebBuilder UseProvider(string providerIdentity);
+
+ ///
+ /// Adds the default middleware type for the given provider identity to the application's request pipeline.
+ ///
+ /// The identity of the authentication provider
+ /// The path on which requests will require authentication using this provider
+ /// The authentication web builder.
+ IAuthenticationWebBuilder UseProvider(string providerIdentity, string pathMatch);
+
+ ///
+ /// Adds the default middleware type for the given provider identity to the application's request pipeline.
+ ///
+ /// The identity of the authentication provider
+ /// The path on which requests will require authentication using this provider
+ /// The authentication scheme to require on the endpoint
+ /// The authentication web builder.
+ IAuthenticationWebBuilder UseProvider(string providerIdentity, string pathMatch, string scheme);
+}
+
+///
+/// Extensions for the authentication web builder.
+///
+public static class AuthenticationWebBuilderExtensions
+{
+ private class AuthenticationWebBuilder(IApplicationBuilder app) : IAuthenticationWebBuilder
+ {
+ public IAuthenticationWebBuilder UseProvider(string providerIdentity)
+ {
+ string endpoint = $"/asi/auth/{providerIdentity}";
+ return UseProvider(providerIdentity, endpoint);
+ }
+
+ public IAuthenticationWebBuilder UseProvider(string providerIdentity, string pathMatch)
+ {
+ return UseProvider(providerIdentity, pathMatch, providerIdentity);
+ }
+
+ public IAuthenticationWebBuilder UseProvider(string providerIdentity, string pathMatch, string scheme)
+ {
+ app.Map(pathMatch, branch => branch
+ .UseRouting()
+ .UseAuthorization()
+ .UseMiddleware(providerIdentity)
+ .UseEndpoints(endpoints =>
+ {
+ IEndpointConventionBuilder conventions = endpoints.MapGet("/", async context =>
+ {
+ await context.SignInAsync(context.User);
+
+ string? returnURL = context.Request.Query["redir"];
+ context.Response.Redirect(returnURL ?? "/");
+ });
+
+ conventions.RequireAuthorization(policy => policy
+ .AddAuthenticationSchemes(scheme)
+ .RequireAuthenticatedUser());
+ }));
+
+ return this;
+ }
+ }
+
+ private class LogoutMiddleware(RequestDelegate next, IOptionsMonitor cookieOptions)
+ {
+ private CookieAuthenticationOptions Options => cookieOptions
+ .Get(CookieAuthenticationDefaults.AuthenticationScheme);
+
+ public async Task Invoke(HttpContext httpContext)
+ {
+ if (!IsLogoutRequest(httpContext.Request))
+ {
+ await next(httpContext);
+ return;
+ }
+
+ await httpContext.SignOutAsync();
+
+ // This handles the redirect to login page
+ if (!IsAjaxRequest(httpContext.Request))
+ await httpContext.ChallengeAsync();
+ }
+
+ private bool IsLogoutRequest(HttpRequest request)
+ {
+ PathString logoutPath = Options.LogoutPath;
+ return logoutPath.HasValue && request.Path.StartsWithSegments(logoutPath);
+ }
+
+ // Taken from Microsoft's reference source for the Cookie authentication implementation
+ // https://github.com/dotnet/aspnetcore/blob/v9.0.8/src/Security/Authentication/Cookies/src/CookieAuthenticationEvents.cs#L105
+ private static bool IsAjaxRequest(HttpRequest request)
+ {
+ return string.Equals(request.Query[HeaderNames.XRequestedWith], "XMLHttpRequest", StringComparison.Ordinal) ||
+ string.Equals(request.Headers.XRequestedWith, "XMLHttpRequest", StringComparison.Ordinal);
+ }
+ }
+
+ ///
+ /// Sets up the default configuration for services to support Gemstone authentication.
+ ///
+ /// Provides setup information for the runtime.
+ /// The collection of services
+ /// Builder for adding additional authentication schemes.
+ public static AuthenticationBuilder ConfigureGemstoneWebAuthentication(this IServiceCollection services) where T : class, IAuthenticationSetup
+ {
+ return services
+ .AddGemstoneAuthentication()
+ .ConfigureGemstoneWebDefaults();
+ }
+
+ ///
+ /// Sets up the default configuration for services to support Gemstone authentication.
+ ///
+ /// The collection of services
+ /// Method to configure the runtime
+ /// Builder for adding additional authentication schemes.
+ public static AuthenticationBuilder ConfigureGemstoneWebAuthentication(this IServiceCollection services, Action configure)
+ {
+ return services
+ .AddGemstoneAuthentication(configure)
+ .ConfigureGemstoneWebDefaults();
+ }
+
+ ///
+ /// Automatically configures the request pipeline to support well-known authentication providers.
+ ///
+ /// The application builder
+ /// The application builder.
+ public static IApplicationBuilder UseGemstoneAuthentication(this IApplicationBuilder app)
+ {
+ return app.UseGemstoneAuthentication(configure);
+
+ void configure(IAuthenticationWebBuilder builder)
+ {
+ IAuthenticationSetup setup = app.ApplicationServices.GetRequiredService();
+
+ foreach (string providerIdentity in setup.GetProviderIdentities())
+ builder.UseProvider(providerIdentity);
+ }
+ }
+
+ ///
+ /// Configures the request pipeline to support authentication providers using the given configuration method.
+ ///
+ /// The application builder
+ /// The method to configure authentication provider middleware
+ /// The application builder.
+ public static IApplicationBuilder UseGemstoneAuthentication(this IApplicationBuilder app, Action configure)
+ {
+ AuthenticationWebBuilder builder = new(app.UseAuthentication());
+ configure(builder);
+ return app.UseMiddleware();
+ }
+
+ private static AuthenticationBuilder ConfigureGemstoneWebDefaults(this IServiceCollection services)
+ {
+ services
+ .AddMemoryCache()
+ .TryAddSingleton();
+
+ services
+ .AddOptions(CookieAuthenticationDefaults.AuthenticationScheme)
+ .Configure((options, sessionStore) =>
+ {
+ options.SessionStore = sessionStore;
+
+ options.ExpireTimeSpan = TimeSpan.FromHours(24);
+ options.SlidingExpiration = false;
+ options.LoginPath = "/Login";
+ options.LogoutPath = "/asi/logout";
+ options.ReturnUrlParameter = "redir";
+
+ options.Cookie.Name = "x-gemstone-auth";
+ options.Cookie.Path = "/";
+ options.Cookie.HttpOnly = true;
+ options.Cookie.IsEssential = true;
+ });
+
+ return services
+ .AddWindowsAuthenticationProvider()
+ .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
+ .AddNegotiate("windows", _ => { })
+ .AddCookie();
+ }
+}