< 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@2d87023b37eb91155071c91dd3d6a2eeb3004705
Line coverage
89%
Covered lines: 196
Uncovered lines: 23
Coverable lines: 219
Total lines: 499
Line coverage: 89.4%
Branch coverage
73%
Covered branches: 71
Total branches: 96
Branch coverage: 73.9%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 08/26/2025 - 01:25:22 Line coverage: 87.5% (183/209) Branch coverage: 71.1% (64/90) Total lines: 478 Tag: Kestrun/Kestrun@07f821172e5dc3657f1be7e6818f18d6721cf38a09/06/2025 - 18:30:33 Line coverage: 90.4% (189/209) Branch coverage: 75.5% (68/90) Total lines: 478 Tag: Kestrun/Kestrun@aeddbedb8a96e9137aac94c2d5edd011b57ac87109/12/2025 - 03:43:11 Line coverage: 90.4% (189/209) Branch coverage: 75.5% (68/90) Total lines: 477 Tag: Kestrun/Kestrun@d160286e3020330b1eb862d66a37db2e26fc904210/13/2025 - 16:52:37 Line coverage: 89.4% (196/219) Branch coverage: 73.9% (71/96) Total lines: 499 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e 08/26/2025 - 01:25:22 Line coverage: 87.5% (183/209) Branch coverage: 71.1% (64/90) Total lines: 478 Tag: Kestrun/Kestrun@07f821172e5dc3657f1be7e6818f18d6721cf38a09/06/2025 - 18:30:33 Line coverage: 90.4% (189/209) Branch coverage: 75.5% (68/90) Total lines: 478 Tag: Kestrun/Kestrun@aeddbedb8a96e9137aac94c2d5edd011b57ac87109/12/2025 - 03:43:11 Line coverage: 90.4% (189/209) Branch coverage: 75.5% (68/90) Total lines: 477 Tag: Kestrun/Kestrun@d160286e3020330b1eb862d66a37db2e26fc904210/13/2025 - 16:52:37 Line coverage: 89.4% (196/219) Branch coverage: 73.9% (71/96) Total lines: 499 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e

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(...)66.66%6694.87%
BuildPsIssueClaims(...)100%11100%
IssueClaimsPowerShellAsync()50%301866.66%
GetPowerShell(...)100%66100%
TryToClaim(...)91.66%2424100%
BuildCsIssueClaims(...)75%4496%
BuildVBNetIssueClaims(...)75%4496.29%

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="host">The Kestrun host instance.</param>
 172    /// <param name="settings">The authentication code settings.</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        KestrunHost host,
 177        AuthenticationCodeSettings settings,
 178          params (string Name, object? Prototype)[] globals)
 179    {
 2180        var log = host.Logger;
 2181        if (log.IsEnabled(Serilog.Events.LogEventLevel.Debug))
 182        {
 2183            log.Debug("Building C# authentication script with globals: {Globals}", globals);
 184        }
 185
 186        // Place-holders so Roslyn knows the globals that will exist
 10187        var stencil = globals.ToDictionary(n => n.Name, n => n.Prototype,
 2188                                             StringComparer.OrdinalIgnoreCase);
 2189        if (log.IsEnabled(Serilog.Events.LogEventLevel.Debug))
 190        {
 2191            log.Debug("Compiling C# authentication script with variables: {Variables}", stencil);
 192        }
 193
 2194        var script = CSharpDelegateBuilder.Compile(
 2195            settings.Code,
 2196            log,                             // already scoped by caller
 2197            settings.ExtraImports,
 2198            settings.ExtraRefs,
 2199            stencil,
 2200            languageVersion: settings.CSharpVersion);
 201
 202        // Return the runtime delegate
 2203        return async (ctx, vars) =>
 2204        {
 4205            if (log.IsEnabled(Serilog.Events.LogEventLevel.Debug))
 2206            {
 4207                log.Debug("Running C# authentication script with variables: {Variables}", vars);
 2208            }
 2209            // --- Kestrun plumbing -------------------------------------------------
 4210            var krReq = await KestrunRequest.NewRequest(ctx);
 4211            var krRes = new KestrunResponse(krReq);
 4212            var kCtx = new KestrunContext(host, krReq, krRes, ctx);
 2213            // ---------------------------------------------------------------------
 4214            var globalsDict = new Dictionary<string, object?>(
 4215                    vars, StringComparer.OrdinalIgnoreCase);
 2216            // Merge shared state + user variables
 4217            var globals = new CsGlobals(
 4218                SharedStateStore.Snapshot(),
 4219                kCtx,
 4220                globalsDict);
 2221
 4222            var result = await script.RunAsync(globals).ConfigureAwait(false);
 4223            return result.ReturnValue is true;
 6224        };
 225    }
 226
 227    internal static Func<HttpContext, IDictionary<string, object?>, Task<bool>> BuildVBNetValidator(
 228        KestrunHost host,
 229        AuthenticationCodeSettings settings,
 230      params (string Name, object? Prototype)[] globals)
 231    {
 3232        if (host is null)
 233        {
 0234            throw new ArgumentNullException(nameof(host), "KestrunHost cannot be null");
 235        }
 3236        if (settings is null)
 237        {
 0238            throw new ArgumentNullException(nameof(settings), "AuthenticationCodeSettings cannot be null");
 239        }
 3240        var log = host.Logger;
 241        // Place-holders so Roslyn knows the globals that will exist
 15242        var stencil = globals.ToDictionary(n => n.Name, n => n.Prototype,
 3243                                                 StringComparer.OrdinalIgnoreCase);
 244
 3245        if (log.IsEnabled(Serilog.Events.LogEventLevel.Debug))
 246        {
 2247            log.Debug("Compiling VB.NET authentication script with variables: {Variables}", stencil);
 248        }
 249
 250        // Compile the VB.NET script with the provided settings
 3251        var script = VBNetDelegateBuilder.Compile<bool>(
 3252            settings.Code,
 3253            log,                             // already scoped by caller
 3254            settings.ExtraImports,
 3255            settings.ExtraRefs,
 3256            stencil,
 3257            languageVersion: settings.VisualBasicVersion);
 258
 259        // Return the runtime delegate
 3260        return async (ctx, vars) =>
 3261        {
 4262            if (log.IsEnabled(Serilog.Events.LogEventLevel.Debug))
 3263            {
 4264                log.Debug("Running VB.NET authentication script with variables: {Variables}", vars);
 3265            }
 3266
 3267            // --- Kestrun plumbing -------------------------------------------------
 4268            var krReq = await KestrunRequest.NewRequest(ctx);
 4269            var krRes = new KestrunResponse(krReq);
 4270            var kCtx = new KestrunContext(host, krReq, krRes, ctx);
 3271            // ---------------------------------------------------------------------
 3272
 3273            // Merge shared state + user variables
 4274            var globals = new CsGlobals(
 4275                SharedStateStore.Snapshot(),
 4276                kCtx,
 4277                new Dictionary<string, object?>(vars, StringComparer.OrdinalIgnoreCase));
 3278
 4279            var result = await script(globals).ConfigureAwait(false);
 3280
 4281            return result is bool isValid && isValid;
 7282        };
 283    }
 284
 285
 286    /// <summary>
 287    /// Builds a PowerShell-based function for issuing claims for a user.
 288    /// </summary>
 289    /// <param name="host">The Kestrun host instance.</param>
 290    /// <param name="settings">The authentication code settings containing the PowerShell script.</param>
 291    /// <returns>A function that issues claims using the provided PowerShell script.</returns>
 292    static Func<HttpContext, string, Task<IEnumerable<Claim>>> BuildPsIssueClaims(
 293        KestrunHost host,
 294        AuthenticationCodeSettings settings) =>
 2295            async (ctx, identity) =>
 2296        {
 2297            return await IssueClaimsPowerShellAsync(settings.Code, ctx, identity, host.Logger);
 4298        };
 299
 300    /// <summary>
 301    /// Issues claims for a user by executing a PowerShell script.
 302    /// </summary>
 303    /// <param name="code">The PowerShell script code used to issue claims.</param>
 304    /// <param name="ctx">The HTTP context containing the PowerShell runspace.</param>
 305    /// <param name="identity">The username for which to issue claims.</param>
 306    /// <param name="logger">The logger instance for logging.</param>
 307    /// <returns>A task representing the asynchronous operation, with a collection of issued claims.</returns>
 308    static async Task<IEnumerable<Claim>> IssueClaimsPowerShellAsync(string? code, HttpContext ctx, string identity, Ser
 309    {
 3310        if (string.IsNullOrWhiteSpace(identity))
 311        {
 1312            logger.Warning("Identity is null or empty.");
 1313            return [];
 314        }
 2315        if (string.IsNullOrEmpty(code))
 316        {
 0317            throw new InvalidOperationException("PowerShell authentication code is null or empty.");
 318        }
 319
 320        try
 321        {
 2322            var ps = GetPowerShell(ctx);
 2323            _ = ps.AddScript(code, useLocalScope: true).AddParameter("identity", identity);
 324
 2325            var psResults = await ps.InvokeAsync().ConfigureAwait(false);
 2326            if (psResults is null || psResults.Count == 0)
 327            {
 0328                return [];
 329            }
 330
 2331            var claims = new List<Claim>(psResults.Count);
 8332            foreach (var r in psResults)
 333            {
 2334                if (TryToClaim(r?.BaseObject, out var claim))
 335                {
 2336                    claims.Add(claim);
 337                }
 338                else
 339                {
 0340                    logger.Warning("PowerShell script returned an unsupported type: {Type}", r?.BaseObject?.GetType());
 0341                    throw new InvalidOperationException("PowerShell script returned an unsupported type.");
 342                }
 343            }
 344
 2345            return claims;
 346        }
 0347        catch (Exception ex)
 348        {
 0349            logger.Error(ex, "Error during Issue Claims for {Identity}", identity);
 0350            return [];
 351        }
 3352    }
 353
 354    /// <summary>
 355    /// Retrieves the PowerShell instance from the HTTP context.
 356    /// </summary>
 357    /// <param name="ctx">The HTTP context containing the PowerShell runspace.</param>
 358    /// <returns>The PowerShell instance associated with the context.</returns>
 359    /// <exception cref="InvalidOperationException">Thrown when the PowerShell runspace is not found.</exception>
 360    private static PowerShell GetPowerShell(HttpContext ctx)
 361    {
 4362        return !ctx.Items.TryGetValue("PS_INSTANCE", out var psObj) || psObj is not PowerShell ps || ps.Runspace == null
 4363            ? throw new InvalidOperationException("PowerShell runspace not found or not set in context items. Ensure Pow
 4364            : ps;
 365    }
 366
 367    /// <summary>
 368    /// Tries to create a Claim from the provided object.
 369    /// </summary>
 370    /// <param name="obj">The object to create a Claim from.</param>
 371    /// <param name="claim">The created Claim, if successful.</param>
 372    /// <returns>True if the Claim was created successfully; otherwise, false.</returns>
 373    private static bool TryToClaim(object? obj, out Claim claim)
 374    {
 6375        switch (obj)
 376        {
 377            case Claim c:
 1378                claim = c;
 1379                return true;
 380
 3381            case IDictionary dict when dict.Contains("Type") && dict.Contains("Value"):
 3382                var typeStr = dict["Type"]?.ToString();
 3383                var valueStr = dict["Value"]?.ToString();
 3384                if (!string.IsNullOrEmpty(typeStr) && !string.IsNullOrEmpty(valueStr))
 385                {
 3386                    claim = new Claim(typeStr, valueStr);
 3387                    return true;
 388                }
 389                break;
 390
 1391            case string s when s.Contains(':'):
 1392                var idx = s.IndexOf(':');
 1393                if (idx >= 0 && idx < s.Length - 1)
 394                {
 1395                    claim = new Claim(s[..idx], s[(idx + 1)..]);
 1396                    return true;
 397                }
 398                break;
 399            default:
 400                // Unsupported type
 401                break;
 402        }
 403
 1404        claim = default!;
 1405        return false;
 406    }
 407
 408
 409    /// <summary>
 410    /// Builds a C#-based function for issuing claims for a user.
 411    /// </summary>
 412    /// <param name="host">The Kestrun host instance.</param>
 413    /// <param name="settings">The authentication code settings containing the C# script.</param>
 414    /// <returns>A function that issues claims using the provided C# script.</returns>
 415    static Func<HttpContext, string, Task<IEnumerable<Claim>>> BuildCsIssueClaims(
 416        KestrunHost host,
 417        AuthenticationCodeSettings settings)
 418    {
 3419        if (host is null)
 420        {
 0421            throw new ArgumentNullException(nameof(host), "KestrunHost cannot be null");
 422        }
 3423        var logger = host.Logger;
 3424        if (logger.IsEnabled(Serilog.Events.LogEventLevel.Debug))
 425        {
 3426            logger.Debug("Compiling C# script for issuing claims.");
 427        }
 428
 429        // Compile the C# script with the provided settings
 3430        var script = CSharpDelegateBuilder.Compile(settings.Code, logger,
 3431        settings.ExtraImports, settings.ExtraRefs,
 3432        new Dictionary<string, object?>
 3433            {
 3434                { "identity", "" }
 3435            }, languageVersion: settings.CSharpVersion);
 436
 3437        return async (ctx, identity) =>
 3438        {
 2439            var krRequest = await KestrunRequest.NewRequest(ctx);
 2440            var krResponse = new KestrunResponse(krRequest);
 2441            var context = new KestrunContext(host, krRequest, krResponse, ctx);
 2442            var globals = new CsGlobals(SharedStateStore.Snapshot(), context, new Dictionary<string, object?>
 2443            {
 2444                { "identity", identity }
 2445            });
 2446            var result = await script.RunAsync(globals).ConfigureAwait(false);
 2447            return result.ReturnValue is IEnumerable<Claim> claims
 2448                ? claims
 2449                : [];
 5450        };
 451    }
 452
 453
 454    /// <summary>
 455    /// Builds a VB.NET-based function for issuing claims for a user.
 456    /// </summary>
 457    /// <param name="host">The Kestrun host instance.</param>
 458    /// <param name="settings">The authentication code settings containing the VB.NET script.</param>
 459    /// <returns>A function that issues claims using the provided VB.NET script.</returns>
 460    static Func<HttpContext, string, Task<IEnumerable<Claim>>> BuildVBNetIssueClaims(
 461        KestrunHost host,
 462        AuthenticationCodeSettings settings)
 463    {
 3464        if (host is null)
 465        {
 0466            throw new ArgumentNullException(nameof(host), "KestrunHost cannot be null");
 467        }
 3468        var logger = host.Logger;
 3469        if (logger.IsEnabled(Serilog.Events.LogEventLevel.Debug))
 470        {
 2471            logger.Debug("Compiling VB.NET script for issuing claims.");
 472        }
 473
 474        // Compile the VB.NET script with the provided settings
 3475        var script = VBNetDelegateBuilder.Compile<IEnumerable<Claim>>(settings.Code, logger,
 3476        settings.ExtraImports, settings.ExtraRefs,
 3477        new Dictionary<string, object?>
 3478            {
 3479                { "identity", "" }
 3480            }, languageVersion: settings.VisualBasicVersion);
 481
 3482        return async (ctx, identity) =>
 3483        {
 2484            var krRequest = await KestrunRequest.NewRequest(ctx);
 2485            var krResponse = new KestrunResponse(krRequest);
 2486            var context = new KestrunContext(host, krRequest, krResponse, ctx);
 2487            var glob = new CsGlobals(SharedStateStore.Snapshot(), context, new Dictionary<string, object?>
 2488            {
 2489                { "identity", identity }
 2490            });
 3491            // Run the VB.NET script and get the result
 3492            // Note: The script should return a boolean indicating success or failure
 2493            var result = await script(glob).ConfigureAwait(false);
 2494            return result is IEnumerable<Claim> claims
 2495              ? claims
 2496           : [];
 5497        };
 498    }
 499}