Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 9 additions & 7 deletions src/Gemstone.Security/AccessControl/ResourceAccessType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

using System;
using System.Security.Claims;
using Gemstone.Security.AuthenticationProviders;

namespace Gemstone.Security.AccessControl;

Expand Down Expand Up @@ -94,25 +95,26 @@ public static bool HasAccessTo(this ClaimsPrincipal user, string resourceType, s
{
ThrowIfNotValid(access);

const string AllowClaim = "Gemstone.ResourceAccess.Allow";
const string DenyClaim = "Gemstone.ResourceAccess.Deny";
const string BaseClaim = "Gemstone.ResourceAccess.Default";

if (access == ResourceAccessType.None)
return false;

string claimValue = $"{resourceType} {resourceName} {access}";

bool IsDenied() =>
user.HasClaim(DenyClaim, claimValue);
user.HasClaim(GemstoneClaimTypes.DenyClaim, claimValue);

bool IsAllowed() =>
user.HasClaim(AllowClaim, claimValue) ||
user.HasClaim(BaseClaim, $"{access}");
user.HasClaim(GemstoneClaimTypes.AllowClaim, claimValue) ||
(!user.IsAPIUser() && user.HasClaim(GemstoneClaimTypes.BaseClaim, $"{access}"));

return !IsDenied() && IsAllowed();
}

private static bool IsAPIUser(this ClaimsPrincipal user)
{
return user.Identity?.AuthenticationType == APIAuthenticationHandler.AuthenticationType;
}

private static void ThrowIfNotValid(ResourceAccessType access)
{
switch (access)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
//******************************************************************************************************
// APIAuthenticationHandler.cs - Gbtc
//
// Copyright © 2026, 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/09/2026 - C. Lackner
// Generated original version of source code.
//
//******************************************************************************************************

using System;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Gemstone.Security.AuthenticationProviders;

/// <summary>
/// Options for the <see cref="APIAuthenticationHandler"/> class.
/// </summary>
public class APIAuthenticationOptions : AuthenticationSchemeOptions
{
/// <summary>
/// Function that parses and validates an API token.
/// </summary>
public Func<string, APIToken?>? ValidateToken { get; set; }
}

/// <summary>
/// Represents metadata associated with an API token.
/// </summary>
public class APIToken
{
/// <summary>
/// Gets or sets the name of the API user.
/// </summary>
public string Name { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the time at which the token expires.
/// </summary>
public DateTime Expiration { get; set; }

/// <summary>
/// Gets or sets the list of claims assigned to the API user.
/// </summary>
public Claim[] Claims { get; set; } = [];
}

/// <summary>
/// Represents an authentication handler for API users.
/// </summary>
public class APIAuthenticationHandler(IOptionsMonitor<APIAuthenticationOptions> options, ILoggerFactory logger, UrlEncoder encoder)
: AuthenticationHandler<APIAuthenticationOptions>(options, logger, encoder)
{
/// <summary>
/// Authentication type used for API authentication.
/// </summary>
public const string AuthenticationType = "APIAuthentication";

private const string HttpAuthenticationScheme = "Bearer";

private string AuthorizationHeader => Request.Headers.Authorization.ToString();

/// <summary>
/// Parses the Authorization header and API token.
/// </summary>
/// <returns>The result of authentication.</returns>
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
AuthenticateResult result = Authenticate();
return Task.FromResult(result);
}

/// <summary>
/// Returns a 401 Unauthorized response with the WWW-Authenticate header.
/// </summary>
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.Headers.WWWAuthenticate = HttpAuthenticationScheme;
return base.HandleChallengeAsync(properties);
}

private AuthenticateResult Authenticate()
{
string prefix = $"{HttpAuthenticationScheme} ";

if (!AuthorizationHeader.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return AuthenticateResult.NoResult();

string token = AuthorizationHeader[prefix.Length..].Trim();
APIToken? resolvedToken;

try
{
resolvedToken = Options.ValidateToken?.Invoke(token);
}
catch (Exception ex)
{
return AuthenticateResult.Fail(ex);
}

if (resolvedToken is null)
return AuthenticateResult.NoResult();

if (resolvedToken.Expiration < DateTime.UtcNow)
return AuthenticateResult.Fail("Token expired");

ClaimsIdentity identity = new(AuthenticationType);
identity.AddClaim(new(ClaimTypes.Name, resolvedToken.Name, Options.ClaimsIssuer));
identity.AddClaims(resolvedToken.Claims);

ClaimsPrincipal principal = new(identity);
AuthenticationTicket ticket = new(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
}

/// <summary>
/// Extension methods for <see cref="APIAuthenticationHandler"/>.
/// </summary>
public static class APIAuthenticationHandlerExtensions
{
/// <summary>
/// Adds the API authentication handler to the application.
/// </summary>
/// <param name="builder">The builder used to configure authentication</param>
/// <returns>The builder used to configure authentication.</returns>
public static AuthenticationBuilder AddAPIAuthentication(this AuthenticationBuilder builder)
{
return builder.AddAPIAuthentication(options => { });
}

/// <summary>
/// Adds the API authentication handler to the application.
/// </summary>
/// <param name="builder">The builder used to configure authentication</param>
/// <param name="configureOptions">Action to configure the <see cref="APIAuthenticationOptions"/></param>
/// <returns>The builder used to configure authentication.</returns>
public static AuthenticationBuilder AddAPIAuthentication(this AuthenticationBuilder builder, Action<APIAuthenticationOptions> configureOptions)
{
return builder.AddAPIAuthentication("api", configureOptions);
}

/// <summary>
/// Adds the API authentication handler to the application.
/// </summary>
/// <param name="builder">The builder used to configure authentication</param>
/// <param name="authenticationScheme">The name of the scheme</param>
/// <param name="configureOptions">Action to configure the <see cref="APIAuthenticationOptions"/></param>
/// <returns>The builder used to configure authentication.</returns>
public static AuthenticationBuilder AddAPIAuthentication(this AuthenticationBuilder builder, string authenticationScheme, Action<APIAuthenticationOptions> configureOptions)
{
return builder.AddAPIAuthentication(authenticationScheme, null, configureOptions);
}

/// <summary>
/// Adds the API authentication handler to the application.
/// </summary>
/// <param name="builder">The builder used to configure authentication</param>
/// <param name="authenticationScheme">The name of the scheme</param>
/// <param name="displayName">The display name of the scheme</param>
/// <param name="configureOptions">Action to configure the <see cref="APIAuthenticationOptions"/></param>
/// <returns>The builder used to configure authentication.</returns>
public static AuthenticationBuilder AddAPIAuthentication(this AuthenticationBuilder builder, string authenticationScheme, string? displayName, Action<APIAuthenticationOptions> configureOptions)
{
return builder.AddScheme<APIAuthenticationOptions, APIAuthenticationHandler>(authenticationScheme, displayName, configureOptions);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,23 +96,23 @@ public IEnumerable<string> GetProviderIdentities()

public IEnumerable<Claim> GetAssignedClaims(string providerIdentity, ClaimsPrincipal principal)
{
const string ProviderIdentityClaim = "Gemstone.ProviderIdentity";
const string UserIdentityClaim = "Gemstone.UserIdentity";

IAuthenticationProvider? provider = ProviderLookup(providerIdentity);

if (provider is null)
return [];

string userIdentity = provider.GetIdentity(principal);

IEnumerable<Claim> providerClaims = Setup
IEnumerable<Claim> providerClaims = principal.Claims
.Append(new(GemstoneClaimTypes.AllUsers, string.Empty));

IEnumerable<Claim> assignedClaims = Setup
.GetProviderClaims(providerIdentity)
.Join(principal.Claims, ToKey, ToKey, (providerClaim, _) => providerClaim.Assigned)
.Prepend(new(UserIdentityClaim, userIdentity))
.Prepend(new(ProviderIdentityClaim, providerIdentity));
.Join(providerClaims, ToKey, ToKey, (mapping, _) => mapping.Assigned)
.Prepend(new(GemstoneClaimTypes.UserIdentity, userIdentity))
.Prepend(new(GemstoneClaimTypes.ProviderIdentity, providerIdentity));

return providerClaims;
return assignedClaims;
}

private static (string, string) ToKey((Claim Match, Claim) tuple)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,14 @@ public OAuthAuthenticationProvider()
/// <inheritdoc/>
public string GetIdentity(ClaimsPrincipal principal)
{
if (ClaimTypes.Length == 1)
if (ClaimTypes is null)
ClaimTypes = principal
.Claims
.Select(claim => claim.Type)
.Prepend(GemstoneClaimTypes.UserIdentity)
.Prepend(GemstoneClaimTypes.AllUsers)
.Distinct()
.Select(type => new ClaimType(type)).Prepend(new ClaimType("Gemstone.AllUsers")).ToArray();
.Select(type => new ClaimType(type)).ToArray();

string? identity = principal
.FindFirst(Options.UserIdClaim ?? "sub")?
Expand All @@ -140,7 +142,7 @@ public string GetIdentity(ClaimsPrincipal principal)
/// <inheritdoc/>
public IEnumerable<IClaimType> GetClaimTypes()
{
return ClaimTypes;
return ClaimTypes ?? [new ClaimType(GemstoneClaimTypes.AllUsers), new(GemstoneClaimTypes.UserIdentity)];
}

/// <inheritdoc/>
Expand All @@ -154,11 +156,11 @@ public IEnumerable<IProviderClaim> FindClaims(string claimType, string searchTex
#region [ Static ]

// Static Properties
private static ClaimType[] ClaimTypes
private static ClaimType[]? ClaimTypes
{
get;
set;
} = [new ClaimType("Gemstone.AllUsers")];
} = null;

// Static Methods

Expand Down Expand Up @@ -256,7 +258,7 @@ public static IServiceCollection AddOAuthAuthenticationProvider(this IServiceCol
/// <returns>The collection of services.</returns>
public static IServiceCollection AddOAuthAuthenticationProvider(this IServiceCollection services, string identity, Action<OAuthAuthenticationProviderOptions> configure)
{
return services.AddKeyedTransient<IAuthenticationProvider>(identity, (_, _) =>
return services.AddKeyedSingleton<IAuthenticationProvider>(identity, (_, _) =>
{
OAuthAuthenticationProviderOptions options = new();
configure(options);
Expand Down
62 changes: 62 additions & 0 deletions src/Gemstone.Security/GemstoneClaimTypes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//******************************************************************************************************
// GemstoneClaimTypes.cs - Gbtc
//
// Copyright © 2026, 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:
// ----------------------------------------------------------------------------------------------------
// 10/16/2019 - J. Ritchie Carroll
// Generated original version of source code.
//
//******************************************************************************************************

namespace Gemstone.Security
{
/// <summary>
/// The claim types used by the <see cref="Security"/> namespace.
/// </summary>
public static class GemstoneClaimTypes
{
/// <summary>
/// Assigned claim that holds the unique identifier for the User.
/// </summary>
public const string UserIdentity = "Gemstone.UserIdentity";

/// <summary>
/// Assigned claim that holds the unique identifier for the Authentication Provider.
/// </summary>
public const string ProviderIdentity = "Gemstone.ProviderIdentity";

/// <summary>
/// Implicit claim that masquerades as a provider claim and applies
/// to any user principal regardless of what claims they have.
/// </summary>
public const string AllUsers = "Gemstone.AllUsers";

/// <summary>
/// Assigned claim that allows a user to access a resource.
/// </summary>
public const string AllowClaim = "Gemstone.ResourceAccess.Allow";

/// <summary>
/// Assigned claim that denies a user access to a resource.
/// </summary>
public const string DenyClaim = "Gemstone.ResourceAccess.Deny";

/// <summary>
/// Assigned claim that allows access to the value as the default access level.
/// </summary>
public const string BaseClaim = "Gemstone.ResourceAccess.Default";
}
}