< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Authentication.IAuthHandler
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Authentication/IAuthHandler.cs
Tag: Kestrun/Kestrun@9d3a582b2d63930269564a7591aa77ef297cadeb
Line coverage
90%
Covered lines: 189
Uncovered lines: 20
Coverable lines: 209
Total lines: 478
Line coverage: 90.4%
Branch coverage
75%
Covered branches: 68
Total branches: 90
Branch coverage: 75.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
GetAuthenticationTicketAsync()100%11100%
GetIssuedClaimsAsync()25%8437.5%
EnsureNameClaim(...)83.33%6685.71%
CreateAuthenticationTicket(...)100%11100%
ValidatePowerShellAsync()70%222082.6%
BuildCsValidator(...)100%44100%
BuildVBNetValidator(...)75%4497.22%
BuildPsIssueClaims(...)100%11100%
IssueClaimsPowerShellAsync()50%301866.66%
GetPowerShell(...)100%66100%
TryToClaim(...)91.66%2424100%
BuildCsIssueClaims(...)100%22100%
BuildVBNetIssueClaims(...)100%22100%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Authentication/IAuthHandler.cs

#LineLine coverage
 1
 2
 3
 4using System.Collections;
 5using System.Management.Automation;
 6using System.Security.Claims;
 7using Kestrun.Hosting;
 8using Kestrun.Languages;
 9using Kestrun.Models;
 10using Kestrun.SharedState;
 11using Microsoft.AspNetCore.Authentication;
 12
 13namespace Kestrun.Authentication;
 14
 15
 16/// <summary>
 17/// Defines common options for authentication, including code validation, claim issuance, and claim policy configuration
 18/// </summary>
 19public interface IAuthHandler
 20{
 21    /// <summary>
 22    /// Generates an <see cref="AuthenticationTicket"/> for the specified user and authentication scheme, issuing additi
 23    /// </summary>
 24    /// <param name="Context">The current HTTP context.</param>
 25    /// <param name="user">The user name for whom the ticket is being generated.</param>
 26    /// <param name="Options">Authentication options including claim issuance delegates.</param>
 27    /// <param name="Scheme">The authentication scheme to use.</param>
 28    /// <param name="alias">An optional alias for the user.</param>
 29    /// <returns>An <see cref="AuthenticationTicket"/> representing the authenticated user.</returns>
 30    static async Task<AuthenticationTicket> GetAuthenticationTicketAsync(
 31        HttpContext Context, string user,
 32    IAuthenticationCommonOptions Options, AuthenticationScheme Scheme, string? alias = null)
 33    {
 834        var claims = new List<Claim>();
 35
 36        // 1) Issue extra claims if configured
 837        claims.AddRange(await GetIssuedClaimsAsync(Context, user, Options).ConfigureAwait(false));
 38
 39        // 2) Ensure a Name claim exists
 840        EnsureNameClaim(claims, user, alias, Options.Logger);
 41
 42        // 3) Create and return the ticket
 843        return CreateAuthenticationTicket(claims, Scheme);
 844    }
 45
 46    /// <summary>
 47    /// Issues claims for the specified user based on the provided context and options.
 48    /// </summary>
 49    /// <param name="context">The HTTP context.</param>
 50    /// <param name="user">The user name for whom claims are being issued.</param>
 51    /// <param name="options">Authentication options including claim issuance delegates.</param>
 52    /// <returns>A collection of issued claims.</returns>
 53    private static async Task<IEnumerable<Claim>> GetIssuedClaimsAsync(HttpContext context, string user, IAuthentication
 54    {
 855        if (options.IssueClaims is null)
 56        {
 857            return [];
 58        }
 59
 060        var extra = await options.IssueClaims(context, user).ConfigureAwait(false);
 061        if (extra is null)
 62        {
 063            return [];
 64        }
 65
 66        // Filter out nulls and empty values
 067        return [.. extra
 068            .Where(c => c is not null)
 069            .OfType<Claim>()
 070            .Where(c => !string.IsNullOrEmpty(c.Value))];
 871    }
 72
 73    /// <summary>
 74    /// Ensures that a Name claim is present in the list of claims.
 75    /// </summary>
 76    /// <param name="claims">The list of claims to check.</param>
 77    /// <param name="user">The user name to use if a Name claim is added.</param>
 78    /// <param name="alias">An optional alias for the user.</param>
 79    /// <param name="logger">The logger instance.</param>
 80    private static void EnsureNameClaim(List<Claim> claims, string user, string? alias, Serilog.ILogger logger)
 81    {
 882        if (claims.Any(c => c.Type == ClaimTypes.Name))
 83        {
 084            return;
 85        }
 86
 887        if (logger.IsEnabled(Serilog.Events.LogEventLevel.Debug))
 88        {
 489            logger.Debug("No Name claim found, adding default Name claim");
 90        }
 91
 892        var name = string.IsNullOrEmpty(alias) ? user : alias;
 893        claims.Add(new Claim(ClaimTypes.Name, name!));
 894    }
 95
 96    /// <summary>
 97    /// Creates an authentication ticket from the specified claims and authentication scheme.
 98    /// </summary>
 99    /// <param name="claims">The claims to include in the ticket.</param>
 100    /// <param name="scheme">The authentication scheme to use.</param>
 101    /// <returns>An authentication ticket containing the specified claims.</returns>
 102    private static AuthenticationTicket CreateAuthenticationTicket(IEnumerable<Claim> claims, AuthenticationScheme schem
 103    {
 8104        var claimsIdentity = new ClaimsIdentity(claims, scheme.Name);
 8105        var principal = new ClaimsPrincipal(claimsIdentity);
 8106        return new AuthenticationTicket(principal, scheme.Name);
 107    }
 108
 109    /// <summary>
 110    /// Authenticates the user using PowerShell script.
 111    /// This method is used to validate the username and password against a PowerShell script.
 112    /// </summary>
 113    /// <param name="code">The PowerShell script code used for authentication.</param>
 114    /// <param name="context">The HTTP context.</param>
 115    /// <param name="credentials">A dictionary containing the credentials to validate (e.g., username and password).</pa
 116    /// <param name="logger">The logger instance.</param>
 117    /// <returns>A task representing the asynchronous operation.</returns>
 118    /// <exception cref="InvalidOperationException"></exception>
 119    static async ValueTask<bool> ValidatePowerShellAsync(string? code, HttpContext context, Dictionary<string, string> c
 120    {
 121        try
 122        {
 6123            if (!context.Items.ContainsKey("PS_INSTANCE"))
 124            {
 0125                throw new InvalidOperationException("PowerShell runspace not found in context items. Ensure PowerShellRu
 126            }
 127            // Validate that the credentials dictionary is not null or empty
 6128            if (credentials == null || credentials.Count == 0)
 129            {
 1130                logger.Warning("Credentials are null or empty.");
 1131                return false;
 132            }
 133            // Validate that the code is not null or empty
 5134            if (string.IsNullOrEmpty(code))
 135            {
 1136                throw new InvalidOperationException("PowerShell authentication code is null or empty.");
 137            }
 138            // Retrieve the PowerShell instance from the context
 4139            var ps = context.Items["PS_INSTANCE"] as PowerShell
 4140                  ?? throw new InvalidOperationException("PowerShell instance not found in context items.");
 4141            if (ps.Runspace == null)
 142            {
 0143                throw new InvalidOperationException("PowerShell runspace is not set. Ensure PowerShellRunspaceMiddleware
 144            }
 145
 4146            _ = ps.AddScript(code, useLocalScope: true);
 24147            foreach (var kvp in credentials)
 148            {
 8149                _ = ps.AddParameter(kvp.Key, kvp.Value);
 150            }
 151
 4152            var psResults = await ps.InvokeAsync().ConfigureAwait(false);
 153
 4154            if (psResults.Count == 0 || psResults[0] == null || psResults[0].BaseObject is not bool isValid)
 155            {
 0156                logger.Error("PowerShell script did not return a valid boolean result.");
 0157                return false;
 158            }
 4159            return isValid;
 160        }
 1161        catch (Exception ex)
 162        {
 1163            logger.Error(ex, "Error during validating PowerShell authentication.");
 1164            return false;
 165        }
 6166    }
 167
 168    /// <summary>
 169    /// Builds a C# validator function for the specified authentication settings.
 170    /// </summary>
 171    /// <param name="settings">The authentication code settings.</param>
 172    /// <param name="log">The logger instance.</param>
 173    /// <param name="globals">Global variables to include in the validation context.</param>
 174    /// <returns>A function that validates the authentication context.</returns>
 175    internal static Func<HttpContext, IDictionary<string, object?>, Task<bool>> BuildCsValidator(
 176        AuthenticationCodeSettings settings,
 177        Serilog.ILogger log,
 178          params (string Name, object? Prototype)[] globals)
 179    {
 2180        if (log.IsEnabled(Serilog.Events.LogEventLevel.Debug))
 181        {
 2182            log.Debug("Building C# authentication script with globals: {Globals}", globals);
 183        }
 184
 185        // Place-holders so Roslyn knows the globals that will exist
 10186        var stencil = globals.ToDictionary(n => n.Name, n => n.Prototype,
 2187                                             StringComparer.OrdinalIgnoreCase);
 2188        if (log.IsEnabled(Serilog.Events.LogEventLevel.Debug))
 189        {
 2190            log.Debug("Compiling C# authentication script with variables: {Variables}", stencil);
 191        }
 192
 2193        var script = CSharpDelegateBuilder.Compile(
 2194            settings.Code,
 2195            log,                             // already scoped by caller
 2196            settings.ExtraImports,
 2197            settings.ExtraRefs,
 2198            stencil,
 2199            languageVersion: settings.CSharpVersion);
 200
 201        // Return the runtime delegate
 2202        return async (ctx, vars) =>
 2203        {
 4204            if (log.IsEnabled(Serilog.Events.LogEventLevel.Debug))
 2205            {
 4206                log.Debug("Running C# authentication script with variables: {Variables}", vars);
 2207            }
 2208            // --- Kestrun plumbing -------------------------------------------------
 4209            var krReq = await KestrunRequest.NewRequest(ctx);
 4210            var krRes = new KestrunResponse(krReq);
 4211            var kCtx = new KestrunContext(krReq, krRes, ctx);
 2212            // ---------------------------------------------------------------------
 4213            var globalsDict = new Dictionary<string, object?>(
 4214                    vars, StringComparer.OrdinalIgnoreCase);
 2215            // Merge shared state + user variables
 4216            var globals = new CsGlobals(
 4217                SharedStateStore.Snapshot(),
 4218                kCtx,
 4219                globalsDict);
 2220
 4221            var result = await script.RunAsync(globals).ConfigureAwait(false);
 4222            return result.ReturnValue is true;
 6223        };
 224    }
 225
 226    internal static Func<HttpContext, IDictionary<string, object?>, Task<bool>> BuildVBNetValidator(
 227        AuthenticationCodeSettings settings,
 228        Serilog.ILogger log,
 229      params (string Name, object? Prototype)[] globals)
 230    {
 3231        if (settings is null)
 232        {
 0233            throw new ArgumentNullException(nameof(settings), "AuthenticationCodeSettings cannot be null");
 234        }
 235        // Place-holders so Roslyn knows the globals that will exist
 15236        var stencil = globals.ToDictionary(n => n.Name, n => n.Prototype,
 3237                                                 StringComparer.OrdinalIgnoreCase);
 238
 3239        if (log.IsEnabled(Serilog.Events.LogEventLevel.Debug))
 240        {
 2241            log.Debug("Compiling VB.NET authentication script with variables: {Variables}", stencil);
 242        }
 243
 244        // Compile the VB.NET script with the provided settings
 3245        var script = VBNetDelegateBuilder.Compile<bool>(
 3246            settings.Code,
 3247            log,                             // already scoped by caller
 3248            settings.ExtraImports,
 3249            settings.ExtraRefs,
 3250            stencil,
 3251            languageVersion: settings.VisualBasicVersion);
 252
 253        // Return the runtime delegate
 3254        return async (ctx, vars) =>
 3255        {
 4256            if (log.IsEnabled(Serilog.Events.LogEventLevel.Debug))
 3257            {
 4258                log.Debug("Running VB.NET authentication script with variables: {Variables}", vars);
 3259            }
 3260
 3261            // --- Kestrun plumbing -------------------------------------------------
 4262            var krReq = await KestrunRequest.NewRequest(ctx);
 4263            var krRes = new KestrunResponse(krReq);
 4264            var kCtx = new KestrunContext(krReq, krRes, ctx);
 3265            // ---------------------------------------------------------------------
 3266
 3267            // Merge shared state + user variables
 4268            var globals = new CsGlobals(
 4269                SharedStateStore.Snapshot(),
 4270                kCtx,
 4271                new Dictionary<string, object?>(vars, StringComparer.OrdinalIgnoreCase));
 3272
 4273            var result = await script(globals).ConfigureAwait(false);
 3274
 4275            return result is bool isValid && isValid;
 7276        };
 277    }
 278
 279
 280    /// <summary>
 281    /// Builds a PowerShell-based function for issuing claims for a user.
 282    /// </summary>
 283    /// <param name="settings">The authentication code settings containing the PowerShell script.</param>
 284    /// <param name="logger">The logger instance for logging.</param>
 285    /// <returns>A function that issues claims using the provided PowerShell script.</returns>
 286    static Func<HttpContext, string, Task<IEnumerable<Claim>>> BuildPsIssueClaims(
 287        AuthenticationCodeSettings settings, Serilog.ILogger logger) =>
 2288            async (ctx, identity) =>
 2289        {
 2290            return await IssueClaimsPowerShellAsync(settings.Code, ctx, identity, logger);
 4291        };
 292
 293    /// <summary>
 294    /// Issues claims for a user by executing a PowerShell script.
 295    /// </summary>
 296    /// <param name="code">The PowerShell script code used to issue claims.</param>
 297    /// <param name="ctx">The HTTP context containing the PowerShell runspace.</param>
 298    /// <param name="identity">The username for which to issue claims.</param>
 299    /// <param name="logger">The logger instance for logging.</param>
 300    /// <returns>A task representing the asynchronous operation, with a collection of issued claims.</returns>
 301    static async Task<IEnumerable<Claim>> IssueClaimsPowerShellAsync(string? code, HttpContext ctx, string identity, Ser
 302    {
 3303        if (string.IsNullOrWhiteSpace(identity))
 304        {
 1305            logger.Warning("Identity is null or empty.");
 1306            return [];
 307        }
 2308        if (string.IsNullOrEmpty(code))
 309        {
 0310            throw new InvalidOperationException("PowerShell authentication code is null or empty.");
 311        }
 312
 313        try
 314        {
 2315            var ps = GetPowerShell(ctx);
 2316            _ = ps.AddScript(code, useLocalScope: true).AddParameter("identity", identity);
 317
 2318            var psResults = await ps.InvokeAsync().ConfigureAwait(false);
 2319            if (psResults is null || psResults.Count == 0)
 320            {
 0321                return [];
 322            }
 323
 2324            var claims = new List<Claim>(psResults.Count);
 8325            foreach (var r in psResults)
 326            {
 2327                if (TryToClaim(r?.BaseObject, out var claim))
 328                {
 2329                    claims.Add(claim);
 330                }
 331                else
 332                {
 0333                    logger.Warning("PowerShell script returned an unsupported type: {Type}", r?.BaseObject?.GetType());
 0334                    throw new InvalidOperationException("PowerShell script returned an unsupported type.");
 335                }
 336            }
 337
 2338            return claims;
 339        }
 0340        catch (Exception ex)
 341        {
 0342            logger.Error(ex, "Error during Issue Claims for {Identity}", identity);
 0343            return [];
 344        }
 3345    }
 346
 347    /// <summary>
 348    /// Retrieves the PowerShell instance from the HTTP context.
 349    /// </summary>
 350    /// <param name="ctx">The HTTP context containing the PowerShell runspace.</param>
 351    /// <returns>The PowerShell instance associated with the context.</returns>
 352    /// <exception cref="InvalidOperationException">Thrown when the PowerShell runspace is not found.</exception>
 353    private static PowerShell GetPowerShell(HttpContext ctx)
 354    {
 4355        return !ctx.Items.TryGetValue("PS_INSTANCE", out var psObj) || psObj is not PowerShell ps || ps.Runspace == null
 4356            ? throw new InvalidOperationException("PowerShell runspace not found or not set in context items. Ensure Pow
 4357            : ps;
 358    }
 359
 360    /// <summary>
 361    /// Tries to create a Claim from the provided object.
 362    /// </summary>
 363    /// <param name="obj">The object to create a Claim from.</param>
 364    /// <param name="claim">The created Claim, if successful.</param>
 365    /// <returns>True if the Claim was created successfully; otherwise, false.</returns>
 366    private static bool TryToClaim(object? obj, out Claim claim)
 367    {
 6368        switch (obj)
 369        {
 370            case Claim c:
 1371                claim = c;
 1372                return true;
 373
 3374            case IDictionary dict when dict.Contains("Type") && dict.Contains("Value"):
 3375                var typeStr = dict["Type"]?.ToString();
 3376                var valueStr = dict["Value"]?.ToString();
 3377                if (!string.IsNullOrEmpty(typeStr) && !string.IsNullOrEmpty(valueStr))
 378                {
 3379                    claim = new Claim(typeStr, valueStr);
 3380                    return true;
 381                }
 382                break;
 383
 1384            case string s when s.Contains(':'):
 1385                var idx = s.IndexOf(':');
 1386                if (idx >= 0 && idx < s.Length - 1)
 387                {
 1388                    claim = new Claim(s[..idx], s[(idx + 1)..]);
 1389                    return true;
 390                }
 391                break;
 392            default:
 393                // Unsupported type
 394                break;
 395        }
 396
 1397        claim = default!;
 1398        return false;
 399    }
 400
 401
 402    /// <summary>
 403    /// Builds a C#-based function for issuing claims for a user.
 404    /// </summary>
 405    /// <param name="settings">The authentication code settings containing the C# script.</param>
 406    /// <param name="logger">The logger instance for logging.</param>
 407    /// <returns>A function that issues claims using the provided C# script.</returns>
 408    static Func<HttpContext, string, Task<IEnumerable<Claim>>> BuildCsIssueClaims(AuthenticationCodeSettings settings, S
 409    {
 3410        if (logger.IsEnabled(Serilog.Events.LogEventLevel.Debug))
 411        {
 3412            logger.Debug("Compiling C# script for issuing claims.");
 413        }
 414
 415        // Compile the C# script with the provided settings
 3416        var script = CSharpDelegateBuilder.Compile(settings.Code, logger,
 3417        settings.ExtraImports, settings.ExtraRefs,
 3418        new Dictionary<string, object?>
 3419            {
 3420                { "identity", "" }
 3421            }, languageVersion: settings.CSharpVersion);
 422
 3423        return async (ctx, identity) =>
 3424        {
 2425            var krRequest = await KestrunRequest.NewRequest(ctx);
 2426            var krResponse = new KestrunResponse(krRequest);
 2427            var context = new KestrunContext(krRequest, krResponse, ctx);
 2428            var globals = new CsGlobals(SharedStateStore.Snapshot(), context, new Dictionary<string, object?>
 2429            {
 2430                { "identity", identity }
 2431            });
 2432            var result = await script.RunAsync(globals).ConfigureAwait(false);
 2433            return result.ReturnValue is IEnumerable<Claim> claims
 2434                ? claims
 2435                : [];
 5436        };
 437    }
 438
 439
 440    /// <summary>
 441    /// Builds a VB.NET-based function for issuing claims for a user.
 442    /// </summary>
 443    /// <param name="settings">The authentication code settings containing the VB.NET script.</param>
 444    /// <param name="logger">The logger instance for logging.</param>
 445    /// <returns>A function that issues claims using the provided VB.NET script.</returns>
 446    static Func<HttpContext, string, Task<IEnumerable<Claim>>> BuildVBNetIssueClaims(AuthenticationCodeSettings settings
 447    {
 3448        if (logger.IsEnabled(Serilog.Events.LogEventLevel.Debug))
 449        {
 2450            logger.Debug("Compiling VB.NET script for issuing claims.");
 451        }
 452
 453        // Compile the VB.NET script with the provided settings
 3454        var script = VBNetDelegateBuilder.Compile<IEnumerable<Claim>>(settings.Code, logger,
 3455        settings.ExtraImports, settings.ExtraRefs,
 3456        new Dictionary<string, object?>
 3457            {
 3458                { "identity", "" }
 3459            }, languageVersion: settings.VisualBasicVersion);
 460
 3461        return async (ctx, identity) =>
 3462        {
 2463            var krRequest = await KestrunRequest.NewRequest(ctx);
 2464            var krResponse = new KestrunResponse(krRequest);
 2465            var context = new KestrunContext(krRequest, krResponse, ctx);
 2466            var glob = new CsGlobals(SharedStateStore.Snapshot(), context, new Dictionary<string, object?>
 2467            {
 2468                { "identity", identity }
 2469            });
 3470            // Run the VB.NET script and get the result
 3471            // Note: The script should return a boolean indicating success or failure
 2472            var result = await script(glob).ConfigureAwait(false);
 2473            return result is IEnumerable<Claim> claims
 2474              ? claims
 2475           : [];
 5476        };
 477    }
 478}