jellyfin-plugin-keycloak-auth/Jellyfin.Plugin.Keycloak/KeyCloakAuthenticationProviderPlugin.cs
2025-03-31 00:08:54 +02:00

287 lines
11 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.Keycloak.Configuration;
using JWT.Builder;
using MediaBrowser.Common;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
namespace Jellyfin.Plugin.Keycloak
{
/// <summary>
/// KeyCloak Authentication Provider Plugin.
/// </summary>
public class KeyCloakAuthenticationProviderPlugin : IAuthenticationProvider
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<KeyCloakAuthenticationProviderPlugin> _logger;
private readonly IApplicationHost _applicationHost;
private string _twoFactorPattern = @"(.*)(\d{6})$";
/// <summary>
/// Initializes a new instance of the <see cref="KeyCloakAuthenticationProviderPlugin"/> class.
/// </summary>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
/// <param name="applicationHost">Instance of the <see cref="IApplicationHost"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
public KeyCloakAuthenticationProviderPlugin(
IHttpClientFactory httpClientFactory,
IApplicationHost applicationHost,
ILogger<KeyCloakAuthenticationProviderPlugin> logger)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_applicationHost = applicationHost;
}
private string TokenUri => $"{Cfg().AuthServerUrl}/realms/{Cfg().Realm}/protocol/openid-connect/token";
/// <inheritdoc />
public string Name => "Keycloak-Authentication";
/// <inheritdoc />
public bool IsEnabled => true;
private HttpClient GetHttpClient()
{
return _httpClientFactory.CreateClient(NamedClient.Default);
}
private PluginConfiguration Cfg()
{
return Plugin.Instance.Configuration;
}
private async Task<(KeycloakUser?, bool)> GetKeycloakUser(string username, string password, string? totp)
{
var httpClient = GetHttpClient();
var keyValues = new List<KeyValuePair<string, string?>>
{
new KeyValuePair<string, string?>("username", username),
new KeyValuePair<string, string?>("password", password),
new KeyValuePair<string, string?>("grant_type", "password"),
new KeyValuePair<string, string?>("client_id", Cfg().ClientId)
};
if (!string.IsNullOrWhiteSpace(Cfg().ClientSecret))
{
keyValues.Add(new KeyValuePair<string, string?>("client_secret", Cfg().ClientSecret));
}
if (!string.IsNullOrWhiteSpace(Cfg().OAuthScope))
{
keyValues.Add(new KeyValuePair<string, string?>("scope", Cfg().OAuthScope));
}
if (!string.IsNullOrWhiteSpace(totp))
{
keyValues.Add(new KeyValuePair<string, string?>("totp", totp));
}
var content = new FormUrlEncodedContent(keyValues);
var response = await httpClient.PostAsync(TokenUri, content).ConfigureAwait(false);
var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
KeycloakErrorResponse? err = await JsonSerializer.DeserializeAsync<KeycloakErrorResponse>(responseStream).ConfigureAwait(false);
if (err == null)
{
return (null, false);
}
else if (err.Error == "invalid_grant")
{
return (null, true);
}
else
{
_logger.LogError("Keycloak error {0}: {1}", err.Error, err.ErrorDescription);
return (null, err.Error == "invalid_grant");
}
}
KeycloakTokenResponse? parsed = await JsonSerializer.DeserializeAsync<KeycloakTokenResponse>(responseStream).ConfigureAwait(false);
if (parsed == null)
{
_logger.LogError("Error parsing Keycloak token response");
return (null, false);
}
try
{
var jwtToken = JwtBuilder.Create().Decode<IDictionary<string, object>>(parsed.AccessToken);
Collection<string> perms = new Collection<string>();
var permsObj = GetJwtAttribute(jwtToken, Cfg().RolesTokenAttribute);
if (permsObj != null)
{
perms = ((JArray)permsObj).ToObject<Collection<string>>();
}
var usernameObj = GetJwtAttribute(jwtToken, Cfg().UsernameTokenAttribute);
if (usernameObj != null)
{
username = (string)usernameObj;
}
KeycloakUser user = new KeycloakUser(username, perms);
return (user, false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error parsing jwt token");
}
return (null, false);
}
private async Task UpdateUserInfo(KeycloakUser keycloakUser, User? jellyfinUser)
{
var userManager = _applicationHost.Resolve<IUserManager>();
if (jellyfinUser != null)
{
jellyfinUser.SetPermission(PermissionKind.IsDisabled, !keycloakUser.IsEnabled);
jellyfinUser.SetPermission(PermissionKind.IsAdministrator, keycloakUser.IsAdmin);
jellyfinUser.SetPermission(PermissionKind.EnableCollectionManagement, keycloakUser.IsEditor);
jellyfinUser.SetPermission(PermissionKind.EnableSubtitleManagement, keycloakUser.IsEditor);
jellyfinUser.SetPermission(PermissionKind.EnableAllFolders, Cfg().EnableAllFolders || keycloakUser.IsAdmin);
var enabledFolders = new HashSet<string>(Cfg().EnabledFolders);
enabledFolders.UnionWith(keycloakUser.EnabledFolders);
jellyfinUser.SetPreference(PreferenceKind.EnabledFolders, enabledFolders.ToArray());
await userManager.UpdateUserAsync(jellyfinUser).ConfigureAwait(false);
}
}
private object? GetJwtAttribute(IDictionary<string, object> jwt, string tokenAttribute)
{
if (!string.IsNullOrWhiteSpace(tokenAttribute))
{
var parts = tokenAttribute.Split('.');
try
{
IDictionary<string, object> subObj = jwt;
for (int i = 0; i < parts.Length - 1; i++)
{
subObj = (IDictionary<string, object>)subObj[parts[i]];
}
return subObj[parts[^1]];
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not extract {TokenAttribute} from JWT", tokenAttribute);
}
}
return null;
}
/// <inheritdoc />
public async Task<ProviderAuthenticationResult> Authenticate(string username, string password)
{
_logger.LogInformation("Keycloak login: {Username}", username);
var userManager = _applicationHost.Resolve<IUserManager>();
string password_t = password;
string? totp = null;
if (Cfg().Enable2Fa)
{
var match = Regex.Match(password, _twoFactorPattern);
if (match.Success)
{
password_t = match.Groups[1].Value;
totp = match.Groups[2].Value;
}
}
(KeycloakUser? keycloakUser, bool retryNoOtp) = await GetKeycloakUser(username, password_t, totp).ConfigureAwait(false);
if (keycloakUser == null && totp != null && retryNoOtp)
{
(keycloakUser, _) = await GetKeycloakUser(username, password, null).ConfigureAwait(false);
}
if (keycloakUser == null)
{
throw new AuthenticationException("Error completing Keycloak login. Invalid username or password.");
}
keycloakUser.IsEnabled |= Cfg().AllowUsersWithoutRole;
User? user = null;
try
{
user = userManager.GetUserByName(keycloakUser.Username);
}
catch (Exception e)
{
_logger.LogWarning("User Manager could not find a user for Keycloak User, this may not be fatal: {E}", e);
}
if (user == null)
{
if (!keycloakUser.IsEnabled)
{
_logger.LogError("Keycloak User not allowed to use Jellyfin: {Username}", username);
throw new AuthenticationException("User is not allowed to use Jellyfin");
}
else if (Cfg().CreateUser)
{
_logger.LogInformation("Creating user: {Username}", username);
user = await userManager.CreateUserAsync(username).ConfigureAwait(false);
var userAuthenticationProviderId = GetType().FullName;
if (userAuthenticationProviderId != null)
{
user.AuthenticationProviderId = userAuthenticationProviderId;
}
await UpdateUserInfo(keycloakUser, user).ConfigureAwait(false);
}
else
{
_logger.LogError("Keycloak User not configured for Jellyfin: {Username}", username);
throw new AuthenticationException(
$"Automatic User Creation is disabled and there is no Jellyfin user for authorized Uid: {username}");
}
}
else
{
await UpdateUserInfo(keycloakUser, user).ConfigureAwait(false);
}
if (user != null && user.HasPermission(PermissionKind.IsDisabled))
{
// If the user no longer has permission to access revoke all sessions for this user
_logger.LogInformation("{Username} is disabled, revoking all sessions", username);
var sessionHandler = _applicationHost.Resolve<ISessionManager>();
await sessionHandler.RevokeUserTokens(user.Id, null).ConfigureAwait(false);
}
return new ProviderAuthenticationResult { Username = username };
}
/// <inheritdoc />
public bool HasPassword(User user)
{
return true;
}
/// <inheritdoc />
public Task ChangePassword(User user, string newPassword)
{
throw new NotImplementedException();
}
}
}