using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Security.Claims; using System.Text.Json; using System.Threading.Tasks; using IdentityModel.OidcClient; using Jellyfin.Data; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Plugin.OidcAuth.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Session; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.OidcAuth.Api; /// /// OIDC login endpoints. /// [ApiController] [Route("OidcAuth")] public class OidcAuthController : ControllerBase { private static readonly TimeSpan StateLifetime = TimeSpan.FromMinutes(15); private static readonly ConcurrentDictionary States = new(); /// /// Profile pictures larger than this are ignored rather than written to disk. /// private const long MaxProfilePictureBytes = 5 * 1024 * 1024; private static readonly Dictionary ProfilePictureExtensions = new(StringComparer.OrdinalIgnoreCase) { ["image/jpeg"] = ".jpg", ["image/png"] = ".png", ["image/gif"] = ".gif", ["image/webp"] = ".webp" }; private readonly IUserManager _userManager; private readonly ISessionManager _sessionManager; private readonly IProviderManager _providerManager; private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. public OidcAuthController( IUserManager userManager, ISessionManager sessionManager, IProviderManager providerManager, IServerConfigurationManager serverConfigurationManager, IHttpClientFactory httpClientFactory, ILogger logger) { _userManager = userManager; _sessionManager = sessionManager; _providerManager = providerManager; _serverConfigurationManager = serverConfigurationManager; _httpClientFactory = httpClientFactory; _logger = logger; } private static PluginConfiguration Config => Plugin.Instance!.Configuration; /// /// Starts the OIDC login flow by redirecting to the provider. /// /// A redirect to the provider's authorization endpoint. [HttpGet("login")] [AllowAnonymous] public async Task Login() { if (string.IsNullOrWhiteSpace(Config.OidIssuer) || string.IsNullOrWhiteSpace(Config.OidClientId)) { return StatusCode(500, "OIDC Auth plugin is not configured."); } CleanupStates(); var client = CreateClient(); var state = await client.PrepareLoginAsync().ConfigureAwait(false); States[state.State] = new FlowState(state); return Redirect(state.StartUrl); } /// /// OIDC redirect URI. Exchanges the authorization code and serves a page that /// finishes the login inside the Jellyfin web client. /// /// The OIDC state parameter. /// An HTML page completing the login. [HttpGet("callback")] [AllowAnonymous] public async Task Callback([FromQuery] string state) { if (string.IsNullOrEmpty(state) || !States.TryGetValue(state, out var flow)) { return BadRequest("Unknown or expired login state. Start again at /OidcAuth/login."); } var client = CreateClient(); var result = await client.ProcessResponseAsync(Request.QueryString.Value, flow.AuthorizeState).ConfigureAwait(false); if (result.IsError) { States.TryRemove(state, out _); _logger.LogWarning("OIDC login failed: {Error}", result.Error); return BadRequest($"OIDC login failed: {result.Error}"); } var username = result.User.FindFirst(Config.UsernameClaim)?.Value ?? result.User.FindFirst("sub")?.Value; if (string.IsNullOrWhiteSpace(username)) { States.TryRemove(state, out _); return BadRequest($"OIDC login failed: no '{Config.UsernameClaim}' or 'sub' claim in token."); } var roles = result.User.FindAll(Config.RoleClaim).Select(c => c.Value).ToArray(); _logger.LogInformation( "OIDC login for {Username}. Claims: {Claims}. Roles from claim '{RoleClaim}': [{Roles}]", username, string.Join("; ", result.User.Claims.Select(c => $"{c.Type}={c.Value}")), Config.RoleClaim, string.Join(", ", roles)); var allowedRoles = SplitCsv(Config.AllowedRoles); if (allowedRoles.Length > 0 && !roles.Intersect(allowedRoles, StringComparer.OrdinalIgnoreCase).Any()) { States.TryRemove(state, out _); _logger.LogWarning("OIDC user {Username} denied: no allowed role. Roles: {Roles}", username, string.Join(",", roles)); return StatusCode(403, "You are not allowed to access this Jellyfin server."); } var user = _userManager.GetUserByName(username); if (user is null) { if (!Config.CreateUsersIfMissing) { States.TryRemove(state, out _); return StatusCode(403, $"No Jellyfin user '{username}' exists and automatic creation is disabled."); } _logger.LogInformation("Creating Jellyfin user {Username} from OIDC login", username); user = await _userManager.CreateUserAsync(username).ConfigureAwait(false); user.SetPermission(PermissionKind.EnableAllFolders, true); if (Config.SetRandomPasswordOnCreate) { // Without a password Jellyfin accepts a blank password for this user // from any client. Users can set their own later in the web profile. await _userManager.ChangePassword( user.Id, Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))) .ConfigureAwait(false); } } var adminRoles = SplitCsv(Config.AdminRoles); if (adminRoles.Length > 0) { var hasAdminRole = roles.Intersect(adminRoles, StringComparer.OrdinalIgnoreCase).Any(); _logger.LogInformation("OIDC admin mapping for {Username}: admin roles [{AdminRoles}] => hasAdminRole={HasAdminRole}", username, Config.AdminRoles, hasAdminRole); if (hasAdminRole) { user.SetPermission(PermissionKind.IsAdministrator, true); } else if (Config.RevokeAdminWithoutRole) { user.SetPermission(PermissionKind.IsAdministrator, false); } } if (Config.SyncProfilePicture) { // A broken or unreachable picture must never keep the user out. try { await SyncProfilePictureAsync(user, result.User).ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "Could not sync the profile picture for {Username}", username); } } await _userManager.UpdateUserAsync(user).ConfigureAwait(false); flow.Username = username; flow.Validated = true; var payload = JsonSerializer.Serialize(new { state, webRoot = $"{Request.PathBase}/web/" }); return Content(CallbackPage.Replace("__PAYLOAD__", payload, StringComparison.Ordinal), "text/html"); } /// /// Exchanges a validated OIDC flow state for a Jellyfin session. Called by the callback page. /// /// Device information and the flow state. /// The Jellyfin . [HttpPost("auth")] [AllowAnonymous] [Produces("application/json")] public async Task> Auth([FromBody] AuthPayload payload) { if (string.IsNullOrEmpty(payload.State) || !States.TryRemove(payload.State, out var flow) || !flow.Validated || flow.Username is null || flow.Created + StateLifetime < DateTime.UtcNow) { return BadRequest("Unknown or expired login state."); } var user = _userManager.GetUserByName(flow.Username); if (user is null) { return BadRequest("User no longer exists."); } var authResult = await _sessionManager.AuthenticateDirect(new AuthenticationRequest { App = string.IsNullOrWhiteSpace(payload.AppName) ? "Jellyfin Web" : payload.AppName, AppVersion = string.IsNullOrWhiteSpace(payload.AppVersion) ? "1.0.0" : payload.AppVersion, DeviceId = string.IsNullOrWhiteSpace(payload.DeviceId) ? Guid.NewGuid().ToString("N") : payload.DeviceId, DeviceName = string.IsNullOrWhiteSpace(payload.DeviceName) ? "OIDC Login" : payload.DeviceName, UserId = user.Id, Username = user.Username, RemoteEndPoint = HttpContext.Connection.RemoteIpAddress?.ToString() }).ConfigureAwait(false); return Ok(authResult); } /// /// Copies the provider's profile picture onto the Jellyfin user. The claim holds a /// URL (Pocket ID points at /api/users/{id}/profile-picture.png), so it is fetched /// anonymously — the access token is deliberately not sent to an address that /// ultimately comes from a token payload. /// private async Task SyncProfilePictureAsync(User user, ClaimsPrincipal principal) { var claim = principal.FindFirst(Config.PictureClaim)?.Value; if (string.IsNullOrWhiteSpace(claim)) { return; } if (!Uri.TryCreate(claim, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) { _logger.LogWarning("Ignoring profile picture for {Username}: '{Claim}' is not an http(s) URL", user.Username, claim); return; } var client = _httpClientFactory.CreateClient(NamedClient.Default); using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { _logger.LogWarning( "Ignoring profile picture for {Username}: {Uri} returned {Status}", user.Username, uri, (int)response.StatusCode); return; } var mimeType = response.Content.Headers.ContentType?.MediaType; if (mimeType is null || !ProfilePictureExtensions.TryGetValue(mimeType, out var extension)) { _logger.LogWarning( "Ignoring profile picture for {Username}: unsupported content type '{MimeType}'", user.Username, mimeType); return; } if (response.Content.Headers.ContentLength > MaxProfilePictureBytes) { _logger.LogWarning( "Ignoring profile picture for {Username}: {Length} bytes exceeds the {Limit} byte limit", user.Username, response.Content.Headers.ContentLength, MaxProfilePictureBytes); return; } var image = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); if (image.Length == 0 || image.Length > MaxProfilePictureBytes) { _logger.LogWarning("Ignoring profile picture for {Username}: {Length} bytes", user.Username, image.Length); return; } var path = Path.Combine( _serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username, "profile" + extension); // Rewriting an identical image on every login would churn the image cache. if (user.ProfileImage is not null && string.Equals(user.ProfileImage.Path, path, StringComparison.Ordinal) && System.IO.File.Exists(path)) { var current = await System.IO.File.ReadAllBytesAsync(path).ConfigureAwait(false); if (image.AsSpan().SequenceEqual(current)) { return; } } if (user.ProfileImage is not null) { await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); } user.ProfileImage = new ImageInfo(path); using var stream = new MemoryStream(image, false); await _providerManager.SaveImage(stream, mimeType, path).ConfigureAwait(false); _logger.LogInformation("Updated the profile picture for {Username} from {Uri}", user.Username, uri); } private OidcClient CreateClient() { var options = new OidcClientOptions { Authority = Config.OidIssuer, ClientId = Config.OidClientId, ClientSecret = string.IsNullOrEmpty(Config.OidClientSecret) ? null : Config.OidClientSecret, RedirectUri = $"{Request.Scheme}://{Request.Host}{Request.PathBase}/OidcAuth/callback", Scope = Config.OidScopes }; if (Config.DisableEndpointValidation) { options.Policy.Discovery.ValidateEndpoints = false; } return new OidcClient(options); } private static string[] SplitCsv(string value) { return value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } private static void CleanupStates() { var cutoff = DateTime.UtcNow - StateLifetime; foreach (var entry in States) { if (entry.Value.Created < cutoff) { States.TryRemove(entry.Key, out _); } } } private sealed class FlowState { public FlowState(AuthorizeState authorizeState) { AuthorizeState = authorizeState; } public AuthorizeState AuthorizeState { get; } public DateTime Created { get; } = DateTime.UtcNow; public bool Validated { get; set; } public string? Username { get; set; } } private const string CallbackPage = """ Signing in…

Signing in…

"""; } /// /// Body of the auth completion request sent by the callback page. /// public class AuthPayload { /// /// Gets or sets the OIDC flow state. /// public string State { get; set; } = string.Empty; /// /// Gets or sets the Jellyfin device id. /// public string DeviceId { get; set; } = string.Empty; /// /// Gets or sets the device name. /// public string? DeviceName { get; set; } /// /// Gets or sets the app name. /// public string? AppName { get; set; } /// /// Gets or sets the app version. /// public string? AppVersion { get; set; } }