Added extendability to the sign-in / sync, the session validation and the frontend claims transfer calls

This commit is contained in:
2025-08-20 17:01:42 +02:00
parent 3cc48fb8f7
commit 26f955fce2
6 changed files with 122 additions and 16 deletions

View File

@@ -1,4 +1,5 @@
using MoonCore.Helpers;
using Moonlight.ApiServer.Implementations.LocalAuth;
using YamlDotNet.Serialization;
namespace Moonlight.ApiServer.Configuration;
@@ -29,6 +30,10 @@ public record AppConfiguration
Kestrel = new()
{
AllowedOrigins = []
},
Authentication = new()
{
EnabledSchemes = []
}
};
}
@@ -60,6 +65,9 @@ public record AppConfiguration
[YamlMember(Description = "This specifies if the first registered/synced user will become an admin automatically")]
public bool FirstUserAdmin { get; set; } = true;
[YamlMember(Description = "This specifies the authentication schemes the frontend should be able to challenge")]
public string[] EnabledSchemes { get; set; } = [LocalAuthConstants.AuthenticationScheme];
}
public record SessionsConfig

View File

@@ -3,7 +3,9 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Implementations.LocalAuth;
using Moonlight.ApiServer.Interfaces;
using Moonlight.Shared.Http.Responses.Auth;
namespace Moonlight.ApiServer.Http.Controllers.Auth;
@@ -13,13 +15,18 @@ namespace Moonlight.ApiServer.Http.Controllers.Auth;
public class AuthController : Controller
{
private readonly IAuthenticationSchemeProvider SchemeProvider;
private readonly IEnumerable<IAuthCheckExtension> Extensions;
private readonly AppConfiguration Configuration;
// Add schemes which should be offered to the client here
private readonly string[] SchemeWhitelist = [LocalAuthConstants.AuthenticationScheme];
public AuthController(IAuthenticationSchemeProvider schemeProvider)
public AuthController(
IAuthenticationSchemeProvider schemeProvider,
IEnumerable<IAuthCheckExtension> extensions,
AppConfiguration configuration
)
{
SchemeProvider = schemeProvider;
Extensions = extensions;
Configuration = configuration;
}
[HttpGet]
@@ -27,8 +34,10 @@ public class AuthController : Controller
{
var schemes = await SchemeProvider.GetAllSchemesAsync();
var allowedSchemes = Configuration.Authentication.EnabledSchemes;
return schemes
.Where(x => SchemeWhitelist.Contains(x.Name))
.Where(x => allowedSchemes.Contains(x.Name))
.Select(scheme => new AuthSchemeResponse()
{
DisplayName = scheme.DisplayName ?? scheme.Name,
@@ -40,11 +49,10 @@ public class AuthController : Controller
[HttpGet("{identifier:alpha}")]
public async Task StartScheme([FromRoute] string identifier)
{
var scheme = await SchemeProvider.GetSchemeAsync(identifier);
// Validate identifier against our enable list
var allowedSchemes = Configuration.Authentication.EnabledSchemes;
// The check for the whitelist ensures a user isn't starting an auth flow
// which isn't meant for users
if (scheme == null || !SchemeWhitelist.Contains(scheme.Name))
if (!allowedSchemes.Contains(identifier))
{
await Results
.Problem(
@@ -56,6 +64,22 @@ public class AuthController : Controller
return;
}
// Now we can check if it even exists
var scheme = await SchemeProvider.GetSchemeAsync(identifier);
if (scheme == null)
{
await Results
.Problem(
"Invalid scheme identifier provided",
statusCode: 404
)
.ExecuteAsync(HttpContext);
return;
}
// Everything fine, challenge the frontend
await HttpContext.ChallengeAsync(
scheme.Name,
new AuthenticationProperties()
@@ -67,7 +91,7 @@ public class AuthController : Controller
[Authorize]
[HttpGet("check")]
public Task<AuthClaimResponse[]> Check()
public async Task<AuthClaimResponse[]> Check()
{
var username = User.FindFirstValue(ClaimTypes.Name)!;
var id = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
@@ -75,6 +99,7 @@ public class AuthController : Controller
var userId = User.FindFirstValue("UserId")!;
var permissions = User.FindFirstValue("Permissions")!;
// Create basic set of claims used by the frontend
var claims = new List<AuthClaimResponse>()
{
new(ClaimTypes.Name, username),
@@ -84,9 +109,15 @@ public class AuthController : Controller
new("Permissions", permissions)
};
return Task.FromResult(
claims.ToArray()
);
// Enrich the frontend claims by extensions (used by plugins)
foreach (var extension in Extensions)
{
claims.AddRange(
await extension.GetFrontendClaims(User)
);
}
return claims.ToArray();
}
[HttpGet("logout")]

View File

@@ -0,0 +1,16 @@
using System.Security.Claims;
using Moonlight.Shared.Http.Responses.Auth;
namespace Moonlight.ApiServer.Interfaces;
public interface IAuthCheckExtension
{
/// <summary>
/// This function will be called by the frontend reaching out to the api server for claim information.
/// You can use this function to give your frontend plugins access to user specific data which is
/// static for the session. E.g. the avatar url of a user
/// </summary>
/// <param name="principal">The principal of the current signed-in user</param>
/// <returns>An array of claim responses which gets added to the list of claims to send to the frontend</returns>
public Task<AuthClaimResponse[]> GetFrontendClaims(ClaimsPrincipal principal);
}

View File

@@ -0,0 +1,25 @@
using System.Security.Claims;
using Moonlight.ApiServer.Database.Entities;
namespace Moonlight.ApiServer.Interfaces;
public interface IUserAuthExtension
{
/// <summary>
/// This function is called on every sign-in. It should be used to synchronize additional user data from the principal
/// or extend the claims saved in the user session
/// </summary>
/// <param name="user">The current user this method is called for</param>
/// <param name="principal">The principal after being processed by moonlight itself</param>
/// <returns>The result of the synchronisation. Returning false will immediately invalidate the sign-in and no other extensions will be called</returns>
public Task<bool> Sync(User user, ClaimsPrincipal principal);
/// <summary>
/// IMPORTANT: Please note that heavy operations should not occur in this method as it will be called for every request
/// of every user
/// </summary>
/// <param name="user">The current user this method is called for</param>
/// <param name="principal">The principal after being processed by moonlight itself</param>
/// <returns>The result of the validation. Returning false will immediately invalidate the users session and no other extensions will be called</returns>
public Task<bool> Validate(User user, ClaimsPrincipal principal);
}

View File

@@ -6,6 +6,7 @@ using MoonCore.Extended.Helpers;
using MoonCore.Helpers;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Interfaces;
namespace Moonlight.ApiServer.Services;
@@ -14,6 +15,7 @@ public class UserAuthService
private readonly ILogger<UserAuthService> Logger;
private readonly DatabaseRepository<User> UserRepository;
private readonly AppConfiguration Configuration;
private readonly IEnumerable<IUserAuthExtension> Extensions;
private const string UserIdClaim = "UserId";
private const string IssuedAtClaim = "IssuedAt";
@@ -21,12 +23,14 @@ public class UserAuthService
public UserAuthService(
ILogger<UserAuthService> logger,
DatabaseRepository<User> userRepository,
AppConfiguration configuration
AppConfiguration configuration,
IEnumerable<IUserAuthExtension> extensions
)
{
Logger = logger;
UserRepository = userRepository;
Configuration = configuration;
Extensions = extensions;
}
public async Task<bool> Sync(ClaimsPrincipal? principal)
@@ -93,11 +97,21 @@ public class UserAuthService
await UserRepository.Update(user);
}
// Enrich claims with required metadata
principal.Identities.First().AddClaims([
new Claim(UserIdClaim, user.Id.ToString()),
new Claim(IssuedAtClaim, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
new Claim("Permissions", string.Join(';', user.Permissions))
]);
// Call extensions
foreach (var extension in Extensions)
{
var result = await extension.Sync(user, principal);
if (!result) // Exit immediately if result is false
return false;
}
return true;
}
@@ -137,6 +151,18 @@ public class UserAuthService
// everything is fine. If not it means that the token should be invalidated
// as it is too old
return issuedAt > user.TokenValidTimestamp;
if (issuedAt < user.TokenValidTimestamp)
return false;
// Call extensions
foreach (var extension in Extensions)
{
var result = await extension.Validate(user, principal);
if (!result) // Exit immediately if result is false
return false;
}
return true;
}
}

View File

@@ -10,7 +10,7 @@
<LazyLoader EnableDefaultSpacing="false" Load="Load">
@if (ShowSelection)
{
<h5 class="card-title mb-2.5">Login to MoonCore</h5>
<h5 class="card-title mb-2.5">Login to Moonlight</h5>
<p class="mb-4">Choose a login provider to start using the app</p>