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