Commit 2e087e82 authored by Eric Dieckman's avatar Eric Dieckman
Browse files

Add in dev authentication

parent dc3c8f30
Loading
Loading
Loading
Loading
+87 −0
Original line number Diff line number Diff line
@@ -51,6 +51,93 @@
            Enables Visor user with the given options.
            </summary>
        </member>
        <member name="T:Cals.Visor.Api.Identity.DevAuthenticationDefaults">
            <summary>
            Default values for Shibboleth authentication middleware
            </summary>
        </member>
        <member name="F:Cals.Visor.Api.Identity.DevAuthenticationDefaults.AuthenticationScheme">
            <summary>
            Default value for <see cref="P:Microsoft.AspNetCore.Authentication.AuthenticationScheme.Name"/>.
            </summary>
        </member>
        <member name="F:Cals.Visor.Api.Identity.DevAuthenticationDefaults.DisplayName">
            <summary>
            Default value for <see cref="P:Microsoft.AspNetCore.Authentication.AuthenticationScheme.DisplayName"/>.
            </summary>
        </member>
        <member name="F:Cals.Visor.Api.Identity.DevAuthenticationDefaults.Issuer">
            <summary>
            Default value for <see cref="P:Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions.ClaimsIssuer"/>.
            </summary>
        </member>
        <member name="M:Cals.Visor.Api.Identity.DevAuthenticationExtensions.AddDevAuthentication(Microsoft.AspNetCore.Authentication.AuthenticationBuilder)">
            <summary>
            Enables dev authentication using the default scheme <see cref="F:Cals.Visor.Api.Identity.DevAuthenticationDefaults.AuthenticationScheme"/>
            </summary>
            <para>
            Manually configuring claims for JWT authentication
            </para>
            <param name="builder">The <see cref="T:Microsoft.AspNetCore.Authentication.AuthenticationBuilder"/>.</param>
            <returns>A reference to <paramref name="builder"/> after the operation has completed.</returns>
        </member>
        <member name="M:Cals.Visor.Api.Identity.DevAuthenticationExtensions.AddDevAuthentication(Microsoft.AspNetCore.Authentication.AuthenticationBuilder,System.String)">
            <summary>
            Enables dev authentication using a pre-defined scheme.
            <para>
            Manually configuring claims for JWT authentication
            </para>
            </summary>
            <param name="builder">The <see cref="T:Microsoft.AspNetCore.Authentication.AuthenticationBuilder"/>.</param>
            <param name="authenticationScheme">The authentication scheme.</param>
            <returns>A reference to <paramref name="builder"/> after the operation has completed.</returns>
        </member>
        <member name="M:Cals.Visor.Api.Identity.DevAuthenticationExtensions.AddDevAuthentication(Microsoft.AspNetCore.Authentication.AuthenticationBuilder,System.Action{Cals.Visor.Api.Identity.DevAuthenticationOptions})">
            <summary>
            Enables dev authentication using the default scheme <see cref="F:Cals.Visor.Api.Identity.DevAuthenticationDefaults.AuthenticationScheme"/>
            <para>
            Manually configuring claims for JWT authentication
            </para>
            </summary>
            <param name="builder">The <see cref="T:Microsoft.AspNetCore.Authentication.AuthenticationBuilder"/>.</param>
            <param name="configureOptions">A delegate that allows configuring <see cref="T:Cals.Visor.Api.Identity.DevAuthenticationOptions"/>.</param>
            <returns>A reference to <paramref name="builder"/> after the operation has completed.</returns>
        </member>
        <member name="M:Cals.Visor.Api.Identity.DevAuthenticationExtensions.AddDevAuthentication(Microsoft.AspNetCore.Authentication.AuthenticationBuilder,System.String,System.Action{Cals.Visor.Api.Identity.DevAuthenticationOptions})">
            <summary>
            Enables UW Shibboleth authentication using the specified scheme.
            <para>
            Manually configuring claims for JWT authentication
            </para>
            </summary>
            <param name="builder">The <see cref="T:Microsoft.AspNetCore.Authentication.AuthenticationBuilder"/>.</param>
            <param name="authenticationScheme">The authentication scheme.</param>
            <param name="configureOptions">A delegate that allows configuring <see cref="T:Cals.Visor.Api.Identity.DevAuthenticationOptions"/>.</param>
            <returns>A reference to <paramref name="builder"/> after the operation has completed.</returns>
        </member>
        <member name="M:Cals.Visor.Api.Identity.DevAuthenticationExtensions.AddDevAuthentication(Microsoft.AspNetCore.Authentication.AuthenticationBuilder,System.String,System.String,System.Action{Cals.Visor.Api.Identity.DevAuthenticationOptions})">
            <summary>
            Enables UW Shibboleth authentication using the specified scheme.
            <para>
            Manually configuring claims for JWT authentication
            </para>
            </summary>
            <param name="builder">The <see cref="T:Microsoft.AspNetCore.Authentication.AuthenticationBuilder"/>.</param>
            <param name="authenticationScheme">The authentication scheme.</param>
            <param name="displayName">The display name for the authentication handler.</param>
            <param name="configureOptions">A delegate that allows configuring <see cref="T:Cals.Visor.Api.Identity.DevAuthenticationOptions"/>.</param>
            <returns>A reference to <paramref name="builder"/> after the operation has completed.</returns>
        </member>
        <member name="T:Cals.Visor.Api.Identity.DevAuthenticationOptions">
            <summary>
            Configuration options for <see cref="T:Cals.Visor.Api.Identity.DevAuthenticationHandler"/>.
            </summary>
        </member>
        <member name="P:Cals.Visor.Api.Identity.DevAuthenticationOptions.TokenExpiration">
            <summary>
            Length of time a token is valid
            </summary>
        </member>
        <member name="T:Cals.Visor.Api.Identity.IdentityMiddleware">
            <summary>
            Middleware to add this app user's identity to the ClaimsPrincipal.
+240 −0
Original line number Diff line number Diff line
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Json;
using Cals.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;

namespace Cals.Visor.Api.Identity;

public static class AuthenticationServiceExtensions
{
    public static IServiceCollection AddAppAuthentication(
        this IServiceCollection services,
        IConfiguration configuration,
        IHostEnvironment environment,
        string? devUsername = null,
        string? defaultAuthenticationScheme = null)
    {
        if (string.IsNullOrEmpty(defaultAuthenticationScheme))
            defaultAuthenticationScheme = environment.IsDevelopment()
                    ? DevAuthenticationDefaults.AuthenticationScheme
                    : JwtBearerDefaults.AuthenticationScheme;


        var authBuilder = services.AddAuthentication(defaultAuthenticationScheme);

        // Always add JWT support
        authBuilder.AddJwtBearer(options =>
        {
            ConfigureJwtBearer(options, configuration);
        });

        // Add Dev authentication only in Development
        if (environment.IsDevelopment())
        {
            authBuilder.AddDevAuthentication(options =>
            {
                options.Claims = GetDevClaimsForUser(devUsername);
            });
        }

        return services;
    }

    private static void ConfigureJwtBearer(JwtBearerOptions options, IConfiguration configuration)
    {
        options.Authority = configuration["Jwt:Authority"];
        options.Audience = configuration["Jwt:Audience"];
        options.MetadataAddress = $"{options.Authority}/.well-known/openid-configuration";

        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateAudience = true,
            ValidateIssuer = true
        };

        var configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
            options.MetadataAddress,
            new OpenIdConnectConfigurationRetriever()
        );

        options.Events = new JwtBearerEvents
        {
            OnAuthenticationFailed = context =>
            {
                Console.WriteLine($"JWT authentication failed: {context.Exception.Message}");
                return Task.CompletedTask;
            },
            OnTokenValidated = async context =>
            {
                await HandleTokenValidatedAsync(context, configManager);
            }
        };
    }

    private static async Task HandleTokenValidatedAsync(
        TokenValidatedContext context,
        IConfigurationManager<OpenIdConnectConfiguration> configurationManager)
    {
        var cache = context.HttpContext.RequestServices.GetRequiredService<IMemoryCache>();
        var logger = context.HttpContext.RequestServices
            .GetRequiredService<ILoggerFactory>()
            .CreateLogger("JwtBearer");

        string? accessToken = context.SecurityToken switch
        {
            JwtSecurityToken jwt => jwt.RawData,
            Microsoft.IdentityModel.JsonWebTokens.JsonWebToken json => json.EncodedToken,
            _ => null
        };

        if (string.IsNullOrEmpty(accessToken))
        {
            logger.LogWarning("No access token found in SecurityToken.");
            return;
        }

        if (!cache.TryGetValue(accessToken, out Dictionary<string, object>? userInfo))
        {
            userInfo = await FetchAndCacheUserInfoAsync(accessToken, cache, configurationManager, context, logger);
            if (userInfo == null)
            {
                context.Fail("Failed to retrieve user info.");
                return;
            }
        }

        AttachUserInfoClaims(context, userInfo!);
    }

    private static async Task<Dictionary<string, object>?> FetchAndCacheUserInfoAsync(
        string accessToken,
        IMemoryCache cache,
        IConfigurationManager<OpenIdConnectConfiguration> configurationManager,
        TokenValidatedContext context,
        ILogger logger)
    {
        try
        {
            var config = await configurationManager.GetConfigurationAsync(context.HttpContext.RequestAborted);
            string userInfoEndpoint = config.UserInfoEndpoint;

            if (string.IsNullOrEmpty(userInfoEndpoint))
            {
                logger.LogError("UserInfo endpoint not found in discovery document.");
                return null;
            }

            using var http = new HttpClient();
            http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

            var response = await http.GetAsync(userInfoEndpoint);
            response.EnsureSuccessStatusCode();

            var userInfo = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>() ?? new();

            CacheUserInfo(cache, accessToken, userInfo, context);
            logger.LogInformation("Fetched and cached userinfo for token.");

            return userInfo;
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Failed to fetch userinfo.");
            return null;
        }
    }

    private static void CacheUserInfo(
        IMemoryCache cache,
        string accessToken,
        Dictionary<string, object> userInfo,
        TokenValidatedContext context)
    {
        string? expClaim = context.Principal?.FindFirst("exp")?.Value;
        if (!string.IsNullOrEmpty(expClaim) && long.TryParse(expClaim, out long expUnix))
        {
            DateTimeOffset expiresAt = DateTimeOffset.FromUnixTimeSeconds(expUnix);
            cache.Set(accessToken, userInfo, expiresAt);
        }
        else
        {
            cache.Set(accessToken, userInfo, TimeSpan.FromMinutes(5));
        }
    }

    private static void AttachUserInfoClaims(
        TokenValidatedContext context,
        Dictionary<string, object> userInfo)
    {
        var identity = (ClaimsIdentity)context.Principal!.Identity!;
        foreach (var kvp in userInfo)
        {
            identity.AddClaim(new Claim(kvp.Key, kvp.Value?.ToString() ?? ""));
        }
    }

    private static List<Claim> GetDevClaimsForUser(string? username)
    {
        string issuedBy = "https://localhost";

        // Default identity (fallback)
        var defaultClaims = new List<Claim>
        {
            new(WiscEduClaimTypes.FirstName, "Dev"),
            new(WiscEduClaimTypes.LastName, "User"),
            new(WiscEduClaimTypes.Name, "Dev User"),
            new(WiscEduClaimTypes.NetId, "jdoe"),
            new(WiscEduClaimTypes.Email, "it_apps@cals.wisc.edu"),
            new(JwtRegisteredClaimNames.Iss, issuedBy)
        };

        // Select user-specific claims
        return username?.ToLowerInvariant() switch
        {
            "ewdieckman" => [
                new(WiscEduClaimTypes.FirstName, "Eric"),
                new(WiscEduClaimTypes.LastName, "Dieckman"),
                new(WiscEduClaimTypes.Name, "Eric Dieckman"),
                new(WiscEduClaimTypes.NetId, "ewdieckman"),
                new(WiscEduClaimTypes.Email, "eric.dieckman@wisc.edu"),
                new(WiscEduClaimTypes.Pvi, "UW106Z480"),
                new(WiscEduClaimTypes.PrincipalName, "ewdieckman@wisc.edu"),
                new(WiscEduClaimTypes.Group, CreateGroupJson(
                    [
                    "uw:domain:cals.wisc.edu:apps:developers",
                    "uw:domain:cals.wisc.edu:apps:visor:admin",
                    "uw:domain:cals.wisc.edu:apps:visor:users"
                    ]
                )),
                new(JwtRegisteredClaimNames.Iss, issuedBy)
            ],
            "slien5" => [
                new(WiscEduClaimTypes.FirstName, "Sarah"),
                new(WiscEduClaimTypes.LastName, "Lien"),
                new(WiscEduClaimTypes.Name, "Sarah Lien"),
                new(WiscEduClaimTypes.NetId, "slien5"),
                new(WiscEduClaimTypes.Email, "sarah.lien@wisc.edu"),
                new(WiscEduClaimTypes.Pvi, "UW121Z750"),
                new(WiscEduClaimTypes.PrincipalName, "slien5@wisc.edu"),
                new(WiscEduClaimTypes.Group, CreateGroupJson(
                    [
                    "uw:domain:cals.wisc.edu:apps:visor:users"
                    ]
                )),
                new(JwtRegisteredClaimNames.Iss, issuedBy)
            ],
            _ => defaultClaims
        };
    }

    private static string CreateGroupJson(List<string> groups)
    {
        return JsonSerializer.Serialize(groups);
    }
}
+25 −0
Original line number Diff line number Diff line
using Microsoft.AspNetCore.Authentication;

namespace Cals.Visor.Api.Identity;

/// <summary>
/// Default values for Shibboleth authentication middleware
/// </summary>
public static class DevAuthenticationDefaults
{
    /// <summary>
    /// Default value for <see cref="AuthenticationScheme.Name"/>.
    /// </summary>
    public const string AuthenticationScheme = "DevAuthentication";

    /// <summary>
    /// Default value for <see cref="AuthenticationScheme.DisplayName"/>.
    /// </summary>
    public const string DisplayName = "DevAuthentication";

    /// <summary>
    /// Default value for <see cref="AuthenticationSchemeOptions.ClaimsIssuer"/>.
    /// </summary>
    public const string Issuer = "DevAuthentication";

}
+77 −0
Original line number Diff line number Diff line
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;

namespace Cals.Visor.Api.Identity;

public static class DevAuthenticationExtensions
{

    /// <summary>
    /// Enables dev authentication using the default scheme <see cref="DevAuthenticationDefaults.AuthenticationScheme"/>
    /// </summary>
    /// <para>
    /// Manually configuring claims for JWT authentication
    /// </para>
    /// <param name="builder">The <see cref="AuthenticationBuilder"/>.</param>
    /// <returns>A reference to <paramref name="builder"/> after the operation has completed.</returns>
    public static AuthenticationBuilder AddDevAuthentication(this AuthenticationBuilder builder)
    {
        return builder.AddDevAuthentication(DevAuthenticationDefaults.AuthenticationScheme, _ => { });
    }

    /// <summary>
    /// Enables dev authentication using a pre-defined scheme.
    /// <para>
    /// Manually configuring claims for JWT authentication
    /// </para>
    /// </summary>
    /// <param name="builder">The <see cref="AuthenticationBuilder"/>.</param>
    /// <param name="authenticationScheme">The authentication scheme.</param>
    /// <returns>A reference to <paramref name="builder"/> after the operation has completed.</returns>
    public static AuthenticationBuilder AddDevAuthentication(this AuthenticationBuilder builder, string authenticationScheme)
        => builder.AddDevAuthentication(authenticationScheme, _ => { });

    /// <summary>
    /// Enables dev authentication using the default scheme <see cref="DevAuthenticationDefaults.AuthenticationScheme"/>
    /// <para>
    /// Manually configuring claims for JWT authentication
    /// </para>
    /// </summary>
    /// <param name="builder">The <see cref="AuthenticationBuilder"/>.</param>
    /// <param name="configureOptions">A delegate that allows configuring <see cref="DevAuthenticationOptions"/>.</param>
    /// <returns>A reference to <paramref name="builder"/> after the operation has completed.</returns>
    public static AuthenticationBuilder AddDevAuthentication(this AuthenticationBuilder builder, Action<DevAuthenticationOptions> configureOptions)
        => builder.AddDevAuthentication(DevAuthenticationDefaults.AuthenticationScheme, configureOptions);

    /// <summary>
    /// Enables UW Shibboleth authentication using the specified scheme.
    /// <para>
    /// Manually configuring claims for JWT authentication
    /// </para>
    /// </summary>
    /// <param name="builder">The <see cref="AuthenticationBuilder"/>.</param>
    /// <param name="authenticationScheme">The authentication scheme.</param>
    /// <param name="configureOptions">A delegate that allows configuring <see cref="DevAuthenticationOptions"/>.</param>
    /// <returns>A reference to <paramref name="builder"/> after the operation has completed.</returns>
    public static AuthenticationBuilder AddDevAuthentication(this AuthenticationBuilder builder, string authenticationScheme, Action<DevAuthenticationOptions> configureOptions)
        => builder.AddDevAuthentication(authenticationScheme, displayName: null, configureOptions: configureOptions);

    /// <summary>
    /// Enables UW Shibboleth authentication using the specified scheme.
    /// <para>
    /// Manually configuring claims for JWT authentication
    /// </para>
    /// </summary>
    /// <param name="builder">The <see cref="AuthenticationBuilder"/>.</param>
    /// <param name="authenticationScheme">The authentication scheme.</param>
    /// <param name="displayName">The display name for the authentication handler.</param>
    /// <param name="configureOptions">A delegate that allows configuring <see cref="DevAuthenticationOptions"/>.</param>
    /// <returns>A reference to <paramref name="builder"/> after the operation has completed.</returns>
    public static AuthenticationBuilder AddDevAuthentication(this AuthenticationBuilder builder,
        string authenticationScheme,
        string? displayName,
        Action<DevAuthenticationOptions> configureOptions)
    {
        return builder.AddScheme<DevAuthenticationOptions, DevAuthenticationHandler>(authenticationScheme, displayName, configureOptions);
    }
}
+38 −0
Original line number Diff line number Diff line
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;

namespace Cals.Visor.Api.Identity;

public class DevAuthenticationHandler : AuthenticationHandler<DevAuthenticationOptions>
{
    private readonly TimeProvider _timeProvider;

    public DevAuthenticationHandler(
        IOptionsMonitor<DevAuthenticationOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        TimeProvider timeProvider)
        : base(options, logger, encoder) => _timeProvider = timeProvider;

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        List<Claim> claims = Options.Claims ??
        [
            new Claim(ClaimTypes.Name, "default-dev-user")
        ];

        // Example: add issued-at claim using TimeProvider
        DateTime issuedAt = _timeProvider.GetUtcNow().UtcDateTime;
        claims.Add(new Claim("iat", issuedAt.ToString("O")));

        claims.Add(new Claim("exp", (issuedAt + Options.TokenExpiration).ToString("O")));

        var identity = new ClaimsIdentity(claims, Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}
Loading