diff --git a/SSO-Auth/Api/SSOController.cs b/SSO-Auth/Api/SSOController.cs
index 8466b38..f0129dc 100644
--- a/SSO-Auth/Api/SSOController.cs
+++ b/SSO-Auth/Api/SSOController.cs
@@ -1,1203 +1,1210 @@
-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 Duende.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 Duende.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, $"Error logging in: {result.Error} - {result.ErrorDescription}");
+ }
+
+ 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);
+
+ if (state.IsError)
+ {
+ return ReturnError(StatusCodes.Status400BadRequest, $"Error preparing login: {state.Error} - {state.ErrorDescription}");
+ }
+
+ 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; }
+}