using System; using System.Collections.Concurrent; using System.Linq; using System.Text.Json; using System.Threading.Tasks; using IdentityModel.OidcClient; using Jellyfin.Data; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Plugin.OidcAuth.Configuration; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Library; 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(); private readonly IUserManager _userManager; private readonly ISessionManager _sessionManager; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. public OidcAuthController(IUserManager userManager, ISessionManager sessionManager, ILogger logger) { _userManager = userManager; _sessionManager = sessionManager; _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(); var allowedRoles = SplitCsv(Config.AllowedRoles); if (allowedRoles.Length > 0 && !roles.Intersect(allowedRoles, StringComparer.Ordinal).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) { user.SetPermission(PermissionKind.IsAdministrator, roles.Intersect(adminRoles, StringComparer.Ordinal).Any()); } 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); } 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; } }