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.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; 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}")] public ActionResult 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 options = new OidcClientOptions { Authority = config.OidEndpoint, ClientId = config.OidClientId, ClientSecret = config.OidSecret, RedirectUri = GetRequestBase() + "/sso/OID/r/" + provider, Scope = string.Join(" ", config.OidScopes.Prepend("openid profile")), }; options.Policy.Discovery.ValidateEndpoints = false; // For Google and other providers with different endpoints options.Policy.Discovery.RequireHttps = config.RequireHttps || true; var oidcClient = new OidcClient(options); var currentState = StateManager[state].State; var result = oidcClient.ProcessResponseAsync(Request.QueryString.Value, currentState).Result; if (result.IsError) { return ReturnError(StatusCodes.Status400BadRequest, result.Error + " Try logging in again."); } if (!config.EnableFolderRoles) { StateManager[state].Folders = new List(config.EnabledFolders); } else { StateManager[state].Folders = new List(); } foreach (var claim in result.User.Claims) { if (claim.Type == (config.DefaultUsernameClaim ?? "preferred_username")) { StateManager[state].Username = claim.Value; if (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 = Regex.Split(config.RoleClaim, "(? 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.Length != 0) { foreach (string validRoles in config.Roles) { if (role.Equals(validRoles)) { StateManager[state].Valid = true; } } } // Check if admin based on roles if (config.AdminRoles.Length != 0) { 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)) { StateManager[state].Folders.AddRange(folderRoleMap.Folders); } } } } } } // 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(), 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}")] 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) { var options = new OidcClientOptions { Authority = config.OidEndpoint, ClientId = config.OidClientId, ClientSecret = config.OidSecret, RedirectUri = GetRequestBase() + "/sso/OID/r/" + provider, Scope = string.Join(" ", config.OidScopes.Prepend("openid profile")), }; options.Policy.Discovery.ValidateEndpoints = false; // For Google and other providers with different endpoints 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 = "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 = "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 = "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 = "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(), response, config.DefaultProvider) .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}")] 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"]); // If no roles are configured, don't use RBAC if (config.Roles.Length == 0) { return Content(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML", isLinking: isLinking), MediaTypeNames.Text.Html); } // 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)) { return Content(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), 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}")] 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) { string relayState = null; if (isLinking) { relayState = "linking"; } var request = new AuthRequest( config.SamlClientId, GetRequestBase() + "/sso/SAML/p/" + provider); return Redirect(request.GetRedirectUrl(config.SamlEndpoint, 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 = "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 = "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 = "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; var samlResponse = new Response(config.SamlCertificate, response.Data); List folders; if (!config.EnableFolderRoles) { folders = new List(config.EnabledFolders); } else { folders = new List(); } foreach (string role in samlResponse.GetCustomAttributes("Role")) { foreach (string allowedRole in config.AdminRoles) { if (allowedRole.Equals(role)) { isAdmin = true; } } if (config.EnableFolderRoles) { foreach (FolderRoleMap folderRoleMap in config.FolderRoleMapping) { if (folderRoleMap.Role.Equals(role)) { folders.AddRange(folderRoleMap.Folders); } } } } Guid userId = await CreateCanonicalLinkAndUserIfNotExist("saml", provider, samlResponse.GetNameID()); var authenticationResult = await Authenticate(userId, isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), response, config.DefaultProvider) .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 = "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(Policy = "DefaultAuthorization")] [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(Policy = "DefaultAuthorization")] [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(Policy = "DefaultAuthorization")] [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(Policy = "DefaultAuthorization")] [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. /// 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, 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); } } 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() { 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; } return new UriBuilder { Scheme = 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; } /// /// 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; } }