< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Authentication.OAuth2Options
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Authentication/OAuth2Options.cs
Tag: Kestrun/Kestrun@5f1d2b981c9d7292c11fd448428c6ab6c811c5de
Line coverage
93%
Covered lines: 109
Uncovered lines: 7
Coverable lines: 116
Total lines: 317
Line coverage: 93.9%
Branch coverage
76%
Covered branches: 38
Total branches: 50
Branch coverage: 76%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 11/19/2025 - 17:40:50 Line coverage: 93% (40/43) Branch coverage: 80% (16/20) Total lines: 107 Tag: Kestrun/Kestrun@fcf33342333cef0516fe0d0912a86709874fd02612/12/2025 - 17:27:19 Line coverage: 73.6% (42/57) Branch coverage: 66.6% (16/24) Total lines: 160 Tag: Kestrun/Kestrun@826bf9dcf9db118c5de4c78a3259bce9549f0dcd12/21/2025 - 06:07:10 Line coverage: 70% (42/60) Branch coverage: 66.6% (16/24) Total lines: 165 Tag: Kestrun/Kestrun@8cf7f77e55fd1fd046ea4e5413eb9ef96e49fe6a03/26/2026 - 03:54:59 Line coverage: 93.9% (109/116) Branch coverage: 76% (38/50) Total lines: 317 Tag: Kestrun/Kestrun@844b5179fb0492dc6b1182bae3ff65fa7365521d 11/19/2025 - 17:40:50 Line coverage: 93% (40/43) Branch coverage: 80% (16/20) Total lines: 107 Tag: Kestrun/Kestrun@fcf33342333cef0516fe0d0912a86709874fd02612/12/2025 - 17:27:19 Line coverage: 73.6% (42/57) Branch coverage: 66.6% (16/24) Total lines: 160 Tag: Kestrun/Kestrun@826bf9dcf9db118c5de4c78a3259bce9549f0dcd12/21/2025 - 06:07:10 Line coverage: 70% (42/60) Branch coverage: 66.6% (16/24) Total lines: 165 Tag: Kestrun/Kestrun@8cf7f77e55fd1fd046ea4e5413eb9ef96e49fe6a03/26/2026 - 03:54:59 Line coverage: 93.9% (109/116) Branch coverage: 76% (38/50) Total lines: 317 Tag: Kestrun/Kestrun@844b5179fb0492dc6b1182bae3ff65fa7365521d

Coverage delta

Coverage delta 24 -24

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_CookieOptions()100%11100%
get_GlobalScheme()100%11100%
get_Description()100%11100%
get_DisplayName()100%11100%
get_Deprecated()100%11100%
get_DocumentationId()100%11100%
get_Host()100%11100%
.ctor()100%11100%
get_AuthenticationScheme()100%11100%
get_CookieScheme()100%22100%
get_ClaimPolicy()100%11100%
get_OAuth2MetadataUrl()100%11100%
get_ResolveEndpointsFromMetadata()100%11100%
get_AllowInsecureMetadataHttp()100%11100%
get_Logger()0%2040%
ApplyTo(...)100%11100%
ApplyTo(...)77.78%181890.91%
PopulateEndpointsFromMetadataAsync()100%1010100%
HasMissingMetadataEndpoints(...)100%44100%
FetchMetadataDocumentAsync()100%22100%
TryResolveEndpointFromMetadata(...)60%111076.92%

File(s)

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

#LineLine coverage
 1
 2using Kestrun.Claims;
 3using Kestrun.Hosting;
 4using Microsoft.AspNetCore.Authentication;
 5using Microsoft.AspNetCore.Authentication.Cookies;
 6using Microsoft.AspNetCore.Authentication.OAuth;
 7using System.Text.Json;
 8
 9namespace Kestrun.Authentication;
 10
 11/// <summary>
 12/// Options for OAuth2 authentication.
 13/// </summary>
 14public class OAuth2Options : OAuthOptions, IOpenApiAuthenticationOptions, IAuthenticationHostOptions, IOAuthCommonOption
 15{
 16    /// <summary>
 17    /// Options for cookie authentication.
 18    /// </summary>
 1919    public CookieAuthOptions CookieOptions { get; }
 20
 21    /// <inheritdoc/>
 922    public bool GlobalScheme { get; set; }
 23
 24    /// <inheritdoc/>
 925    public string? Description { get; set; }
 26
 27    /// <inheritdoc/>
 228    public string? DisplayName { get; set; }
 29
 30    /// <inheritdoc/>
 931    public bool Deprecated { get; set; }
 32
 33    /// <inheritdoc/>
 5634    public string[] DocumentationId { get; set; } = [];
 35
 36#pragma warning disable IDE0370 // Remove unnecessary suppression
 37    /// <inheritdoc/>
 1438    public KestrunHost Host { get; set; } = default!;
 39#pragma warning restore IDE0370 // Remove unnecessary suppression
 40
 41    /// <summary>
 42    /// Initializes a new instance of the <see cref="OAuth2Options"/> class.
 43    /// </summary>
 3044    public OAuth2Options()
 45    {
 3046        CookieOptions = new CookieAuthOptions()
 3047        {
 3048            SlidingExpiration = true
 3049        };
 3050    }
 51    /// <summary>
 52    /// Gets or sets the authentication scheme name.
 53    /// </summary>
 5554    public string AuthenticationScheme { get; set; } = AuthenticationDefaults.OAuth2SchemeName;
 55
 56    /// <summary>
 57    /// Gets the cookie authentication scheme name.
 58    /// </summary>
 59    public string CookieScheme =>
 1460    CookieOptions.Cookie.Name ?? (CookieAuthenticationDefaults.AuthenticationScheme + "." + AuthenticationScheme);
 61
 62    /// <summary>
 63    /// Configuration for claim policy enforcement.
 64    /// </summary>
 3565    public ClaimPolicyConfig? ClaimPolicy { get; set; }
 66
 67    /// <summary>
 68    /// Gets or sets the OAuth2 authorization server metadata URL (RFC 8414).
 69    /// This is used for OpenAPI metadata and optional endpoint discovery.
 70    /// </summary>
 6271    public string? OAuth2MetadataUrl { get; set; }
 72
 73    /// <summary>
 74    /// Gets or sets a value indicating whether missing OAuth2 endpoints should be
 75    /// resolved from <see cref="OAuth2MetadataUrl"/>.
 76    /// </summary>
 2377    public bool ResolveEndpointsFromMetadata { get; set; } = false;
 78
 79    /// <summary>
 80    /// Gets or sets a value indicating whether OAuth2 metadata discovery may use a non-HTTPS URL.
 81    /// Defaults to <see langword="false"/> so metadata discovery requires HTTPS.
 82    /// </summary>
 1083    public bool AllowInsecureMetadataHttp { get; set; } = false;
 84
 85    private Serilog.ILogger? _logger;
 86    /// <inheritdoc/>
 87    public Serilog.ILogger Logger
 88    {
 089        get => _logger ?? (Host is null ? Serilog.Log.Logger : Host.Logger); set => _logger = value;
 90    }
 91
 92    /// <summary>
 93    /// Helper to copy values from a user-supplied OAuth2Options instance to the instance
 94    /// created by the framework inside AddOAuth(). Reassigning the local variable (opts = source) would
 95    /// not work because only the local reference changes – the framework keeps the original instance.
 96    /// </summary>
 97    /// <param name="target">The target OAuth2Options instance to copy values to.</param>
 98    public void ApplyTo(OAuth2Options target)
 99    {
 2100        ApplyTo((OAuthOptions)target);
 1101        CookieOptions.ApplyTo(target.CookieOptions);
 102        // OpenAPI / documentation properties
 1103        target.GlobalScheme = GlobalScheme;
 1104        target.Description = Description;
 1105        target.DisplayName = DisplayName;
 1106        target.DocumentationId = DocumentationId;
 1107        target.Host = Host;
 1108        target.ClaimPolicy = ClaimPolicy;
 1109        target.Deprecated = Deprecated;
 1110        target.OAuth2MetadataUrl = OAuth2MetadataUrl;
 1111        target.ResolveEndpointsFromMetadata = ResolveEndpointsFromMetadata;
 1112        target.AllowInsecureMetadataHttp = AllowInsecureMetadataHttp;
 1113    }
 114
 115    /// <summary>
 116    /// Apply these options to the target <see cref="OAuthOptions"/> instance.
 117    /// </summary>
 118    /// <param name="target">The target OAuthOptions instance to apply settings to.</param>
 119    public void ApplyTo(OAuthOptions target)
 120    {
 121        // Core OAuth endpoints
 7122        target.AuthorizationEndpoint = AuthorizationEndpoint;
 6123        target.TokenEndpoint = TokenEndpoint;
 6124        target.UserInformationEndpoint = UserInformationEndpoint;
 6125        target.ClientId = ClientId;
 6126        target.ClientSecret = ClientSecret;
 6127        target.CallbackPath = CallbackPath;
 128
 129        // OAuth configuration
 6130        target.UsePkce = UsePkce;
 6131        target.SaveTokens = SaveTokens;
 6132        target.ClaimsIssuer = ClaimsIssuer;
 133
 134        // Scopes - clear and copy
 6135        target.Scope.Clear();
 16136        foreach (var scope in Scope)
 137        {
 2138            target.Scope.Add(scope);
 139        }
 140
 141        // Token handling
 6142        target.AccessDeniedPath = AccessDeniedPath;
 6143        target.RemoteAuthenticationTimeout = RemoteAuthenticationTimeout;
 6144        target.ReturnUrlParameter = ReturnUrlParameter;
 145
 146        // Scheme linkage
 6147        target.SignInScheme = SignInScheme;
 148
 149        // Backchannel configuration
 6150        if (Backchannel != null)
 151        {
 0152            target.Backchannel = Backchannel;
 153        }
 6154        if (BackchannelHttpHandler != null)
 155        {
 0156            target.BackchannelHttpHandler = BackchannelHttpHandler;
 157        }
 6158        if (BackchannelTimeout != default)
 159        {
 6160            target.BackchannelTimeout = BackchannelTimeout;
 161        }
 162
 163        // Claim actions
 6164        if (ClaimActions != null)
 165        {
 16166            foreach (var jka in ClaimActions
 6167                .OfType<Microsoft.AspNetCore.Authentication.OAuth.Claims.JsonKeyClaimAction>()
 8168                .Where(a => !string.IsNullOrEmpty(a.JsonKey) && !string.IsNullOrEmpty(a.ClaimType)))
 169            {
 2170                target.ClaimActions.MapJsonKey(jka.ClaimType, jka.JsonKey);
 171            }
 172        }
 173
 174        // Events - copy if provided
 6175        if (Events != null)
 176        {
 6177            target.Events = Events;
 178        }
 6179        if (EventsType != null)
 180        {
 0181            target.EventsType = EventsType;
 182        }
 183
 184        // Other properties
 6185        target.StateDataFormat = StateDataFormat;
 6186    }
 187
 188    /// <summary>
 189    /// Populates missing OAuth2 endpoints from an OAuth2 metadata document.
 190    /// </summary>
 191    /// <param name="options">The OAuth2 options to populate.</param>
 192    /// <param name="httpClient">The HTTP client used to fetch metadata.</param>
 193    /// <param name="cancellationToken">A cancellation token.</param>
 194    /// <returns>A task that represents the asynchronous operation.</returns>
 195    internal static async Task PopulateEndpointsFromMetadataAsync(
 196        OAuth2Options options,
 197        HttpClient httpClient,
 198        CancellationToken cancellationToken = default)
 199    {
 9200        ArgumentNullException.ThrowIfNull(options);
 9201        ArgumentNullException.ThrowIfNull(httpClient);
 202
 9203        if (string.IsNullOrWhiteSpace(options.OAuth2MetadataUrl))
 204        {
 1205            return;
 206        }
 207
 8208        if (!HasMissingMetadataEndpoints(options))
 209        {
 1210            return;
 211        }
 212
 7213        using var json = await FetchMetadataDocumentAsync(
 7214            options.OAuth2MetadataUrl,
 7215            httpClient,
 7216            cancellationToken).ConfigureAwait(false);
 217
 5218        if (TryResolveEndpointFromMetadata(
 5219            options.AuthorizationEndpoint,
 5220            json.RootElement,
 5221            "authorization_endpoint",
 5222            out var authorizationEndpoint))
 223        {
 4224            options.AuthorizationEndpoint = authorizationEndpoint;
 225        }
 226
 5227        if (TryResolveEndpointFromMetadata(
 5228            options.TokenEndpoint,
 5229            json.RootElement,
 5230            "token_endpoint",
 5231            out var tokenEndpoint))
 232        {
 4233            options.TokenEndpoint = tokenEndpoint;
 234        }
 235
 5236        if (TryResolveEndpointFromMetadata(
 5237            options.UserInformationEndpoint,
 5238            json.RootElement,
 5239            "userinfo_endpoint",
 5240            out var userInformationEndpoint))
 241        {
 5242            options.UserInformationEndpoint = userInformationEndpoint;
 243        }
 7244    }
 245
 246    /// <summary>
 247    /// Determines whether any OAuth2 endpoint that can be resolved from metadata is missing.
 248    /// </summary>
 249    /// <param name="options">The OAuth2 options to inspect.</param>
 250    /// <returns><see langword="true"/> when at least one endpoint is missing; otherwise, <see langword="false"/>.</retu
 251    private static bool HasMissingMetadataEndpoints(OAuth2Options options) =>
 8252        string.IsNullOrWhiteSpace(options.AuthorizationEndpoint) ||
 8253        string.IsNullOrWhiteSpace(options.TokenEndpoint) ||
 8254        string.IsNullOrWhiteSpace(options.UserInformationEndpoint);
 255
 256    /// <summary>
 257    /// Downloads and parses the OAuth2 metadata document.
 258    /// </summary>
 259    /// <param name="metadataUrl">The metadata document URL.</param>
 260    /// <param name="httpClient">The HTTP client used to fetch metadata.</param>
 261    /// <param name="cancellationToken">A cancellation token.</param>
 262    /// <returns>The parsed metadata document.</returns>
 263    private static async Task<JsonDocument> FetchMetadataDocumentAsync(
 264        string metadataUrl,
 265        HttpClient httpClient,
 266        CancellationToken cancellationToken)
 267    {
 7268        using var response = await httpClient.GetAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
 7269        _ = response.EnsureSuccessStatusCode();
 270
 5271        await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 5272        return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
 5273    }
 274
 275    /// <summary>
 276    /// Resolves a single OAuth2 endpoint value from metadata when the current value is missing.
 277    /// </summary>
 278    /// <param name="currentEndpoint">The current endpoint value.</param>
 279    /// <param name="metadataRoot">The metadata JSON root element.</param>
 280    /// <param name="propertyName">The metadata property name to read.</param>
 281    /// <param name="resolvedEndpoint">The resolved endpoint, when available.</param>
 282    /// <returns><see langword="true"/> when an endpoint was resolved from metadata; otherwise <see langword="false"/>.<
 283    /// <exception cref="FormatException">Thrown when a discovered endpoint is not an absolute URI.</exception>
 284    private static bool TryResolveEndpointFromMetadata(
 285        string? currentEndpoint,
 286        JsonElement metadataRoot,
 287        string propertyName,
 288        out string resolvedEndpoint)
 289    {
 15290        resolvedEndpoint = string.Empty;
 291
 15292        if (!string.IsNullOrWhiteSpace(currentEndpoint))
 293        {
 2294            return false;
 295        }
 296
 13297        if (!metadataRoot.TryGetProperty(propertyName, out var endpointElement) ||
 13298            endpointElement.ValueKind != JsonValueKind.String)
 299        {
 0300            return false;
 301        }
 302
 13303        var endpoint = endpointElement.GetString();
 13304        if (string.IsNullOrWhiteSpace(endpoint))
 305        {
 0306            return false;
 307        }
 308
 13309        if (Uri.TryCreate(endpoint, UriKind.Absolute, out _))
 310        {
 13311            resolvedEndpoint = endpoint;
 13312            return true;
 313        }
 314
 0315        throw new FormatException($"OAuth2 metadata property '{propertyName}' must be an absolute URI, but received '{en
 316    }
 317}