jellyfin-plugin-sso/SSO-Auth/Api/SSOController.cs

1202 lines
48 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Mime;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using IdentityModel.OidcClient;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.SSO_Auth.Config;
using Jellyfin.Plugin.SSO_Auth.Helpers;
using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Cryptography;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using SSO_Auth.Lib;
namespace Jellyfin.Plugin.SSO_Auth.Api;
/// <summary>
/// The sso api controller.
/// </summary>
[ApiController]
[Route("[controller]")]
public class SSOController : ControllerBase
{
private readonly IUserManager _userManager;
private readonly ISessionManager _sessionManager;
private readonly IAuthorizationContext _authContext;
private readonly ILogger<SSOController> _logger;
private readonly ICryptoProvider _cryptoProvider;
private static readonly IDictionary<string, TimedAuthorizeState> StateManager = new Dictionary<string, TimedAuthorizeState>();
/// <summary>
/// Initializes a new instance of the <see cref="SSOController"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{SSOController}"/> interface.</param>
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
/// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
/// <param name="cryptoProvider">Instance of the <see cref="ICryptoProvider"/> interface.</param>
public SSOController(ILogger<SSOController> logger, ISessionManager sessionManager, IUserManager userManager, IAuthorizationContext authContext, ICryptoProvider cryptoProvider)
{
_sessionManager = sessionManager;
_userManager = userManager;
_authContext = authContext;
_cryptoProvider = cryptoProvider;
_logger = logger;
_logger.LogInformation("SSO Controller initialized");
}
/// <summary>
/// The GET endpoint for OpenID provider to callback to. Returns a webpage that parses client data and completes auth.
/// </summary>
/// <param name="provider">The ID of the provider which will use the callback information.</param>
/// <param name="state">The current request state.</param>
/// <returns>A webpage that will complete the client-side flow.</returns>
// Actually a GET: https://github.com/IdentityModel/IdentityModel.OidcClient/issues/325
[HttpGet("OID/r/{provider}")]
[HttpGet("OID/redirect/{provider}")]
public async Task<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 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<string>(config.EnabledFolders);
}
else
{
StateManager[state].Folders = new List<string>();
}
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<string>() : Regex.Split(config.RoleClaim.Trim(), "(?<!\\\\)\\.");
if (segments.Any())
{
// Now we make sure that any escaped "."s ("\.") are replaced with "."
segments = segments.Select(i => i.Replace("\\.", ".")).ToArray();
if (claim.Type == segments[0])
{
List<string> roles;
// If we are not using JSON values, just use the raw info from the claim value
if (segments.Length == 1)
{
roles = new List<string> { claim.Value };
}
else
{
// We recursively traverse through the JSON data for the roles and parse it
var json = JsonConvert.DeserializeObject<IDictionary<string, object>>(claim.Value);
for (int i = 1; i < segments.Length - 1; i++)
{
var segment = segments[i];
json = (json[segment] as JObject).ToObject<IDictionary<string, object>>();
}
// The final step is to take the JSON and turn it from a dictionary into a string
roles = (json[segments[^1]] as JArray).ToObject<List<string>>();
}
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");
}
/// <summary>
/// Initiates the login flow for OpenID. This redirects the user to the auth provider.
/// </summary>
/// <param name="provider">The name of the provider.</param>
/// <param name="isLinking">Whether or not this request is to link accounts (Rather than authenticate).</param>
/// <returns>An asynchronous result for the authentication.</returns>
[HttpGet("OID/p/{provider}")]
[HttpGet("OID/start/{provider}")]
public async Task<ActionResult> 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
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");
}
/// <summary>
/// Adds an OpenID auth configuration. Requires administrator privileges. If the provider already exists, it will be removed and readded.
/// </summary>
/// <param name="provider">The name of the provider to add.</param>
/// <param name="config">The OID configuration (deserialized from a JSON post).</param>
[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);
}
/// <summary>
/// Deletes an OpenID provider.
/// </summary>
/// <param name="provider">Name of provider to delete.</param>
[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);
}
/// <summary>
/// Lists the OpenID providers configured. Requires administrator privileges.
/// </summary>
/// <returns>The list of OpenID configurations.</returns>
[Authorize(Policy = Policies.RequiresElevation)]
[HttpGet("OID/Get")]
public ActionResult OidProviders()
{
return Ok(SSOPlugin.Instance.Configuration.OidConfigs);
}
/// <summary>
/// Lists the OpenID providers names only.
/// </summary>
/// <returns>The list of OpenID configurations.</returns>
[HttpGet("OID/GetNames")]
public ActionResult OidProviderNames()
{
return Ok(SSOPlugin.Instance.Configuration.OidConfigs.Keys);
}
/// <summary>
/// Lists the SAML providers names only.
/// </summary>
/// <returns>The list of OpenID configurations.</returns>
[HttpGet("SAML/GetNames")]
public ActionResult SamlProviderNames()
{
return Ok(SSOPlugin.Instance.Configuration.SamlConfigs.Keys);
}
/// <summary>
/// This is a debug endpoint to list all running OpenID flows. Requires administrator privileges.
/// </summary>
/// <returns>The list of OpenID flows in progress.</returns>
[Authorize(Policy = Policies.RequiresElevation)]
[HttpGet("OID/States")]
public ActionResult OidStates()
{
return Ok(StateManager);
}
/// <summary>
/// This endpoint accepts JSON and will authorize the user from the device values passed from the client.
/// </summary>
/// <param name="provider">Name of provider to authenticate against.</param>
/// <param name="response">The data passed to the client to ensure it is the right one.</param>
/// <returns>JSON for the client to populate information with.</returns>
[HttpPost("OID/Auth/{provider}")]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult> 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");
}
/// <summary>
/// This is the callback for the SAML flow. This creates a webpage to complete auth.
/// </summary>
/// <param name="provider">The provider that is calling back.</param>
/// <param name="relayState">
/// RelayState given in the original saml request. If it is equal to "linking",
/// We consider this to be a linking request.
/// </param>
/// <returns>A webpage that will complete the client-side flow.</returns>
[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");
}
/// <summary>
/// Initializes the SAML flow. This will redirect the user to the SAML provider.
/// </summary>
/// <param name="provider">The provider to being the flow with.</param>
/// <param name="isLinking">Whether this flow intends to link an account, or initiate auth.</param>
/// <returns>A redirect to the SAML provider's auth page.</returns>
[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");
}
/// <summary>
/// Adds a SAML configuration. If the provider already exists, overwrite it.
/// </summary>
/// <param name="provider">The provider name to add.</param>
/// <param name="newConfig">The SAML configuration object (deserialized) from JSON.</param>
/// <returns>The success result.</returns>
[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();
}
/// <summary>
/// Deletes a provider from the configuration with a given ID.
/// </summary>
/// <param name="provider">The ID of the provider to delete.</param>
/// <returns>The success result.</returns>
[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();
}
/// <summary>
/// Returns a list of all SAML providers configured. Requires administrator privileges.
/// </summary>
/// <returns>A list of all of the Saml providers available.</returns>
[Authorize(Policy = Policies.RequiresElevation)]
[HttpGet("SAML/Get")]
public ActionResult SamlProviders()
{
return Ok(SSOPlugin.Instance.Configuration.SamlConfigs);
}
/// <summary>
/// This endpoint accepts JSON and will authorize the user from the device values passed from the client.
/// </summary>
/// <param name="provider">The provider to authenticate against.</param>
/// <param name="response">The data passed to the client to ensure it is the right one.</param>
/// <returns>JSON for the client to populate information with.</returns>
[HttpPost("SAML/Auth/{provider}")]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult> 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<string> folders;
if (!config.EnableFolderRoles && config.EnabledFolders != null)
{
folders = new List<string>(config.EnabledFolders);
}
else
{
folders = new List<string>();
}
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");
}
/// <summary>
/// Removes a user from SSO auth and switches it back to another auth provider. Requires administrator privileges.
/// </summary>
/// <param name="username">The username to switch to the new provider.</param>
/// <param name="provider">The new provider to switch to.</param>
/// <returns>Whether this API endpoint succeeded.</returns>
[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<string, Guid> GetCanonicalLinks(string mode, string provider)
{
SerializableDictionary<string, Guid> 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<string, Guid>();
}
return links;
}
private async Task<Guid> 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<string, Guid> links = null;
Guid userId = Guid.Empty;
links = GetCanonicalLinks(mode, provider);
userId = links[canonicalName];
return userId;
}
/// <summary>
/// Create a canonical link for a given user. Must be performed by the user being changed, or admin.
/// </summary>
/// <param name="mode">The mode of the function; SAML or OID.</param>
/// <param name="provider">The name of the provider to link to a jellyfin account.</param>
/// <param name="jellyfinUserId">The user ID within jellyfin to link to the provider.</param>
/// <param name="authResponse">The client information to authenticate the user with.</param>
/// <returns>Whether this API endpoint succeeded.</returns>
[Authorize]
[HttpPost("{mode}/Link/{provider}/{jellyfinUserId}")]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult> 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'");
}
}
/// <summary>
/// Unregisters a given mapping from id within provider to user.
/// </summary>
/// <param name="mode">The mode of the function; SAML or OID.</param>
/// <param name="provider">The name of the provider from which the link should be removed.</param>
/// <param name="jellyfinUserId">The user ID within jellyfin to unlink from the provider.</param>
/// <param name="canonicalName">The user ID within jellyfin to unlink.</param>
/// <returns>Whether this API endpoint succeeded.</returns>
[Authorize]
[HttpDelete("{mode}/Link/{provider}/{jellyfinUserId}/{canonicalName}")]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult> 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);
}
/// <summary>
/// Gets all the saml links for a user.
/// </summary>
/// <param name="jellyfinUserId">The user ID within jellyfin for which to return the links.</param>
/// <returns>A dictionary of provider : link mappings.</returns>
[Authorize]
[HttpGet("saml/links/{jellyfinUserId}")]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult<SerializableDictionary<string, IEnumerable<string>>>> 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<string, IEnumerable<string>>();
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;
}
/// <summary>
/// Gets all the oid links for a user.
/// </summary>
/// <param name="jellyfinUserId">The user ID within jellyfin for which to return the links.</param>
/// <returns>A dictionary of provider : link mappings.</returns>
[Authorize]
[HttpGet("oid/links/{jellyfinUserId}")]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult<SerializableDictionary<string, IEnumerable<string>>>> 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<string, IEnumerable<string>>();
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;
}
/// <summary>
/// Validate a saml link request and create the link if it is valid.
/// </summary>
/// <param name="provider">The provider to authenticate against.</param>
/// <param name="jellyfinUserId">
/// The ID of the account to be linked to the provider.
/// Must be performed by this user, or an admin.
/// </param>
/// <param name="response">The data passed to the client to ensure it is the right one.</param>
/// <returns>JSON for the client to populate information with.</returns>
[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);
}
/// <summary>
/// Validate an OIDC link request and create the link if it is valid.
/// </summary>
/// <param name="provider">The provider to authenticate against.</param>
/// <param name="jellyfinUserId">
/// The ID of the account to be linked to the provider.
/// Must be performed by this user, or an admin.
/// </param>
/// <param name="response">The data passed to the client to ensure it is the right one.</param>
/// <returns>JSON for the client to populate information with.</returns>
[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<string, Guid> 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<string, Guid> 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();
}
/// <summary>
/// Authenticates the user with the given information.
/// </summary>
/// <param name="userId">The user id of the user to authenticate.</param>
/// <param name="isAdmin">Determines whether this user is an administrator.</param>
/// <param name="enableAuthorization">Determines whether RBAC is used for this user.</param>
/// <param name="enableAllFolders">Determines whether all folders are enabled.</param>
/// <param name="enabledFolders">Determines which folders should be enabled for this client.</param>
/// <param name="enableLiveTv">Determines whether live TV access is allowed for this user.</param>
/// <param name="enableLiveTvAdmin">Determines whether live TV can be managed by this user.</param>
/// <param name="authResponse">The client information to authenticate the user with.</param>
/// <param name="defaultProvider">The default provider of the user to be set after logging in.</param>
private async Task<AuthenticationResult> 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;
}
}
/// <summary>
/// The data the client should pass back to the API.
/// </summary>
public class AuthResponse
{
/// <summary>
/// Gets or sets the device ID of the client.
/// </summary>
public string DeviceID { get; set; }
/// <summary>
/// Gets or sets the device name of the client.
/// </summary>
public string DeviceName { get; set; }
/// <summary>
/// Gets or sets the app name of the client.
/// </summary>
public string AppName { get; set; }
/// <summary>
/// Gets or sets the app version of the client.
/// </summary>
public string AppVersion { get; set; }
/// <summary>
/// Gets or sets the auth data of the client (for authorizing the response).
/// </summary>
public string Data { get; set; }
}
/// <summary>
/// A manager for OpenID to manage the state of the clients.
/// </summary>
public class TimedAuthorizeState
{
/// <summary>
/// Initializes a new instance of the <see cref="TimedAuthorizeState"/> class.
/// </summary>
/// <param name="state">The AuthorizeState to time.</param>
/// <param name="created">When this state was created.</param>
public TimedAuthorizeState(AuthorizeState state, DateTime created)
{
State = state;
Created = created;
Valid = false;
Admin = false;
IsLinking = false;
EnableLiveTv = false;
EnableLiveTvManagement = false;
}
/// <summary>
/// Gets or sets the Authorization State of the client.
/// </summary>
public AuthorizeState State { get; set; }
/// <summary>
/// Gets or sets when this object was created to time it out.
/// </summary>
public DateTime Created { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user is valid.
/// </summary>
public bool Valid { get; set; }
/// <summary>
/// Gets or sets the user tied to the state.
/// </summary>
public string Username { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user is an administrator.
/// </summary>
public bool Admin { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the state is
/// tied to a linking flow (instead of a login flow).
/// </summary>
public bool IsLinking { get; set; }
/// <summary>
/// Gets or sets the folders the user is allowed access to.
/// </summary>
public List<string> Folders { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user is allowed to view live TV.
/// </summary>
public bool EnableLiveTv { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user is allowed to manage live TV.
/// </summary>
public bool EnableLiveTvManagement { get; set; }
}