From 73b6c17518d5da7964da7ec6826ce7e099533a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borja=20Dom=C3=ADnguez=20V=C3=A1zquez?= Date: Mon, 24 Mar 2025 20:06:28 +0100 Subject: [PATCH 1/2] Allow port override --- SSO-Auth/Api/SSOController.cs | 2416 ++++++++++++------------ SSO-Auth/Config/PluginConfiguration.cs | 10 + SSO-Auth/Config/configPage.html | 12 + 3 files changed, 1235 insertions(+), 1203 deletions(-) diff --git a/SSO-Auth/Api/SSOController.cs b/SSO-Auth/Api/SSOController.cs index 9f7ac66..0e0d7d9 100644 --- a/SSO-Auth/Api/SSOController.cs +++ b/SSO-Auth/Api/SSOController.cs @@ -1,1203 +1,1213 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Mime; -using System.Security.Cryptography; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using IdentityModel.OidcClient; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; -using Jellyfin.Plugin.SSO_Auth.Config; -using Jellyfin.Plugin.SSO_Auth.Helpers; -using MediaBrowser.Common.Api; -using MediaBrowser.Controller.Authentication; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Cryptography; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using SSO_Auth.Lib; - -namespace Jellyfin.Plugin.SSO_Auth.Api; - -/// -/// The sso api controller. -/// -[ApiController] -[Route("[controller]")] -public class SSOController : ControllerBase -{ - private readonly IUserManager _userManager; - private readonly ISessionManager _sessionManager; - private readonly IAuthorizationContext _authContext; - private readonly ILogger _logger; - private readonly ICryptoProvider _cryptoProvider; - private static readonly IDictionary StateManager = new Dictionary(); - - /// - /// 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. - public SSOController(ILogger logger, ISessionManager sessionManager, IUserManager userManager, IAuthorizationContext authContext, ICryptoProvider cryptoProvider) - { - _sessionManager = sessionManager; - _userManager = userManager; - _authContext = authContext; - _cryptoProvider = cryptoProvider; - _logger = logger; - _logger.LogInformation("SSO Controller initialized"); - } - - /// - /// The GET endpoint for OpenID provider to callback to. Returns a webpage that parses client data and completes auth. - /// - /// The ID of the provider which will use the callback information. - /// The current request state. - /// A webpage that will complete the client-side flow. - // Actually a GET: https://github.com/IdentityModel/IdentityModel.OidcClient/issues/325 - [HttpGet("OID/r/{provider}")] - [HttpGet("OID/redirect/{provider}")] - public async Task OidPost( - [FromRoute] string provider, - [FromQuery] string state) // Although this is a GET function, this function is called `Post` for consistency with SAML - { - OidConfig config; - try - { - config = SSOPlugin.Instance.Configuration.OidConfigs[provider]; - } - catch (KeyNotFoundException) - { - return BadRequest("No matching provider found"); - } - - if (config.Enabled) - { - var scopes = config.OidScopes == null ? new string[2] : config.OidScopes; - var options = new OidcClientOptions - { - Authority = config.OidEndpoint?.Trim(), - ClientId = config.OidClientId?.Trim(), - ClientSecret = config.OidSecret?.Trim(), - RedirectUri = GetRequestBase(config.SchemeOverride) + $"/sso/OID/{(Request.Path.Value.Contains("/start/", StringComparison.InvariantCultureIgnoreCase) ? "redirect" : "r")}/" + provider, - Scope = string.Join(" ", scopes.Prepend("openid profile")), - }; - var oidEndpointUri = new Uri(config.OidEndpoint?.Trim()); - options.Policy.Discovery.AdditionalEndpointBaseAddresses.Add(oidEndpointUri.GetLeftPart(UriPartial.Authority)); - options.Policy.Discovery.ValidateEndpoints = !config.DoNotValidateEndpoints; // For Google and other providers with different endpoints - options.Policy.Discovery.RequireHttps = !config.DisableHttps; - options.Policy.Discovery.ValidateIssuerName = !config.DoNotValidateIssuerName; - var oidcClient = new OidcClient(options); - var currentState = StateManager[state].State; - var result = await oidcClient.ProcessResponseAsync(Request.QueryString.Value, currentState).ConfigureAwait(false); - if (result.IsError) - { - return ReturnError(StatusCodes.Status400BadRequest, result.Error + " Try logging in again."); - } - - if (!config.EnableFolderRoles && config.EnabledFolders != null) - { - StateManager[state].Folders = new List(config.EnabledFolders); - } - else - { - StateManager[state].Folders = new List(); - } - - StateManager[state].EnableLiveTv = config.EnableLiveTv; - StateManager[state].EnableLiveTvManagement = config.EnableLiveTvManagement; - - foreach (var claim in result.User.Claims) - { - if (claim.Type == (config.DefaultUsernameClaim?.Trim() ?? "preferred_username")) - { - StateManager[state].Username = claim.Value; - if (config.Roles == null || config.Roles.Length == 0) - { - StateManager[state].Valid = true; - } - } - - // Role processing - // The regex matches any "." not preceded by a "\": a.b.c will be split into a, b, and c, but a.b\.c will be split into a, b.c (after processing the escaped dots) - // We have to first process the RoleClaim string - string[] segments = string.IsNullOrEmpty(config.RoleClaim) ? Array.Empty() : Regex.Split(config.RoleClaim.Trim(), "(? i.Replace("\\.", ".")).ToArray(); - - if (claim.Type == segments[0]) - { - List roles; - // If we are not using JSON values, just use the raw info from the claim value - if (segments.Length == 1) - { - roles = new List { claim.Value }; - } - else - { - // We recursively traverse through the JSON data for the roles and parse it - var json = JsonConvert.DeserializeObject>(claim.Value); - for (int i = 1; i < segments.Length - 1; i++) - { - var segment = segments[i]; - json = (json[segment] as JObject).ToObject>(); - } - - // The final step is to take the JSON and turn it from a dictionary into a string - roles = (json[segments[^1]] as JArray).ToObject>(); - } - - foreach (string role in roles) - { - // Check if allowed to login based on roles - if (config.Roles != null && config.Roles.Any()) - { - foreach (string validRoles in config.Roles) - { - if (role.Equals(validRoles)) - { - StateManager[state].Valid = true; - } - } - } - - // Check if admin based on roles - if (config.AdminRoles != null && config.AdminRoles.Any()) - { - foreach (string validAdminRoles in config.AdminRoles) - { - if (role.Equals(validAdminRoles)) - { - StateManager[state].Admin = true; - } - } - } - - // Get allowed folders from roles - if (config.EnableFolderRoles) - { - foreach (FolderRoleMap folderRoleMap in config.FolderRoleMapping) - { - if (role.Equals(folderRoleMap.Role?.Trim())) - { - StateManager[state].Folders.AddRange(folderRoleMap.Folders); - } - } - } - - if (config.EnableLiveTvRoles) - { - // Check if allowed Live TV based on roles - if (config.LiveTvRoles != null && config.LiveTvRoles.Any()) - { - foreach (string validLiveTvRoles in config.LiveTvRoles) - { - if (role.Equals(validLiveTvRoles)) - { - StateManager[state].EnableLiveTv = true; - } - } - } - - // Check if allowed Live TV management based on roles - if (config.LiveTvManagementRoles != null && config.LiveTvManagementRoles.Any()) - { - foreach (string validLiveTvManagementRoles in config.LiveTvManagementRoles) - { - if (role.Equals(validLiveTvManagementRoles)) - { - StateManager[state].EnableLiveTvManagement = true; - } - } - } - } - } - } - } - } - - // If the provider doesn't support the preferred username claim, then use the sub claim - if (!StateManager[state].Valid) - { - foreach (var claim in result.User.Claims) - { - if (claim.Type == "sub") - { - StateManager[state].Username = claim.Value; - if (config.Roles.Length == 0) - { - StateManager[state].Valid = true; - } - } - } - } - - bool isLinking = StateManager[state].IsLinking; - - if (StateManager[state].Valid) - { - _logger.LogInformation($"Is request linking: {isLinking}"); - return Content(WebResponse.Generator(data: state, provider: provider, baseUrl: GetRequestBase(config.SchemeOverride), mode: "OID", isLinking: isLinking), MediaTypeNames.Text.Html); - } - else - { - _logger.LogWarning( - "OpenID user {Username} has one or more incorrect role claims: {@Claims}. Expected any one of: {@ExpectedClaims}", - StateManager[state].Username, - result.User.Claims.Select(o => new { o.Type, o.Value }), - config.Roles); - - return ReturnError(StatusCodes.Status401Unauthorized, "Error. Check permissions."); - } - } - - // If the config doesn't have an active provider matching the requeset, show an error - return BadRequest("No matching provider found"); - } - - /// - /// Initiates the login flow for OpenID. This redirects the user to the auth provider. - /// - /// The name of the provider. - /// Whether or not this request is to link accounts (Rather than authenticate). - /// An asynchronous result for the authentication. - [HttpGet("OID/p/{provider}")] - [HttpGet("OID/start/{provider}")] - public async Task OidChallenge(string provider, [FromQuery] bool isLinking = false) - { - Invalidate(); - OidConfig config; - try - { - config = SSOPlugin.Instance.Configuration.OidConfigs[provider]; - } - catch (KeyNotFoundException) - { - throw new ArgumentException("Provider does not exist"); - } - - if (config.Enabled) - { - bool newPath = config.NewPath; - if (!isLinking) - { - newPath = Request.Path.Value.Contains("/start/", StringComparison.InvariantCultureIgnoreCase); - config.NewPath = newPath; - } - - string redirectUri = GetRequestBase(config.SchemeOverride) + $"/sso/OID/{(newPath ? "redirect" : "r")}/" + provider; - - var options = new OidcClientOptions - { - Authority = config.OidEndpoint?.Trim(), - ClientId = config.OidClientId?.Trim(), - ClientSecret = config.OidSecret?.Trim(), - RedirectUri = redirectUri, - Scope = string.Join(" ", config.OidScopes.Prepend("openid profile")), - }; - var oidEndpointUri = new Uri(config.OidEndpoint?.Trim()); - options.Policy.Discovery.AdditionalEndpointBaseAddresses.Add(oidEndpointUri.GetLeftPart(UriPartial.Authority)); - options.Policy.Discovery.ValidateEndpoints = !config.DoNotValidateEndpoints; // For Google and other providers with different endpoints - options.Policy.Discovery.RequireHttps = !config.DisableHttps; - options.Policy.Discovery.ValidateIssuerName = !config.DoNotValidateIssuerName; - var oidcClient = new OidcClient(options); - var state = await oidcClient.PrepareLoginAsync().ConfigureAwait(false); - StateManager.Add(state.State, new TimedAuthorizeState(state, DateTime.Now)); - - // Track whether this is a linking request or not. - StateManager[state.State].IsLinking = isLinking; - return Redirect(state.StartUrl); - } - - throw new ArgumentException("Provider does not exist"); - } - - /// - /// Adds an OpenID auth configuration. Requires administrator privileges. If the provider already exists, it will be removed and readded. - /// - /// The name of the provider to add. - /// The OID configuration (deserialized from a JSON post). - [Authorize(Policy = Policies.RequiresElevation)] - [HttpPost("OID/Add/{provider}")] - public void OidAdd(string provider, [FromBody] OidConfig config) - { - var configuration = SSOPlugin.Instance.Configuration; - configuration.OidConfigs[provider] = config; - SSOPlugin.Instance.UpdateConfiguration(configuration); - } - - /// - /// Deletes an OpenID provider. - /// - /// Name of provider to delete. - [Authorize(Policy = Policies.RequiresElevation)] - [HttpGet("OID/Del/{provider}")] - public void OidDel(string provider) - { - var configuration = SSOPlugin.Instance.Configuration; - configuration.OidConfigs.Remove(provider); - SSOPlugin.Instance.UpdateConfiguration(configuration); - } - - /// - /// Lists the OpenID providers configured. Requires administrator privileges. - /// - /// The list of OpenID configurations. - [Authorize(Policy = Policies.RequiresElevation)] - [HttpGet("OID/Get")] - public ActionResult OidProviders() - { - return Ok(SSOPlugin.Instance.Configuration.OidConfigs); - } - - /// - /// Lists the OpenID providers names only. - /// - /// The list of OpenID configurations. - [HttpGet("OID/GetNames")] - public ActionResult OidProviderNames() - { - return Ok(SSOPlugin.Instance.Configuration.OidConfigs.Keys); - } - - /// - /// Lists the SAML providers names only. - /// - /// The list of OpenID configurations. - [HttpGet("SAML/GetNames")] - public ActionResult SamlProviderNames() - { - return Ok(SSOPlugin.Instance.Configuration.SamlConfigs.Keys); - } - - /// - /// This is a debug endpoint to list all running OpenID flows. Requires administrator privileges. - /// - /// The list of OpenID flows in progress. - [Authorize(Policy = Policies.RequiresElevation)] - [HttpGet("OID/States")] - public ActionResult OidStates() - { - return Ok(StateManager); - } - - /// - /// This endpoint accepts JSON and will authorize the user from the device values passed from the client. - /// - /// Name of provider to authenticate against. - /// The data passed to the client to ensure it is the right one. - /// JSON for the client to populate information with. - [HttpPost("OID/Auth/{provider}")] - [Consumes(MediaTypeNames.Application.Json)] - [Produces(MediaTypeNames.Application.Json)] - public async Task OidAuth(string provider, [FromBody] AuthResponse response) - { - OidConfig config; - try - { - config = SSOPlugin.Instance.Configuration.OidConfigs[provider]; - } - catch (KeyNotFoundException) - { - return BadRequest("No matching provider found"); - } - - if (config.Enabled) - { - foreach (var kvp in StateManager) - { - if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid) - { - Guid userId = await CreateCanonicalLinkAndUserIfNotExist("oid", provider, kvp.Value.Username); - - var authenticationResult = await Authenticate(userId, kvp.Value.Admin, config.EnableAuthorization, config.EnableAllFolders, kvp.Value.Folders.ToArray(), kvp.Value.EnableLiveTv, kvp.Value.EnableLiveTvManagement, response, config.DefaultProvider?.Trim()) - .ConfigureAwait(false); - return Ok(authenticationResult); - } - } - } - - return Problem("Something went wrong"); - } - - /// - /// This is the callback for the SAML flow. This creates a webpage to complete auth. - /// - /// The provider that is calling back. - /// - /// RelayState given in the original saml request. If it is equal to "linking", - /// We consider this to be a linking request. - /// - /// A webpage that will complete the client-side flow. - [HttpPost("SAML/p/{provider}")] - [HttpPost("SAML/post/{provider}")] - public ActionResult SamlPost(string provider, [FromQuery] string relayState = null) - { - SamlConfig config; - try - { - config = SSOPlugin.Instance.Configuration.SamlConfigs[provider]; - } - catch (KeyNotFoundException) - { - return BadRequest("No matching provider found"); - } - - bool isLinking = relayState == "linking"; - - _logger.LogInformation( - $"SAML request has relayState of {relayState}"); - - if (config.Enabled) - { - var samlResponse = new Response(config.SamlCertificate, Request.Form["SAMLResponse"]); - - bool valid = false; - - // If no roles are configured, don't use RBAC - if (config.Roles.Length == 0) - { - valid = true; - } - - // Check if user is allowed to log in based on roles - foreach (string role in samlResponse.GetCustomAttributes("Role")) - { - foreach (string allowedRole in config.Roles) - { - if (allowedRole.Equals(role)) - { - valid = true; - } - } - } - - if (valid) - { - return Content( - WebResponse.Generator( - data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), - provider: provider, - baseUrl: GetRequestBase(config.SchemeOverride), - mode: "SAML", - isLinking: isLinking), - MediaTypeNames.Text.Html); - } - - _logger.LogWarning( - "SAML user: {UserId} has insufficient roles: {@Roles}. Expected any one of: {@ExpectedRoles}", - samlResponse.GetNameID(), - samlResponse.GetCustomAttributes("Role"), - config.Roles); - return ReturnError(StatusCodes.Status401Unauthorized, "Error. Check permissions."); - } - - return ReturnError(StatusCodes.Status400BadRequest, "No active providers found"); - } - - /// - /// Initializes the SAML flow. This will redirect the user to the SAML provider. - /// - /// The provider to being the flow with. - /// Whether this flow intends to link an account, or initiate auth. - /// A redirect to the SAML provider's auth page. - [HttpGet("SAML/p/{provider}")] - [HttpGet("SAML/start/{provider}")] - public RedirectResult SamlChallenge(string provider, [FromQuery] bool isLinking = false) - { - SamlConfig config; - try - { - config = SSOPlugin.Instance.Configuration.SamlConfigs[provider]; - } - catch (KeyNotFoundException) - { - throw new ArgumentException("Provider does not exist"); - } - - if (config.Enabled) - { - bool newPath = config.NewPath; - if (!isLinking) - { - newPath = Request.Path.Value.Contains("/start/", StringComparison.InvariantCultureIgnoreCase); - config.NewPath = newPath; - } - - string redirectUri = GetRequestBase(config.SchemeOverride) + $"/sso/SAML/{(newPath ? "post" : "p")}/" + provider; - string relayState = null; - if (isLinking) - { - relayState = "linking"; - } - - var request = new AuthRequest( - config.SamlClientId.Trim(), - redirectUri); - - return Redirect(request.GetRedirectUrl(config.SamlEndpoint.Trim(), relayState)); - } - - throw new ArgumentException("Provider does not exist"); - } - - /// - /// Adds a SAML configuration. If the provider already exists, overwrite it. - /// - /// The provider name to add. - /// The SAML configuration object (deserialized) from JSON. - /// The success result. - [Authorize(Policy = Policies.RequiresElevation)] - [HttpPost("SAML/Add/{provider}")] - public OkResult SamlAdd(string provider, [FromBody] SamlConfig newConfig) - { - var configuration = SSOPlugin.Instance.Configuration; - configuration.SamlConfigs[provider] = newConfig; - SSOPlugin.Instance.UpdateConfiguration(configuration); - return Ok(); - } - - /// - /// Deletes a provider from the configuration with a given ID. - /// - /// The ID of the provider to delete. - /// The success result. - [Authorize(Policy = Policies.RequiresElevation)] - [HttpGet("SAML/Del/{provider}")] - public OkResult SamlDel(string provider) - { - var configuration = SSOPlugin.Instance.Configuration; - configuration.SamlConfigs.Remove(provider); - SSOPlugin.Instance.UpdateConfiguration(configuration); - return Ok(); - } - - /// - /// Returns a list of all SAML providers configured. Requires administrator privileges. - /// - /// A list of all of the Saml providers available. - [Authorize(Policy = Policies.RequiresElevation)] - [HttpGet("SAML/Get")] - public ActionResult SamlProviders() - { - return Ok(SSOPlugin.Instance.Configuration.SamlConfigs); - } - - /// - /// This endpoint accepts JSON and will authorize the user from the device values passed from the client. - /// - /// The provider to authenticate against. - /// The data passed to the client to ensure it is the right one. - /// JSON for the client to populate information with. - [HttpPost("SAML/Auth/{provider}")] - [Consumes(MediaTypeNames.Application.Json)] - [Produces(MediaTypeNames.Application.Json)] - public async Task SamlAuth(string provider, [FromBody] AuthResponse response) - { - SamlConfig config; - try - { - config = SSOPlugin.Instance.Configuration.SamlConfigs[provider]; - } - catch (KeyNotFoundException) - { - return BadRequest("No matching provider found"); - } - - if (config.Enabled) - { - bool isAdmin = false; - bool liveTv = config.EnableLiveTv; - bool liveTvManagement = config.EnableLiveTvManagement; - var samlResponse = new Response(config.SamlCertificate, response.Data); - List folders; - if (!config.EnableFolderRoles && config.EnabledFolders != null) - { - folders = new List(config.EnabledFolders); - } - else - { - folders = new List(); - } - - foreach (string role in samlResponse.GetCustomAttributes("Role")) - { - if (config.AdminRoles != null) - { - foreach (string allowedRole in config.AdminRoles) - { - if (allowedRole.Equals(role)) - { - isAdmin = true; - } - } - } - - if (config.EnableFolderRoles) - { - if (config.FolderRoleMapping != null) - { - foreach (FolderRoleMap folderRoleMap in config.FolderRoleMapping) - { - if (folderRoleMap.Role.Equals(role)) - { - folders.AddRange(folderRoleMap.Folders); - } - } - } - } - - if (config.EnableLiveTvRoles) - { - if (config.LiveTvRoles != null) - { - foreach (string allowedLiveTvRole in config.LiveTvRoles) - { - if (allowedLiveTvRole.Equals(role)) - { - liveTv = true; - } - } - } - - if (config.LiveTvManagementRoles != null) - { - foreach (string allowedLiveTvManagementRole in config.LiveTvManagementRoles) - { - if (allowedLiveTvManagementRole.Equals(role)) - { - liveTvManagement = true; - } - } - } - } - } - - Guid userId = await CreateCanonicalLinkAndUserIfNotExist("saml", provider, samlResponse.GetNameID()); - - var authenticationResult = await Authenticate(userId, isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), liveTv, liveTvManagement, response, config.DefaultProvider?.Trim()) - .ConfigureAwait(false); - return Ok(authenticationResult); - } - - return Problem("Something went wrong"); - } - - /// - /// Removes a user from SSO auth and switches it back to another auth provider. Requires administrator privileges. - /// - /// The username to switch to the new provider. - /// The new provider to switch to. - /// Whether this API endpoint succeeded. - [Authorize(Policy = Policies.RequiresElevation)] - [HttpPost("Unregister/{username}")] - public ActionResult Unregister(string username, [FromBody] string provider) - { - User user = _userManager.GetUserByName(username); - user.AuthenticationProviderId = provider; - - return Ok(); - } - - private SerializableDictionary GetCanonicalLinks(string mode, string provider) - { - SerializableDictionary links = null; - - switch (mode.ToLower()) - { - case "saml": - links = SSOPlugin.Instance.Configuration.SamlConfigs[provider].CanonicalLinks; - break; - case "oid": - links = SSOPlugin.Instance.Configuration.OidConfigs[provider].CanonicalLinks; - break; - default: - throw new ArgumentException($"{mode} is not a valid choice between 'saml' and 'oid'"); - } - - if (links == null) - { - links = new SerializableDictionary(); - } - - return links; - } - - private async Task CreateCanonicalLinkAndUserIfNotExist(string mode, string provider, string canonicalName) - { - User user = null; - user = _userManager.GetUserByName(canonicalName); - if (user == null) - { - _logger.LogInformation($"SSO user {canonicalName} doesn't exist, creating..."); - user = await _userManager.CreateUserAsync(canonicalName).ConfigureAwait(false); - user.AuthenticationProviderId = GetType().FullName; - // https://jonathancrozier.com/blog/how-to-generate-a-cryptographically-secure-random-string-in-dot-net-with-c-sharp - user.Password = _cryptoProvider.CreatePasswordHash(Convert.ToBase64String(RandomNumberGenerator.GetBytes(64))).ToString(); - - // Make sure there aren't any trailing existing links - var links = GetCanonicalLinks(mode, provider); - links.Remove(canonicalName); - UpdateCanonicalLinkConfig(links, mode, provider); - } - - Guid userId = Guid.Empty; - try - { - userId = GetCanonicalLink(mode, provider, canonicalName); - } - catch (KeyNotFoundException) - { - userId = Guid.Empty; - } - - if (userId == Guid.Empty) - { - _logger.LogInformation("SSO user link doesn't exist, creating..."); - userId = user.Id; - CreateCanonicalLink(mode, provider, userId, canonicalName); - } - - return userId; - } - - private Guid GetCanonicalLink(string mode, string provider, string canonicalName) - { - SerializableDictionary links = null; - Guid userId = Guid.Empty; - - links = GetCanonicalLinks(mode, provider); - - userId = links[canonicalName]; - - return userId; - } - - /// - /// Create a canonical link for a given user. Must be performed by the user being changed, or admin. - /// - /// The mode of the function; SAML or OID. - /// The name of the provider to link to a jellyfin account. - /// The user ID within jellyfin to link to the provider. - /// The client information to authenticate the user with. - /// Whether this API endpoint succeeded. - [Authorize] - [HttpPost("{mode}/Link/{provider}/{jellyfinUserId}")] - [Consumes(MediaTypeNames.Application.Json)] - [Produces(MediaTypeNames.Application.Json)] - public async Task AddCanonicalLink([FromRoute] string mode, [FromRoute] string provider, [FromRoute] Guid jellyfinUserId, [FromBody] AuthResponse authResponse) - { - if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, jellyfinUserId, true).ConfigureAwait(false)) - { - return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to link SSO providers."); - } - - switch (mode.ToLower()) - { - case "saml": - return SamlLink(provider, jellyfinUserId, authResponse); - case "oid": - return OidLink(provider, jellyfinUserId, authResponse); - default: - throw new ArgumentException($"{mode} is not a valid choice between 'saml' and 'oid'"); - } - } - - /// - /// Unregisters a given mapping from id within provider to user. - /// - /// The mode of the function; SAML or OID. - /// The name of the provider from which the link should be removed. - /// The user ID within jellyfin to unlink from the provider. - /// The user ID within jellyfin to unlink. - /// Whether this API endpoint succeeded. - [Authorize] - [HttpDelete("{mode}/Link/{provider}/{jellyfinUserId}/{canonicalName}")] - [Consumes(MediaTypeNames.Application.Json)] - [Produces(MediaTypeNames.Application.Json)] - public async Task DeleteCanonicalLink([FromRoute] string mode, [FromRoute] string provider, [FromRoute] Guid jellyfinUserId, [FromRoute] string canonicalName) - { - if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, jellyfinUserId, true).ConfigureAwait(false)) - { - return StatusCode(StatusCodes.Status403Forbidden, "Current user is not allowed to unlink SSO providers for user ID."); - } - - Guid linkedId = GetCanonicalLink(mode, provider, canonicalName); - - if (linkedId != jellyfinUserId) - { - return StatusCode(StatusCodes.Status409Conflict, "jellyfin UID does not match id registered to that canonical name."); - } - - var links = GetCanonicalLinks(mode, provider); - - links.Remove(canonicalName); - - return UpdateCanonicalLinkConfig(links, mode, provider); - } - - /// - /// Gets all the saml links for a user. - /// - /// The user ID within jellyfin for which to return the links. - /// A dictionary of provider : link mappings. - [Authorize] - [HttpGet("saml/links/{jellyfinUserId}")] - [Produces(MediaTypeNames.Application.Json)] - public async Task>>> GetSamlLinksByUser(Guid jellyfinUserId) - { - if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, jellyfinUserId, true).ConfigureAwait(false)) - { - return StatusCode(StatusCodes.Status403Forbidden, "Non-admin is not allowed to query other user's mappings."); - } - - var mappings = new SerializableDictionary>(); - var providerList = SSOPlugin.Instance.Configuration.SamlConfigs; - - foreach (var providerName in providerList.Keys) - { - var canonLinks = providerList[providerName].CanonicalLinks; - var canonKeys = from link in canonLinks where link.Value == jellyfinUserId select link.Key; - mappings[providerName] = canonKeys; - } - - return mappings; - } - - /// - /// Gets all the oid links for a user. - /// - /// The user ID within jellyfin for which to return the links. - /// A dictionary of provider : link mappings. - [Authorize] - [HttpGet("oid/links/{jellyfinUserId}")] - [Produces(MediaTypeNames.Application.Json)] - public async Task>>> GetOidLinksByUser(Guid jellyfinUserId) - { - if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, jellyfinUserId, true).ConfigureAwait(false)) - { - return StatusCode(StatusCodes.Status403Forbidden, "Non-admin is not allowed to query other user's mappings."); - } - - var mappings = new SerializableDictionary>(); - var providerList = SSOPlugin.Instance.Configuration.OidConfigs; - - foreach (var providerName in providerList.Keys) - { - var canonLinks = providerList[providerName].CanonicalLinks; - var canonKeys = from link in canonLinks where link.Value == jellyfinUserId select link.Key; - mappings[providerName] = canonKeys; - } - - return mappings; - } - - /// - /// Validate a saml link request and create the link if it is valid. - /// - /// The provider to authenticate against. - /// - /// The ID of the account to be linked to the provider. - /// Must be performed by this user, or an admin. - /// - /// The data passed to the client to ensure it is the right one. - /// JSON for the client to populate information with. - [Consumes(MediaTypeNames.Application.Json)] - [Produces(MediaTypeNames.Application.Json)] - private ActionResult SamlLink(string provider, Guid jellyfinUserId, AuthResponse response) - { - SamlConfig config; - try - { - config = SSOPlugin.Instance.Configuration.SamlConfigs[provider]; - } - catch (KeyNotFoundException) - { - return BadRequest("No matching provider found"); - } - - var samlResponse = new Response(config.SamlCertificate, response.Data); - // TODO: Does saml response require further validation? - - string providerUserId = samlResponse.GetNameID(); - - return CreateCanonicalLink("saml", provider, jellyfinUserId, providerUserId); - } - - /// - /// Validate an OIDC link request and create the link if it is valid. - /// - /// The provider to authenticate against. - /// - /// The ID of the account to be linked to the provider. - /// Must be performed by this user, or an admin. - /// - /// The data passed to the client to ensure it is the right one. - /// JSON for the client to populate information with. - [Consumes(MediaTypeNames.Application.Json)] - [Produces(MediaTypeNames.Application.Json)] - private ActionResult OidLink(string provider, Guid jellyfinUserId, AuthResponse response) - { - OidConfig config; - try - { - config = SSOPlugin.Instance.Configuration.OidConfigs[provider]; - } - catch (KeyNotFoundException) - { - return BadRequest("No matching provider found"); - } - - foreach (var kvp in StateManager) - { - if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid) - { - string providerUserId = kvp.Value.Username; - return CreateCanonicalLink("oid", provider, jellyfinUserId, providerUserId); - } - } - - return Problem("Something went wrong!"); - } - - private ActionResult CreateCanonicalLink(string mode, string provider, [FromRoute] Guid jellyfinUserId, string providerUserId) - { - SerializableDictionary links = null; - try - { - links = GetCanonicalLinks(mode, provider); - } - catch (KeyNotFoundException) - { - return BadRequest("No matching provider found"); - } - - links[providerUserId] = jellyfinUserId; - UpdateCanonicalLinkConfig(links, mode, provider); - - return NoContent(); - } - - private OkResult UpdateCanonicalLinkConfig(SerializableDictionary links, string mode, string provider) - { - var configuration = SSOPlugin.Instance.Configuration; - switch (mode.ToLower()) - { - case "saml": - configuration.SamlConfigs[provider].CanonicalLinks = links; - break; - case "oid": - configuration.OidConfigs[provider].CanonicalLinks = links; - break; - default: - throw new ArgumentException($"{mode} is not a valid choice between 'saml' and 'oid'"); - } - - SSOPlugin.Instance.UpdateConfiguration(configuration); - return Ok(); - } - - /// - /// Authenticates the user with the given information. - /// - /// The user id of the user to authenticate. - /// Determines whether this user is an administrator. - /// Determines whether RBAC is used for this user. - /// Determines whether all folders are enabled. - /// Determines which folders should be enabled for this client. - /// Determines whether live TV access is allowed for this user. - /// Determines whether live TV can be managed by this user. - /// The client information to authenticate the user with. - /// The default provider of the user to be set after logging in. - private async Task Authenticate(Guid userId, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, bool enableLiveTv, bool enableLiveTvAdmin, AuthResponse authResponse, string defaultProvider) - { - User user = _userManager.GetUserById(userId); - if (enableAuthorization) - { - user.SetPermission(PermissionKind.IsAdministrator, isAdmin); - user.SetPermission(PermissionKind.EnableAllFolders, enableAllFolders); - if (!enableAllFolders) - { - user.SetPreference(PreferenceKind.EnabledFolders, enabledFolders); - } - } - - user.SetPermission(PermissionKind.EnableLiveTvAccess, enableLiveTv); - user.SetPermission(PermissionKind.EnableLiveTvManagement, enableLiveTvAdmin); - - await _userManager.UpdateUserAsync(user).ConfigureAwait(false); - - var authRequest = new AuthenticationRequest(); - authRequest.UserId = user.Id; - authRequest.Username = user.Username; - authRequest.App = authResponse.AppName; - authRequest.AppVersion = authResponse.AppVersion; - authRequest.DeviceId = authResponse.DeviceID; - authRequest.DeviceName = authResponse.DeviceName; - _logger.LogInformation("Auth request created..."); - if (!string.IsNullOrEmpty(defaultProvider)) - { - user.AuthenticationProviderId = defaultProvider; - await _userManager.UpdateUserAsync(user).ConfigureAwait(false); - _logger.LogInformation("Set default login provider to " + defaultProvider); - } - - return await _sessionManager.AuthenticateDirect(authRequest).ConfigureAwait(false); - } - - private void Invalidate() - { - foreach (var kvp in StateManager) - { - var now = DateTime.Now; - if (now.Subtract(kvp.Value.Created).TotalMinutes > 1) - { - StateManager.Remove(kvp.Key); - } - } - } - - private string GetRequestBase(string schemeOverride = null) - { - int requestPort = Request.Host.Port ?? -1; - if ((requestPort == 80 && string.Equals(Request.Scheme, "http", StringComparison.OrdinalIgnoreCase)) || (requestPort == 443 && string.Equals(Request.Scheme, "https", StringComparison.OrdinalIgnoreCase))) - { - requestPort = -1; - } - - if (schemeOverride != "http" && schemeOverride != "https") - { - schemeOverride = null; - } - - return new UriBuilder - { - Scheme = schemeOverride ?? Request.Scheme, - Host = Request.Host.Host, - Port = requestPort, - Path = Request.PathBase - }.ToString().TrimEnd('/'); - } - - private ContentResult ReturnError(int code, string message) - { - var errorResult = new ContentResult(); - errorResult.Content = message; - errorResult.ContentType = MediaTypeNames.Text.Plain; - errorResult.StatusCode = code; - return errorResult; - } -} - -/// -/// The data the client should pass back to the API. -/// -public class AuthResponse -{ - /// - /// Gets or sets the device ID of the client. - /// - public string DeviceID { get; set; } - - /// - /// Gets or sets the device name of the client. - /// - public string DeviceName { get; set; } - - /// - /// Gets or sets the app name of the client. - /// - public string AppName { get; set; } - - /// - /// Gets or sets the app version of the client. - /// - public string AppVersion { get; set; } - - /// - /// Gets or sets the auth data of the client (for authorizing the response). - /// - public string Data { get; set; } -} - -/// -/// A manager for OpenID to manage the state of the clients. -/// -public class TimedAuthorizeState -{ - /// - /// Initializes a new instance of the class. - /// - /// The AuthorizeState to time. - /// When this state was created. - public TimedAuthorizeState(AuthorizeState state, DateTime created) - { - State = state; - Created = created; - Valid = false; - Admin = false; - IsLinking = false; - EnableLiveTv = false; - EnableLiveTvManagement = false; - } - - /// - /// Gets or sets the Authorization State of the client. - /// - public AuthorizeState State { get; set; } - - /// - /// Gets or sets when this object was created to time it out. - /// - public DateTime Created { get; set; } - - /// - /// Gets or sets a value indicating whether the user is valid. - /// - public bool Valid { get; set; } - - /// - /// Gets or sets the user tied to the state. - /// - public string Username { get; set; } - - /// - /// Gets or sets a value indicating whether the user is an administrator. - /// - public bool Admin { get; set; } - - /// - /// Gets or sets a value indicating whether the state is - /// tied to a linking flow (instead of a login flow). - /// - public bool IsLinking { get; set; } - - /// - /// Gets or sets the folders the user is allowed access to. - /// - public List Folders { get; set; } - - /// - /// Gets or sets a value indicating whether the user is allowed to view live TV. - /// - public bool EnableLiveTv { get; set; } - - /// - /// Gets or sets a value indicating whether the user is allowed to manage live TV. - /// - public bool EnableLiveTvManagement { get; set; } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Mime; +using System.Security.Cryptography; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using IdentityModel.OidcClient; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using Jellyfin.Plugin.SSO_Auth.Config; +using Jellyfin.Plugin.SSO_Auth.Helpers; +using MediaBrowser.Common.Api; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Cryptography; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SSO_Auth.Lib; + +namespace Jellyfin.Plugin.SSO_Auth.Api; + +/// +/// The sso api controller. +/// +[ApiController] +[Route("[controller]")] +public class SSOController : ControllerBase +{ + private readonly IUserManager _userManager; + private readonly ISessionManager _sessionManager; + private readonly IAuthorizationContext _authContext; + private readonly ILogger _logger; + private readonly ICryptoProvider _cryptoProvider; + private static readonly IDictionary StateManager = new Dictionary(); + + /// + /// 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. + public SSOController(ILogger logger, ISessionManager sessionManager, IUserManager userManager, IAuthorizationContext authContext, ICryptoProvider cryptoProvider) + { + _sessionManager = sessionManager; + _userManager = userManager; + _authContext = authContext; + _cryptoProvider = cryptoProvider; + _logger = logger; + _logger.LogInformation("SSO Controller initialized"); + } + + /// + /// The GET endpoint for OpenID provider to callback to. Returns a webpage that parses client data and completes auth. + /// + /// The ID of the provider which will use the callback information. + /// The current request state. + /// A webpage that will complete the client-side flow. + // Actually a GET: https://github.com/IdentityModel/IdentityModel.OidcClient/issues/325 + [HttpGet("OID/r/{provider}")] + [HttpGet("OID/redirect/{provider}")] + public async Task OidPost( + [FromRoute] string provider, + [FromQuery] string state) // Although this is a GET function, this function is called `Post` for consistency with SAML + { + OidConfig config; + try + { + config = SSOPlugin.Instance.Configuration.OidConfigs[provider]; + } + catch (KeyNotFoundException) + { + return BadRequest("No matching provider found"); + } + + if (config.Enabled) + { + var scopes = config.OidScopes == null ? new string[2] : config.OidScopes; + var options = new OidcClientOptions + { + Authority = config.OidEndpoint?.Trim(), + ClientId = config.OidClientId?.Trim(), + ClientSecret = config.OidSecret?.Trim(), + RedirectUri = GetRequestBase(config.SchemeOverride, config.PortOverride) + $"/sso/OID/{(Request.Path.Value.Contains("/start/", StringComparison.InvariantCultureIgnoreCase) ? "redirect" : "r")}/" + provider, + Scope = string.Join(" ", scopes.Prepend("openid profile")), + }; + var oidEndpointUri = new Uri(config.OidEndpoint?.Trim()); + options.Policy.Discovery.AdditionalEndpointBaseAddresses.Add(oidEndpointUri.GetLeftPart(UriPartial.Authority)); + options.Policy.Discovery.ValidateEndpoints = !config.DoNotValidateEndpoints; // For Google and other providers with different endpoints + options.Policy.Discovery.RequireHttps = !config.DisableHttps; + options.Policy.Discovery.ValidateIssuerName = !config.DoNotValidateIssuerName; + var oidcClient = new OidcClient(options); + var currentState = StateManager[state].State; + var result = await oidcClient.ProcessResponseAsync(Request.QueryString.Value, currentState).ConfigureAwait(false); + if (result.IsError) + { + return ReturnError(StatusCodes.Status400BadRequest, result.Error + " Try logging in again."); + } + + if (!config.EnableFolderRoles && config.EnabledFolders != null) + { + StateManager[state].Folders = new List(config.EnabledFolders); + } + else + { + StateManager[state].Folders = new List(); + } + + StateManager[state].EnableLiveTv = config.EnableLiveTv; + StateManager[state].EnableLiveTvManagement = config.EnableLiveTvManagement; + + foreach (var claim in result.User.Claims) + { + if (claim.Type == (config.DefaultUsernameClaim?.Trim() ?? "preferred_username")) + { + StateManager[state].Username = claim.Value; + if (config.Roles == null || config.Roles.Length == 0) + { + StateManager[state].Valid = true; + } + } + + // Role processing + // The regex matches any "." not preceded by a "\": a.b.c will be split into a, b, and c, but a.b\.c will be split into a, b.c (after processing the escaped dots) + // We have to first process the RoleClaim string + string[] segments = string.IsNullOrEmpty(config.RoleClaim) ? Array.Empty() : Regex.Split(config.RoleClaim.Trim(), "(? i.Replace("\\.", ".")).ToArray(); + + if (claim.Type == segments[0]) + { + List roles; + // If we are not using JSON values, just use the raw info from the claim value + if (segments.Length == 1) + { + roles = new List { claim.Value }; + } + else + { + // We recursively traverse through the JSON data for the roles and parse it + var json = JsonConvert.DeserializeObject>(claim.Value); + for (int i = 1; i < segments.Length - 1; i++) + { + var segment = segments[i]; + json = (json[segment] as JObject).ToObject>(); + } + + // The final step is to take the JSON and turn it from a dictionary into a string + roles = (json[segments[^1]] as JArray).ToObject>(); + } + + foreach (string role in roles) + { + // Check if allowed to login based on roles + if (config.Roles != null && config.Roles.Any()) + { + foreach (string validRoles in config.Roles) + { + if (role.Equals(validRoles)) + { + StateManager[state].Valid = true; + } + } + } + + // Check if admin based on roles + if (config.AdminRoles != null && config.AdminRoles.Any()) + { + foreach (string validAdminRoles in config.AdminRoles) + { + if (role.Equals(validAdminRoles)) + { + StateManager[state].Admin = true; + } + } + } + + // Get allowed folders from roles + if (config.EnableFolderRoles) + { + foreach (FolderRoleMap folderRoleMap in config.FolderRoleMapping) + { + if (role.Equals(folderRoleMap.Role?.Trim())) + { + StateManager[state].Folders.AddRange(folderRoleMap.Folders); + } + } + } + + if (config.EnableLiveTvRoles) + { + // Check if allowed Live TV based on roles + if (config.LiveTvRoles != null && config.LiveTvRoles.Any()) + { + foreach (string validLiveTvRoles in config.LiveTvRoles) + { + if (role.Equals(validLiveTvRoles)) + { + StateManager[state].EnableLiveTv = true; + } + } + } + + // Check if allowed Live TV management based on roles + if (config.LiveTvManagementRoles != null && config.LiveTvManagementRoles.Any()) + { + foreach (string validLiveTvManagementRoles in config.LiveTvManagementRoles) + { + if (role.Equals(validLiveTvManagementRoles)) + { + StateManager[state].EnableLiveTvManagement = true; + } + } + } + } + } + } + } + } + + // If the provider doesn't support the preferred username claim, then use the sub claim + if (!StateManager[state].Valid) + { + foreach (var claim in result.User.Claims) + { + if (claim.Type == "sub") + { + StateManager[state].Username = claim.Value; + if (config.Roles.Length == 0) + { + StateManager[state].Valid = true; + } + } + } + } + + bool isLinking = StateManager[state].IsLinking; + + if (StateManager[state].Valid) + { + _logger.LogInformation($"Is request linking: {isLinking}"); + return Content(WebResponse.Generator(data: state, provider: provider, baseUrl: GetRequestBase(config.SchemeOverride, config.PortOverride), mode: "OID", isLinking: isLinking), MediaTypeNames.Text.Html); + } + else + { + _logger.LogWarning( + "OpenID user {Username} has one or more incorrect role claims: {@Claims}. Expected any one of: {@ExpectedClaims}", + StateManager[state].Username, + result.User.Claims.Select(o => new { o.Type, o.Value }), + config.Roles); + + return ReturnError(StatusCodes.Status401Unauthorized, "Error. Check permissions."); + } + } + + // If the config doesn't have an active provider matching the requeset, show an error + return BadRequest("No matching provider found"); + } + + /// + /// Initiates the login flow for OpenID. This redirects the user to the auth provider. + /// + /// The name of the provider. + /// Whether or not this request is to link accounts (Rather than authenticate). + /// An asynchronous result for the authentication. + [HttpGet("OID/p/{provider}")] + [HttpGet("OID/start/{provider}")] + public async Task OidChallenge(string provider, [FromQuery] bool isLinking = false) + { + Invalidate(); + OidConfig config; + try + { + config = SSOPlugin.Instance.Configuration.OidConfigs[provider]; + } + catch (KeyNotFoundException) + { + throw new ArgumentException("Provider does not exist"); + } + + if (config.Enabled) + { + bool newPath = config.NewPath; + if (!isLinking) + { + newPath = Request.Path.Value.Contains("/start/", StringComparison.InvariantCultureIgnoreCase); + config.NewPath = newPath; + } + + string redirectUri = GetRequestBase(config.SchemeOverride, config.PortOverride) + $"/sso/OID/{(newPath ? "redirect" : "r")}/" + provider; + + var options = new OidcClientOptions + { + Authority = config.OidEndpoint?.Trim(), + ClientId = config.OidClientId?.Trim(), + ClientSecret = config.OidSecret?.Trim(), + RedirectUri = redirectUri, + Scope = string.Join(" ", config.OidScopes.Prepend("openid profile")), + }; + var oidEndpointUri = new Uri(config.OidEndpoint?.Trim()); + options.Policy.Discovery.AdditionalEndpointBaseAddresses.Add(oidEndpointUri.GetLeftPart(UriPartial.Authority)); + options.Policy.Discovery.ValidateEndpoints = !config.DoNotValidateEndpoints; // For Google and other providers with different endpoints + options.Policy.Discovery.RequireHttps = !config.DisableHttps; + options.Policy.Discovery.ValidateIssuerName = !config.DoNotValidateIssuerName; + var oidcClient = new OidcClient(options); + var state = await oidcClient.PrepareLoginAsync().ConfigureAwait(false); + StateManager.Add(state.State, new TimedAuthorizeState(state, DateTime.Now)); + + // Track whether this is a linking request or not. + StateManager[state.State].IsLinking = isLinking; + return Redirect(state.StartUrl); + } + + throw new ArgumentException("Provider does not exist"); + } + + /// + /// Adds an OpenID auth configuration. Requires administrator privileges. If the provider already exists, it will be removed and readded. + /// + /// The name of the provider to add. + /// The OID configuration (deserialized from a JSON post). + [Authorize(Policy = Policies.RequiresElevation)] + [HttpPost("OID/Add/{provider}")] + public void OidAdd(string provider, [FromBody] OidConfig config) + { + var configuration = SSOPlugin.Instance.Configuration; + configuration.OidConfigs[provider] = config; + SSOPlugin.Instance.UpdateConfiguration(configuration); + } + + /// + /// Deletes an OpenID provider. + /// + /// Name of provider to delete. + [Authorize(Policy = Policies.RequiresElevation)] + [HttpGet("OID/Del/{provider}")] + public void OidDel(string provider) + { + var configuration = SSOPlugin.Instance.Configuration; + configuration.OidConfigs.Remove(provider); + SSOPlugin.Instance.UpdateConfiguration(configuration); + } + + /// + /// Lists the OpenID providers configured. Requires administrator privileges. + /// + /// The list of OpenID configurations. + [Authorize(Policy = Policies.RequiresElevation)] + [HttpGet("OID/Get")] + public ActionResult OidProviders() + { + return Ok(SSOPlugin.Instance.Configuration.OidConfigs); + } + + /// + /// Lists the OpenID providers names only. + /// + /// The list of OpenID configurations. + [HttpGet("OID/GetNames")] + public ActionResult OidProviderNames() + { + return Ok(SSOPlugin.Instance.Configuration.OidConfigs.Keys); + } + + /// + /// Lists the SAML providers names only. + /// + /// The list of OpenID configurations. + [HttpGet("SAML/GetNames")] + public ActionResult SamlProviderNames() + { + return Ok(SSOPlugin.Instance.Configuration.SamlConfigs.Keys); + } + + /// + /// This is a debug endpoint to list all running OpenID flows. Requires administrator privileges. + /// + /// The list of OpenID flows in progress. + [Authorize(Policy = Policies.RequiresElevation)] + [HttpGet("OID/States")] + public ActionResult OidStates() + { + return Ok(StateManager); + } + + /// + /// This endpoint accepts JSON and will authorize the user from the device values passed from the client. + /// + /// Name of provider to authenticate against. + /// The data passed to the client to ensure it is the right one. + /// JSON for the client to populate information with. + [HttpPost("OID/Auth/{provider}")] + [Consumes(MediaTypeNames.Application.Json)] + [Produces(MediaTypeNames.Application.Json)] + public async Task OidAuth(string provider, [FromBody] AuthResponse response) + { + OidConfig config; + try + { + config = SSOPlugin.Instance.Configuration.OidConfigs[provider]; + } + catch (KeyNotFoundException) + { + return BadRequest("No matching provider found"); + } + + if (config.Enabled) + { + foreach (var kvp in StateManager) + { + if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid) + { + Guid userId = await CreateCanonicalLinkAndUserIfNotExist("oid", provider, kvp.Value.Username); + + var authenticationResult = await Authenticate(userId, kvp.Value.Admin, config.EnableAuthorization, config.EnableAllFolders, kvp.Value.Folders.ToArray(), kvp.Value.EnableLiveTv, kvp.Value.EnableLiveTvManagement, response, config.DefaultProvider?.Trim()) + .ConfigureAwait(false); + return Ok(authenticationResult); + } + } + } + + return Problem("Something went wrong"); + } + + /// + /// This is the callback for the SAML flow. This creates a webpage to complete auth. + /// + /// The provider that is calling back. + /// + /// RelayState given in the original saml request. If it is equal to "linking", + /// We consider this to be a linking request. + /// + /// A webpage that will complete the client-side flow. + [HttpPost("SAML/p/{provider}")] + [HttpPost("SAML/post/{provider}")] + public ActionResult SamlPost(string provider, [FromQuery] string relayState = null) + { + SamlConfig config; + try + { + config = SSOPlugin.Instance.Configuration.SamlConfigs[provider]; + } + catch (KeyNotFoundException) + { + return BadRequest("No matching provider found"); + } + + bool isLinking = relayState == "linking"; + + _logger.LogInformation( + $"SAML request has relayState of {relayState}"); + + if (config.Enabled) + { + var samlResponse = new Response(config.SamlCertificate, Request.Form["SAMLResponse"]); + + bool valid = false; + + // If no roles are configured, don't use RBAC + if (config.Roles.Length == 0) + { + valid = true; + } + + // Check if user is allowed to log in based on roles + foreach (string role in samlResponse.GetCustomAttributes("Role")) + { + foreach (string allowedRole in config.Roles) + { + if (allowedRole.Equals(role)) + { + valid = true; + } + } + } + + if (valid) + { + return Content( + WebResponse.Generator( + data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), + provider: provider, + baseUrl: GetRequestBase(config.SchemeOverride, config.PortOverride), + mode: "SAML", + isLinking: isLinking), + MediaTypeNames.Text.Html); + } + + _logger.LogWarning( + "SAML user: {UserId} has insufficient roles: {@Roles}. Expected any one of: {@ExpectedRoles}", + samlResponse.GetNameID(), + samlResponse.GetCustomAttributes("Role"), + config.Roles); + return ReturnError(StatusCodes.Status401Unauthorized, "Error. Check permissions."); + } + + return ReturnError(StatusCodes.Status400BadRequest, "No active providers found"); + } + + /// + /// Initializes the SAML flow. This will redirect the user to the SAML provider. + /// + /// The provider to being the flow with. + /// Whether this flow intends to link an account, or initiate auth. + /// A redirect to the SAML provider's auth page. + [HttpGet("SAML/p/{provider}")] + [HttpGet("SAML/start/{provider}")] + public RedirectResult SamlChallenge(string provider, [FromQuery] bool isLinking = false) + { + SamlConfig config; + try + { + config = SSOPlugin.Instance.Configuration.SamlConfigs[provider]; + } + catch (KeyNotFoundException) + { + throw new ArgumentException("Provider does not exist"); + } + + if (config.Enabled) + { + bool newPath = config.NewPath; + if (!isLinking) + { + newPath = Request.Path.Value.Contains("/start/", StringComparison.InvariantCultureIgnoreCase); + config.NewPath = newPath; + } + + string redirectUri = GetRequestBase(config.SchemeOverride, config.PortOverride) + $"/sso/SAML/{(newPath ? "post" : "p")}/" + provider; + string relayState = null; + if (isLinking) + { + relayState = "linking"; + } + + var request = new AuthRequest( + config.SamlClientId.Trim(), + redirectUri); + + return Redirect(request.GetRedirectUrl(config.SamlEndpoint.Trim(), relayState)); + } + + throw new ArgumentException("Provider does not exist"); + } + + /// + /// Adds a SAML configuration. If the provider already exists, overwrite it. + /// + /// The provider name to add. + /// The SAML configuration object (deserialized) from JSON. + /// The success result. + [Authorize(Policy = Policies.RequiresElevation)] + [HttpPost("SAML/Add/{provider}")] + public OkResult SamlAdd(string provider, [FromBody] SamlConfig newConfig) + { + var configuration = SSOPlugin.Instance.Configuration; + configuration.SamlConfigs[provider] = newConfig; + SSOPlugin.Instance.UpdateConfiguration(configuration); + return Ok(); + } + + /// + /// Deletes a provider from the configuration with a given ID. + /// + /// The ID of the provider to delete. + /// The success result. + [Authorize(Policy = Policies.RequiresElevation)] + [HttpGet("SAML/Del/{provider}")] + public OkResult SamlDel(string provider) + { + var configuration = SSOPlugin.Instance.Configuration; + configuration.SamlConfigs.Remove(provider); + SSOPlugin.Instance.UpdateConfiguration(configuration); + return Ok(); + } + + /// + /// Returns a list of all SAML providers configured. Requires administrator privileges. + /// + /// A list of all of the Saml providers available. + [Authorize(Policy = Policies.RequiresElevation)] + [HttpGet("SAML/Get")] + public ActionResult SamlProviders() + { + return Ok(SSOPlugin.Instance.Configuration.SamlConfigs); + } + + /// + /// This endpoint accepts JSON and will authorize the user from the device values passed from the client. + /// + /// The provider to authenticate against. + /// The data passed to the client to ensure it is the right one. + /// JSON for the client to populate information with. + [HttpPost("SAML/Auth/{provider}")] + [Consumes(MediaTypeNames.Application.Json)] + [Produces(MediaTypeNames.Application.Json)] + public async Task SamlAuth(string provider, [FromBody] AuthResponse response) + { + SamlConfig config; + try + { + config = SSOPlugin.Instance.Configuration.SamlConfigs[provider]; + } + catch (KeyNotFoundException) + { + return BadRequest("No matching provider found"); + } + + if (config.Enabled) + { + bool isAdmin = false; + bool liveTv = config.EnableLiveTv; + bool liveTvManagement = config.EnableLiveTvManagement; + var samlResponse = new Response(config.SamlCertificate, response.Data); + List folders; + if (!config.EnableFolderRoles && config.EnabledFolders != null) + { + folders = new List(config.EnabledFolders); + } + else + { + folders = new List(); + } + + foreach (string role in samlResponse.GetCustomAttributes("Role")) + { + if (config.AdminRoles != null) + { + foreach (string allowedRole in config.AdminRoles) + { + if (allowedRole.Equals(role)) + { + isAdmin = true; + } + } + } + + if (config.EnableFolderRoles) + { + if (config.FolderRoleMapping != null) + { + foreach (FolderRoleMap folderRoleMap in config.FolderRoleMapping) + { + if (folderRoleMap.Role.Equals(role)) + { + folders.AddRange(folderRoleMap.Folders); + } + } + } + } + + if (config.EnableLiveTvRoles) + { + if (config.LiveTvRoles != null) + { + foreach (string allowedLiveTvRole in config.LiveTvRoles) + { + if (allowedLiveTvRole.Equals(role)) + { + liveTv = true; + } + } + } + + if (config.LiveTvManagementRoles != null) + { + foreach (string allowedLiveTvManagementRole in config.LiveTvManagementRoles) + { + if (allowedLiveTvManagementRole.Equals(role)) + { + liveTvManagement = true; + } + } + } + } + } + + Guid userId = await CreateCanonicalLinkAndUserIfNotExist("saml", provider, samlResponse.GetNameID()); + + var authenticationResult = await Authenticate(userId, isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), liveTv, liveTvManagement, response, config.DefaultProvider?.Trim()) + .ConfigureAwait(false); + return Ok(authenticationResult); + } + + return Problem("Something went wrong"); + } + + /// + /// Removes a user from SSO auth and switches it back to another auth provider. Requires administrator privileges. + /// + /// The username to switch to the new provider. + /// The new provider to switch to. + /// Whether this API endpoint succeeded. + [Authorize(Policy = Policies.RequiresElevation)] + [HttpPost("Unregister/{username}")] + public ActionResult Unregister(string username, [FromBody] string provider) + { + User user = _userManager.GetUserByName(username); + user.AuthenticationProviderId = provider; + + return Ok(); + } + + private SerializableDictionary GetCanonicalLinks(string mode, string provider) + { + SerializableDictionary links = null; + + switch (mode.ToLower()) + { + case "saml": + links = SSOPlugin.Instance.Configuration.SamlConfigs[provider].CanonicalLinks; + break; + case "oid": + links = SSOPlugin.Instance.Configuration.OidConfigs[provider].CanonicalLinks; + break; + default: + throw new ArgumentException($"{mode} is not a valid choice between 'saml' and 'oid'"); + } + + if (links == null) + { + links = new SerializableDictionary(); + } + + return links; + } + + private async Task CreateCanonicalLinkAndUserIfNotExist(string mode, string provider, string canonicalName) + { + User user = null; + user = _userManager.GetUserByName(canonicalName); + if (user == null) + { + _logger.LogInformation($"SSO user {canonicalName} doesn't exist, creating..."); + user = await _userManager.CreateUserAsync(canonicalName).ConfigureAwait(false); + user.AuthenticationProviderId = GetType().FullName; + // https://jonathancrozier.com/blog/how-to-generate-a-cryptographically-secure-random-string-in-dot-net-with-c-sharp + user.Password = _cryptoProvider.CreatePasswordHash(Convert.ToBase64String(RandomNumberGenerator.GetBytes(64))).ToString(); + + // Make sure there aren't any trailing existing links + var links = GetCanonicalLinks(mode, provider); + links.Remove(canonicalName); + UpdateCanonicalLinkConfig(links, mode, provider); + } + + Guid userId = Guid.Empty; + try + { + userId = GetCanonicalLink(mode, provider, canonicalName); + } + catch (KeyNotFoundException) + { + userId = Guid.Empty; + } + + if (userId == Guid.Empty) + { + _logger.LogInformation("SSO user link doesn't exist, creating..."); + userId = user.Id; + CreateCanonicalLink(mode, provider, userId, canonicalName); + } + + return userId; + } + + private Guid GetCanonicalLink(string mode, string provider, string canonicalName) + { + SerializableDictionary links = null; + Guid userId = Guid.Empty; + + links = GetCanonicalLinks(mode, provider); + + userId = links[canonicalName]; + + return userId; + } + + /// + /// Create a canonical link for a given user. Must be performed by the user being changed, or admin. + /// + /// The mode of the function; SAML or OID. + /// The name of the provider to link to a jellyfin account. + /// The user ID within jellyfin to link to the provider. + /// The client information to authenticate the user with. + /// Whether this API endpoint succeeded. + [Authorize] + [HttpPost("{mode}/Link/{provider}/{jellyfinUserId}")] + [Consumes(MediaTypeNames.Application.Json)] + [Produces(MediaTypeNames.Application.Json)] + public async Task AddCanonicalLink([FromRoute] string mode, [FromRoute] string provider, [FromRoute] Guid jellyfinUserId, [FromBody] AuthResponse authResponse) + { + if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, jellyfinUserId, true).ConfigureAwait(false)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to link SSO providers."); + } + + switch (mode.ToLower()) + { + case "saml": + return SamlLink(provider, jellyfinUserId, authResponse); + case "oid": + return OidLink(provider, jellyfinUserId, authResponse); + default: + throw new ArgumentException($"{mode} is not a valid choice between 'saml' and 'oid'"); + } + } + + /// + /// Unregisters a given mapping from id within provider to user. + /// + /// The mode of the function; SAML or OID. + /// The name of the provider from which the link should be removed. + /// The user ID within jellyfin to unlink from the provider. + /// The user ID within jellyfin to unlink. + /// Whether this API endpoint succeeded. + [Authorize] + [HttpDelete("{mode}/Link/{provider}/{jellyfinUserId}/{canonicalName}")] + [Consumes(MediaTypeNames.Application.Json)] + [Produces(MediaTypeNames.Application.Json)] + public async Task DeleteCanonicalLink([FromRoute] string mode, [FromRoute] string provider, [FromRoute] Guid jellyfinUserId, [FromRoute] string canonicalName) + { + if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, jellyfinUserId, true).ConfigureAwait(false)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Current user is not allowed to unlink SSO providers for user ID."); + } + + Guid linkedId = GetCanonicalLink(mode, provider, canonicalName); + + if (linkedId != jellyfinUserId) + { + return StatusCode(StatusCodes.Status409Conflict, "jellyfin UID does not match id registered to that canonical name."); + } + + var links = GetCanonicalLinks(mode, provider); + + links.Remove(canonicalName); + + return UpdateCanonicalLinkConfig(links, mode, provider); + } + + /// + /// Gets all the saml links for a user. + /// + /// The user ID within jellyfin for which to return the links. + /// A dictionary of provider : link mappings. + [Authorize] + [HttpGet("saml/links/{jellyfinUserId}")] + [Produces(MediaTypeNames.Application.Json)] + public async Task>>> GetSamlLinksByUser(Guid jellyfinUserId) + { + if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, jellyfinUserId, true).ConfigureAwait(false)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Non-admin is not allowed to query other user's mappings."); + } + + var mappings = new SerializableDictionary>(); + var providerList = SSOPlugin.Instance.Configuration.SamlConfigs; + + foreach (var providerName in providerList.Keys) + { + var canonLinks = providerList[providerName].CanonicalLinks; + var canonKeys = from link in canonLinks where link.Value == jellyfinUserId select link.Key; + mappings[providerName] = canonKeys; + } + + return mappings; + } + + /// + /// Gets all the oid links for a user. + /// + /// The user ID within jellyfin for which to return the links. + /// A dictionary of provider : link mappings. + [Authorize] + [HttpGet("oid/links/{jellyfinUserId}")] + [Produces(MediaTypeNames.Application.Json)] + public async Task>>> GetOidLinksByUser(Guid jellyfinUserId) + { + if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, jellyfinUserId, true).ConfigureAwait(false)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Non-admin is not allowed to query other user's mappings."); + } + + var mappings = new SerializableDictionary>(); + var providerList = SSOPlugin.Instance.Configuration.OidConfigs; + + foreach (var providerName in providerList.Keys) + { + var canonLinks = providerList[providerName].CanonicalLinks; + var canonKeys = from link in canonLinks where link.Value == jellyfinUserId select link.Key; + mappings[providerName] = canonKeys; + } + + return mappings; + } + + /// + /// Validate a saml link request and create the link if it is valid. + /// + /// The provider to authenticate against. + /// + /// The ID of the account to be linked to the provider. + /// Must be performed by this user, or an admin. + /// + /// The data passed to the client to ensure it is the right one. + /// JSON for the client to populate information with. + [Consumes(MediaTypeNames.Application.Json)] + [Produces(MediaTypeNames.Application.Json)] + private ActionResult SamlLink(string provider, Guid jellyfinUserId, AuthResponse response) + { + SamlConfig config; + try + { + config = SSOPlugin.Instance.Configuration.SamlConfigs[provider]; + } + catch (KeyNotFoundException) + { + return BadRequest("No matching provider found"); + } + + var samlResponse = new Response(config.SamlCertificate, response.Data); + // TODO: Does saml response require further validation? + + string providerUserId = samlResponse.GetNameID(); + + return CreateCanonicalLink("saml", provider, jellyfinUserId, providerUserId); + } + + /// + /// Validate an OIDC link request and create the link if it is valid. + /// + /// The provider to authenticate against. + /// + /// The ID of the account to be linked to the provider. + /// Must be performed by this user, or an admin. + /// + /// The data passed to the client to ensure it is the right one. + /// JSON for the client to populate information with. + [Consumes(MediaTypeNames.Application.Json)] + [Produces(MediaTypeNames.Application.Json)] + private ActionResult OidLink(string provider, Guid jellyfinUserId, AuthResponse response) + { + OidConfig config; + try + { + config = SSOPlugin.Instance.Configuration.OidConfigs[provider]; + } + catch (KeyNotFoundException) + { + return BadRequest("No matching provider found"); + } + + foreach (var kvp in StateManager) + { + if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid) + { + string providerUserId = kvp.Value.Username; + return CreateCanonicalLink("oid", provider, jellyfinUserId, providerUserId); + } + } + + return Problem("Something went wrong!"); + } + + private ActionResult CreateCanonicalLink(string mode, string provider, [FromRoute] Guid jellyfinUserId, string providerUserId) + { + SerializableDictionary links = null; + try + { + links = GetCanonicalLinks(mode, provider); + } + catch (KeyNotFoundException) + { + return BadRequest("No matching provider found"); + } + + links[providerUserId] = jellyfinUserId; + UpdateCanonicalLinkConfig(links, mode, provider); + + return NoContent(); + } + + private OkResult UpdateCanonicalLinkConfig(SerializableDictionary links, string mode, string provider) + { + var configuration = SSOPlugin.Instance.Configuration; + switch (mode.ToLower()) + { + case "saml": + configuration.SamlConfigs[provider].CanonicalLinks = links; + break; + case "oid": + configuration.OidConfigs[provider].CanonicalLinks = links; + break; + default: + throw new ArgumentException($"{mode} is not a valid choice between 'saml' and 'oid'"); + } + + SSOPlugin.Instance.UpdateConfiguration(configuration); + return Ok(); + } + + /// + /// Authenticates the user with the given information. + /// + /// The user id of the user to authenticate. + /// Determines whether this user is an administrator. + /// Determines whether RBAC is used for this user. + /// Determines whether all folders are enabled. + /// Determines which folders should be enabled for this client. + /// Determines whether live TV access is allowed for this user. + /// Determines whether live TV can be managed by this user. + /// The client information to authenticate the user with. + /// The default provider of the user to be set after logging in. + private async Task Authenticate(Guid userId, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, bool enableLiveTv, bool enableLiveTvAdmin, AuthResponse authResponse, string defaultProvider) + { + User user = _userManager.GetUserById(userId); + if (enableAuthorization) + { + user.SetPermission(PermissionKind.IsAdministrator, isAdmin); + user.SetPermission(PermissionKind.EnableAllFolders, enableAllFolders); + if (!enableAllFolders) + { + user.SetPreference(PreferenceKind.EnabledFolders, enabledFolders); + } + } + + user.SetPermission(PermissionKind.EnableLiveTvAccess, enableLiveTv); + user.SetPermission(PermissionKind.EnableLiveTvManagement, enableLiveTvAdmin); + + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + + var authRequest = new AuthenticationRequest(); + authRequest.UserId = user.Id; + authRequest.Username = user.Username; + authRequest.App = authResponse.AppName; + authRequest.AppVersion = authResponse.AppVersion; + authRequest.DeviceId = authResponse.DeviceID; + authRequest.DeviceName = authResponse.DeviceName; + _logger.LogInformation("Auth request created..."); + if (!string.IsNullOrEmpty(defaultProvider)) + { + user.AuthenticationProviderId = defaultProvider; + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + _logger.LogInformation("Set default login provider to " + defaultProvider); + } + + return await _sessionManager.AuthenticateDirect(authRequest).ConfigureAwait(false); + } + + private void Invalidate() + { + foreach (var kvp in StateManager) + { + var now = DateTime.Now; + if (now.Subtract(kvp.Value.Created).TotalMinutes > 1) + { + StateManager.Remove(kvp.Key); + } + } + } + + private string GetRequestBase(string schemeOverride = null, int? portOverride = null) + { + int requestPort; + + if (portOverride != null) + { + requestPort = portOverride.Value; + } + else + { + requestPort = Request.Host.Port ?? -1; + } + + if ((requestPort == 80 && string.Equals(Request.Scheme, "http", StringComparison.OrdinalIgnoreCase)) || (requestPort == 443 && string.Equals(Request.Scheme, "https", StringComparison.OrdinalIgnoreCase))) + { + requestPort = -1; + } + + if (schemeOverride != "http" && schemeOverride != "https") + { + schemeOverride = null; + } + + return new UriBuilder + { + Scheme = schemeOverride ?? Request.Scheme, + Host = Request.Host.Host, + Port = requestPort, + Path = Request.PathBase + }.ToString().TrimEnd('/'); + } + + private ContentResult ReturnError(int code, string message) + { + var errorResult = new ContentResult(); + errorResult.Content = message; + errorResult.ContentType = MediaTypeNames.Text.Plain; + errorResult.StatusCode = code; + return errorResult; + } +} + +/// +/// The data the client should pass back to the API. +/// +public class AuthResponse +{ + /// + /// Gets or sets the device ID of the client. + /// + public string DeviceID { get; set; } + + /// + /// Gets or sets the device name of the client. + /// + public string DeviceName { get; set; } + + /// + /// Gets or sets the app name of the client. + /// + public string AppName { get; set; } + + /// + /// Gets or sets the app version of the client. + /// + public string AppVersion { get; set; } + + /// + /// Gets or sets the auth data of the client (for authorizing the response). + /// + public string Data { get; set; } +} + +/// +/// A manager for OpenID to manage the state of the clients. +/// +public class TimedAuthorizeState +{ + /// + /// Initializes a new instance of the class. + /// + /// The AuthorizeState to time. + /// When this state was created. + public TimedAuthorizeState(AuthorizeState state, DateTime created) + { + State = state; + Created = created; + Valid = false; + Admin = false; + IsLinking = false; + EnableLiveTv = false; + EnableLiveTvManagement = false; + } + + /// + /// Gets or sets the Authorization State of the client. + /// + public AuthorizeState State { get; set; } + + /// + /// Gets or sets when this object was created to time it out. + /// + public DateTime Created { get; set; } + + /// + /// Gets or sets a value indicating whether the user is valid. + /// + public bool Valid { get; set; } + + /// + /// Gets or sets the user tied to the state. + /// + public string Username { get; set; } + + /// + /// Gets or sets a value indicating whether the user is an administrator. + /// + public bool Admin { get; set; } + + /// + /// Gets or sets a value indicating whether the state is + /// tied to a linking flow (instead of a login flow). + /// + public bool IsLinking { get; set; } + + /// + /// Gets or sets the folders the user is allowed access to. + /// + public List Folders { get; set; } + + /// + /// Gets or sets a value indicating whether the user is allowed to view live TV. + /// + public bool EnableLiveTv { get; set; } + + /// + /// Gets or sets a value indicating whether the user is allowed to manage live TV. + /// + public bool EnableLiveTvManagement { get; set; } +} diff --git a/SSO-Auth/Config/PluginConfiguration.cs b/SSO-Auth/Config/PluginConfiguration.cs index 767ed2e..e0a15c8 100644 --- a/SSO-Auth/Config/PluginConfiguration.cs +++ b/SSO-Auth/Config/PluginConfiguration.cs @@ -131,6 +131,11 @@ public class SamlConfig /// public string SchemeOverride { get; set; } + /// + /// Gets or sets the redirect port override. + /// + public int? PortOverride { get; set; } + /// /// Gets or sets a value indicating whether the new, more descriptive paths are to be used. /// @@ -265,6 +270,11 @@ public class OidConfig /// public string SchemeOverride { get; set; } + /// + /// Gets or sets the redirect port override. + /// + public int? PortOverride { get; set; } + /// /// Gets or sets a value indicating whether the new, more descriptive paths are to be used. /// diff --git a/SSO-Auth/Config/configPage.html b/SSO-Auth/Config/configPage.html index 7aa1f56..bccf2c7 100644 --- a/SSO-Auth/Config/configPage.html +++ b/SSO-Auth/Config/configPage.html @@ -619,6 +619,18 @@ +
+ + +
+ If the plugin is redirecting to an incorrect port, set this to + the appropiate port +
+
+