mirror of
https://github.com/9p4/jellyfin-plugin-sso.git
synced 2026-09-19 13:12:19 +00:00
Merge branch 'main' of https://github.com/9p4/jellyfin-plugin-sso into patch-2
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
name: .NET
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v1
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
- name: Build
|
||||
run: dotnet build --no-restore --warnaserror
|
||||
- name: Test
|
||||
run: dotnet test --no-build --verbosity normal
|
||||
@@ -154,7 +154,7 @@ These all require authorization. Append an API key to the end of the request: `c
|
||||
- `adminRoles`: array of strings. This uses the OpenID response against the claim set in `roleClaim`. If a user has any of these roles, then the user is an admin. Leave blank to disable (default is to not enable admin permissions).
|
||||
- `enableFolderRoles`: boolean. Determines if role-based folder access should be used.
|
||||
- `folderRoleMapping`: object in the format "role": string and "folders": array of strings. The user with this role will have access to the following folders if `enableFolderRoles` is enabled. To get the IDs of the folders, GET the `/Library/MediaFolders` URL with an API key. Look for the `Id` attribute.
|
||||
- `roleClaim`: string. This is the value in the OpenID response to check for roles. For Keycloak, it is `realm_access` by default.
|
||||
- `roleClaim`: string. This is the value in the OpenID response to check for roles. For Keycloak, it is `realm_access.roles` by default. The first element is the claim type, the subsequent values are to parse the JSON of the claim value. Use a "\\." to denote a literal ".". This expects a list of strings from the OIDC server.
|
||||
- GET `OID/Del/clientId`: This removes a configuration for OpenID for a given client ID.
|
||||
- GET `OID/Get`: Lists the configurations currently available.
|
||||
- GET `OID/States`: Lists currently active OpenID flows in progress.
|
||||
|
||||
+179
-15
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Mime;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using IdentityModel.OidcClient;
|
||||
using Jellyfin.Data.Entities;
|
||||
@@ -13,6 +14,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Jellyfin.Plugin.SSO_Auth.Api;
|
||||
|
||||
@@ -42,8 +44,13 @@ public class SSOController : ControllerBase
|
||||
_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>
|
||||
/// <returns>A webpage that will complete the client-side flow.</returns>
|
||||
[HttpGet("OID/r/{provider}")]
|
||||
public ActionResult OIDPost(string provider)
|
||||
public ActionResult OIDPost(string provider) // Although this is a GET function, this function is called `Post` for consistency with SAML
|
||||
{
|
||||
// Actually a GET: https://github.com/IdentityModel/IdentityModel.OidcClient/issues/325
|
||||
foreach (var config in SSOPlugin.Instance.Configuration.OIDConfigs)
|
||||
@@ -64,13 +71,15 @@ public class SSOController : ControllerBase
|
||||
var result = oidcClient.ProcessResponseAsync(Request.QueryString.Value, state).Result;
|
||||
if (result.IsError)
|
||||
{
|
||||
return Content("Something went wrong...", MediaTypeNames.Text.Plain);
|
||||
return BadRequest(result.Error + " Try logging in again.");
|
||||
}
|
||||
|
||||
if (!config.EnableFolderRoles)
|
||||
{
|
||||
StateManager[Request.Query["state"]].Folders = new List<string>(config.EnabledFolders);
|
||||
} else {
|
||||
}
|
||||
else
|
||||
{
|
||||
StateManager[Request.Query["state"]].Folders = new List<string>();
|
||||
}
|
||||
|
||||
@@ -86,9 +95,37 @@ public class SSOController : ControllerBase
|
||||
}
|
||||
|
||||
// Role processing
|
||||
if (claim.Type == config.RoleClaim)
|
||||
// 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, "(?<!\\\\)\\.");
|
||||
// Now we make sure that any escaped "."s ("\.") are replaced with "."
|
||||
for (int i = 0; i < segments.Length; i++)
|
||||
{
|
||||
List<string> roles = JsonConvert.DeserializeObject<IDictionary<string, List<string>>>(claim.Value)["roles"]; // Might need error handling here
|
||||
segments[i] = segments[i].Replace("\\.", ".");
|
||||
}
|
||||
|
||||
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[segments.Length - 1]] as JArray).ToObject<List<string>>();
|
||||
}
|
||||
|
||||
foreach (string role in roles)
|
||||
{
|
||||
// Check if allowed to login based on roles
|
||||
@@ -102,6 +139,7 @@ public class SSOController : ControllerBase
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if admin based on roles
|
||||
if (config.AdminRoles.Length != 0)
|
||||
{
|
||||
@@ -113,6 +151,7 @@ public class SSOController : ControllerBase
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get allowed folders from roles
|
||||
if (config.EnableFolderRoles)
|
||||
{
|
||||
@@ -143,20 +182,27 @@ public class SSOController : ControllerBase
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (StateManager[Request.Query["state"]].Valid)
|
||||
{
|
||||
return Content(WebResponse.Generator(data: Request.Query["state"], provider: provider, baseUrl: GetRequestBase(), mode: "OID"), MediaTypeNames.Text.Html);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Content("Error. Check permissions."); // TODO: Return error code as well
|
||||
return BadRequest("Error. Check permissions.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Content("no active providers found"); // TODO: Return error code as well
|
||||
// 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>
|
||||
/// <returns>An asynchronous result for the authentication.</returns>
|
||||
[HttpGet("OID/p/{provider}")]
|
||||
public async Task<ActionResult> OIDChallenge(string provider)
|
||||
{
|
||||
@@ -184,6 +230,10 @@ public class SSOController : ControllerBase
|
||||
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="config">The OID configuration (deserialized from a JSON post).</param>
|
||||
[Authorize(Policy = "RequiresElevation")]
|
||||
[HttpPost("OID/Add")]
|
||||
public void OIDAdd([FromBody] OIDConfig config)
|
||||
@@ -201,6 +251,10 @@ public class SSOController : ControllerBase
|
||||
SSOPlugin.Instance.UpdateConfiguration(configuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an OpenID provider.
|
||||
/// </summary>
|
||||
/// <param name="provider">Name of provider to delete.</param>
|
||||
[Authorize(Policy = "RequiresElevation")]
|
||||
[HttpGet("OID/Del/{provider}")]
|
||||
public void OIDDel(string provider)
|
||||
@@ -217,6 +271,10 @@ public class SSOController : ControllerBase
|
||||
SSOPlugin.Instance.UpdateConfiguration(configuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists the OpenID providers configured. Requires administrator privileges.
|
||||
/// </summary>
|
||||
/// <returns>The list of OpenID configurations.</returns>
|
||||
[Authorize(Policy = "RequiresElevation")]
|
||||
[HttpGet("OID/Get")]
|
||||
public ActionResult OIDProviders()
|
||||
@@ -224,6 +282,10 @@ public class SSOController : ControllerBase
|
||||
return Ok(SSOPlugin.Instance.Configuration.OIDConfigs);
|
||||
}
|
||||
|
||||
/// <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 = "RequiresElevation")]
|
||||
[HttpGet("OID/States")]
|
||||
public ActionResult OIDStates()
|
||||
@@ -231,6 +293,11 @@ public class SSOController : ControllerBase
|
||||
return Ok(StateManager);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This endpoint accepts JSON and will authorize the user from the device values passed from the client.
|
||||
/// </summary>
|
||||
/// <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")]
|
||||
[Consumes(MediaTypeNames.Application.Json)]
|
||||
[Produces(MediaTypeNames.Application.Json)]
|
||||
@@ -264,6 +331,11 @@ public class SSOController : ControllerBase
|
||||
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>
|
||||
/// <returns>A webpage that will complete the client-side flow.</returns>
|
||||
[HttpPost("SAML/p/{provider}")]
|
||||
public ActionResult SAMLPost(string provider)
|
||||
{
|
||||
@@ -278,6 +350,7 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
return Content(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML"), MediaTypeNames.Text.Html);
|
||||
}
|
||||
|
||||
// Check if user is allowed to log in based on roles
|
||||
foreach (string role in samlResponse.GetCustomAttributes("Role"))
|
||||
{
|
||||
@@ -289,13 +362,19 @@ public class SSOController : ControllerBase
|
||||
}
|
||||
}
|
||||
}
|
||||
return Content("401 Forbidden"); // TODO: Return error code as well
|
||||
|
||||
return Forbid("401 Forbidden"); // TODO: Return error code as well
|
||||
}
|
||||
}
|
||||
|
||||
return Content("no active providers found"); // TODO: Return error code as well
|
||||
return BadRequest("no active providers found"); // TODO: Return error code as well
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <returns>A redirect to the SAML provider's auth page.</returns>
|
||||
[HttpGet("SAML/p/{provider}")]
|
||||
public RedirectResult SAMLChallenge(string provider)
|
||||
{
|
||||
@@ -314,6 +393,10 @@ public class SSOController : ControllerBase
|
||||
throw new ArgumentException("Provider does not exist");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a SAML configuration. If the provider already exists, overwrite it.
|
||||
/// </summary>
|
||||
/// <param name="config">The SAML configuration object (deserialized) from JSON.</param>
|
||||
[Authorize(Policy = "RequiresElevation")]
|
||||
[HttpPost("SAML/Add")]
|
||||
public void SamlAdd([FromBody] SamlConfig config)
|
||||
@@ -331,6 +414,10 @@ public class SSOController : ControllerBase
|
||||
SSOPlugin.Instance.UpdateConfiguration(configuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a provider from the configuration with a given ID.
|
||||
/// </summary>
|
||||
/// <param name="provider">The ID of the provider to delete.</param>
|
||||
[Authorize(Policy = "RequiresElevation")]
|
||||
[HttpGet("SAML/Del/{provider}")]
|
||||
public void SamlDel(string provider)
|
||||
@@ -347,6 +434,10 @@ public class SSOController : ControllerBase
|
||||
SSOPlugin.Instance.UpdateConfiguration(configuration);
|
||||
}
|
||||
|
||||
/// <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 = "RequiresElevation")]
|
||||
[HttpGet("SAML/Get")]
|
||||
public ActionResult SamlProviders()
|
||||
@@ -354,6 +445,11 @@ public class SSOController : ControllerBase
|
||||
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="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")]
|
||||
[Consumes(MediaTypeNames.Application.Json)]
|
||||
[Produces(MediaTypeNames.Application.Json)]
|
||||
@@ -369,9 +465,12 @@ public class SSOController : ControllerBase
|
||||
if (!config.EnableFolderRoles)
|
||||
{
|
||||
folders = new List<string>(config.EnabledFolders);
|
||||
} else {
|
||||
}
|
||||
else
|
||||
{
|
||||
folders = new List<string>();
|
||||
}
|
||||
|
||||
foreach (string role in samlResponse.GetCustomAttributes("Role"))
|
||||
{
|
||||
foreach (string allowedRole in config.AdminRoles)
|
||||
@@ -382,15 +481,18 @@ public class SSOController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
if (config.EnableFolderRoles) {
|
||||
if (config.EnableFolderRoles)
|
||||
{
|
||||
foreach (FolderRoleMap folderRoleMap in config.FolderRoleMapping)
|
||||
{
|
||||
if (folderRoleMap.Role.Equals(role)) {
|
||||
if (folderRoleMap.Role.Equals(role))
|
||||
{
|
||||
folders.AddRange(folderRoleMap.Folders);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var authenticationResult = await Authenticate(samlResponse.GetNameID(), isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), response)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -409,6 +511,12 @@ public class SSOController : ControllerBase
|
||||
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 = "RequiresElevation")]
|
||||
[HttpPost("Unregister/{username}")]
|
||||
public ActionResult Unregister(string username, [FromBody] string provider)
|
||||
@@ -419,6 +527,15 @@ public class SSOController : ControllerBase
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates the user with the given information.
|
||||
/// </summary>
|
||||
/// <param name="username">The username 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="authResponse">The client information to authenticate the user with.</param>
|
||||
private async Task<AuthenticationResult> Authenticate(string username, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse)
|
||||
{
|
||||
User user = null;
|
||||
@@ -429,8 +546,10 @@ public class SSOController : ControllerBase
|
||||
_logger.LogInformation("SSO user doesn't exist, creating...");
|
||||
user = await _userManager.CreateUserAsync(username).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
user.AuthenticationProviderId = GetType().FullName;
|
||||
if (enableAuthorization) {
|
||||
if (enableAuthorization)
|
||||
{
|
||||
user.SetPermission(PermissionKind.IsAdministrator, isAdmin);
|
||||
user.SetPermission(PermissionKind.EnableAllFolders, enableAllFolders);
|
||||
if (!enableAllFolders)
|
||||
@@ -470,23 +589,52 @@ public class SSOController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Gets or sets the provider to check data against.
|
||||
/// </summary>
|
||||
public string Provider { 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;
|
||||
@@ -495,17 +643,33 @@ public class TimedAuthorizeState
|
||||
Admin = 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; }
|
||||
|
||||
public string Email { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the folders the user is allowed access to.
|
||||
/// </summary>
|
||||
public List<string> Folders { get; set; }
|
||||
}
|
||||
|
||||
@@ -17,38 +17,80 @@ public class PluginConfiguration : MediaBrowser.Model.Plugins.BasePluginConfigur
|
||||
OIDConfigs = new List<OIDConfig>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SAML configurations available.
|
||||
/// </summary>
|
||||
[XmlArray("SamlConfigs")]
|
||||
[XmlArrayItem(typeof(SamlConfig), ElementName = "SamlConfigs")]
|
||||
public List<SamlConfig> SamlConfigs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OpenID configurations available.
|
||||
/// </summary>
|
||||
[XmlArray("OIDConfigs")]
|
||||
[XmlArrayItem(typeof(OIDConfig), ElementName = "OIDConfigs")]
|
||||
public List<OIDConfig> OIDConfigs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The configuration required for a SAML flow.
|
||||
/// </summary>
|
||||
[XmlRoot("PluginConfiguration")]
|
||||
public class SamlConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the SAML information endpoint.
|
||||
/// </summary>
|
||||
public string SamlEndpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SAML provider's client ID.
|
||||
/// </summary>
|
||||
public string SamlClientId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SAML public key.
|
||||
/// </summary>
|
||||
public string SamlCertificate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the provider is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether RBAC is enabled.
|
||||
/// </summary>
|
||||
public bool EnableAuthorization { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether all folders are allowed by default.
|
||||
/// </summary>
|
||||
public bool EnableAllFolders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets what folders should users have access to by default.
|
||||
/// </summary>
|
||||
public string[] EnabledFolders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the roles that are checked to determine whether the user is an administrator.
|
||||
/// </summary>
|
||||
public string[] AdminRoles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets what roles are checked to determine whether the user is allowed to use Jellyfin.
|
||||
/// </summary>
|
||||
public string[] Roles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether RBAC is used to manage folder access.
|
||||
/// </summary>
|
||||
public bool EnableFolderRoles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets which folders map to what roles in RBAC.
|
||||
/// </summary>
|
||||
[XmlArray("FolderRoleMappings")]
|
||||
[XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMappings")]
|
||||
public List<FolderRoleMap> FolderRoleMapping { get; set; }
|
||||
@@ -56,41 +98,89 @@ public class SamlConfig
|
||||
public string DefaultProvider { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The configuration required for a OpenID flow.
|
||||
/// </summary>
|
||||
[XmlRoot("PluginConfiguration")]
|
||||
public class OIDConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the OpenID well-known information endpoint.
|
||||
/// </summary>
|
||||
public string OIDEndpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets OpenID client ID.
|
||||
/// </summary>
|
||||
public string OIDClientId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets OpenID shared secret.
|
||||
/// </summary>
|
||||
public string OIDSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the provider is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether RBAC is enabled.
|
||||
/// </summary>
|
||||
public bool EnableAuthorization { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether all folders are allowed by default.
|
||||
/// </summary>
|
||||
public bool EnableAllFolders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets what folders should users have access to by default.
|
||||
/// </summary>
|
||||
public string[] EnabledFolders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the roles that are checked to determine whether the user is an administrator.
|
||||
/// </summary>
|
||||
public string[] AdminRoles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets what roles are checked to determine whether the user is allowed to use Jellyfin.
|
||||
/// </summary>
|
||||
public string[] Roles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether RBAC is used to manage folder access.
|
||||
/// </summary>
|
||||
public bool EnableFolderRoles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets which folders map to what roles in RBAC.
|
||||
/// </summary>
|
||||
[XmlArray("FolderRoleMappings")]
|
||||
[XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMap")]
|
||||
[XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMappings")]
|
||||
public List<FolderRoleMap> FolderRoleMapping { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the claim to check roles against. Separated by "."s.
|
||||
/// </summary>
|
||||
public string RoleClaim { get; set; }
|
||||
|
||||
public string DefaultProvider { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The OpenID client ID.
|
||||
/// </summary>
|
||||
public class FolderRoleMap
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the role of the mapping.
|
||||
/// </summary>
|
||||
public string Role { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the folders that are allowed from the given role.
|
||||
/// </summary>
|
||||
public List<string> Folders { get; set; }
|
||||
}
|
||||
|
||||
@@ -8,20 +8,41 @@ using MediaBrowser.Model.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SSO_Auth;
|
||||
|
||||
/// <summary>
|
||||
/// The SSO plugin class.
|
||||
/// </summary>
|
||||
public class SSOPlugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SSOPlugin"/> class.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">Internal Jellyfin interface for the ApplicationPath.</param>
|
||||
/// <param name="xmlSerializer">Internal Jellyfin interface for the XML information.</param>
|
||||
public SSOPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the instance of the SSO plugin.
|
||||
/// </summary>
|
||||
public static SSOPlugin Instance { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the SSO plugin.
|
||||
/// </summary>
|
||||
public override string Name => "SSO-Auth";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the GUID of the SSO plugin.
|
||||
/// </summary>
|
||||
public override Guid Id => Guid.Parse("505ce9d1-d916-42fa-86ca-673ef241d7df");
|
||||
|
||||
/// <summary>
|
||||
/// Returns the available internal web pages of this plugin.
|
||||
/// </summary>
|
||||
/// <returns>A list of internal webpages in this application.</returns>
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
yield return new PluginPageInfo
|
||||
|
||||
@@ -18,33 +18,61 @@ using System.Xml;
|
||||
|
||||
namespace Jellyfin.Plugin.SSO_Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SAML response.
|
||||
/// </summary>
|
||||
public class Response
|
||||
{
|
||||
private readonly X509Certificate2 _certificate;
|
||||
private XmlDocument _xmlDoc;
|
||||
private XmlNamespaceManager _xmlNameSpaceManager; // we need this one to run our XPath queries on the SAML XML
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Response"/> class.
|
||||
/// </summary>
|
||||
/// <param name="certificateStr">The certificate formatted as a Base64 string.</param>
|
||||
/// <param name="responseString">The SAML response formatted as a string.</param>
|
||||
public Response(string certificateStr, string responseString)
|
||||
: this(Convert.FromBase64String(certificateStr), responseString)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Response"/> class.
|
||||
/// </summary>
|
||||
/// <param name="certificateBytes">The certificate formatted as an array of bytes.</param>
|
||||
/// <param name="responseString">The SAML response formatted as a string.</param>
|
||||
public Response(byte[] certificateBytes, string responseString) : this(certificateBytes)
|
||||
{
|
||||
LoadXmlFromBase64(responseString);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Response"/> class.
|
||||
/// </summary>
|
||||
/// <param name="certificateStr">The certificate formatted as a Base64 string.</param>
|
||||
public Response(string certificateStr) : this(Convert.FromBase64String(certificateStr))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Response"/> class.
|
||||
/// </summary>
|
||||
/// <param name="certificateBytes">The certificate formatted as an array of bytes.</param>
|
||||
public Response(byte[] certificateBytes)
|
||||
{
|
||||
_certificate = new X509Certificate2(certificateBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SAML response's XML data.
|
||||
/// </summary>
|
||||
public string Xml => _xmlDoc.OuterXml;
|
||||
|
||||
/// <summary>
|
||||
/// Loads XML from the parameter into the instance's XML data.
|
||||
/// </summary>
|
||||
/// <param name="xml">The XML string to put into the class.</param>
|
||||
public void LoadXml(string xml)
|
||||
{
|
||||
_xmlDoc = new XmlDocument();
|
||||
@@ -55,11 +83,19 @@ public class Response
|
||||
_xmlNameSpaceManager = GetNamespaceManager(); // lets construct a "manager" for XPath queries
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads Base64 encoded XML from the parameter into the instance's XML data.
|
||||
/// </summary>
|
||||
/// <param name="response">The Base64 encoded XML string to put into the class.</param>
|
||||
public void LoadXmlFromBase64(string response)
|
||||
{
|
||||
LoadXml(Encoding.UTF8.GetString(Convert.FromBase64String(response)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the XML response is valid by verifying the signature.
|
||||
/// </summary>
|
||||
/// <returns>Whether the XML response is valid.</returns>
|
||||
public bool IsValid()
|
||||
{
|
||||
var nodeList = _xmlDoc.SelectNodes("//ds:Signature", _xmlNameSpaceManager);
|
||||
@@ -118,17 +154,29 @@ public class Response
|
||||
return DateTime.UtcNow > expirationDate.ToUniversalTime();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name ID attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The name ID attribute.</returns>
|
||||
public string GetNameID()
|
||||
{
|
||||
var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:Subject/saml:NameID", _xmlNameSpaceManager);
|
||||
return node.InnerText;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the UPN attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The UPN attribute.</returns>
|
||||
public virtual string GetUpn()
|
||||
{
|
||||
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the email attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The email attribute.</returns>
|
||||
public virtual string GetEmail()
|
||||
{
|
||||
return GetCustomAttribute("User.email")
|
||||
@@ -138,6 +186,10 @@ public class Response
|
||||
?? GetCustomAttribute("mail");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the First Name attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The First Name attribute.</returns>
|
||||
public virtual string GetFirstName()
|
||||
{
|
||||
return GetCustomAttribute("first_name")
|
||||
@@ -148,6 +200,10 @@ public class Response
|
||||
?? GetCustomAttribute("givenName");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Last Name attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The Last Name attribute.</returns>
|
||||
public virtual string GetLastName()
|
||||
{
|
||||
return GetCustomAttribute("last_name")
|
||||
@@ -158,18 +214,30 @@ public class Response
|
||||
?? GetCustomAttribute("sn");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the department attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The department attribute.</returns>
|
||||
public virtual string GetDepartment()
|
||||
{
|
||||
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department")
|
||||
?? GetCustomAttribute("department");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the phone attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The phone attribute.</returns>
|
||||
public virtual string GetPhone()
|
||||
{
|
||||
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/homephone")
|
||||
?? GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/telephonenumber");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the company attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The company attribute.</returns>
|
||||
public virtual string GetCompany()
|
||||
{
|
||||
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/companyname")
|
||||
@@ -177,18 +245,32 @@ public class Response
|
||||
?? GetCustomAttribute("User.CompanyName");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the location attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The location attribute.</returns>
|
||||
public virtual string GetLocation()
|
||||
{
|
||||
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/location")
|
||||
?? GetCustomAttribute("physicalDeliveryOfficeName");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the first custom attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <param name="attr">The custom attribute to query.</param>
|
||||
/// <returns>The custom attribute.</returns>
|
||||
public string GetCustomAttribute(string attr)
|
||||
{
|
||||
var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager);
|
||||
return node?.InnerText;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the values for a custom attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <param name="attr">The custom attribute to query.</param>
|
||||
/// <returns>The custom attributes.</returns>
|
||||
public List<string> GetCustomAttributes(string attr)
|
||||
{
|
||||
var node = _xmlDoc.SelectNodes("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager);
|
||||
@@ -197,6 +279,7 @@ public class Response
|
||||
{
|
||||
output.Add(item?.InnerText);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
@@ -213,6 +296,9 @@ public class Response
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SAML request.
|
||||
/// </summary>
|
||||
public class AuthRequest
|
||||
{
|
||||
private readonly string _id;
|
||||
@@ -221,6 +307,11 @@ public class AuthRequest
|
||||
private readonly string _issuer;
|
||||
private readonly string _assertionConsumerServiceUrl;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AuthRequest"/> class..
|
||||
/// </summary>
|
||||
/// <param name="issuer">The issuer of the SAML request.</param>
|
||||
/// <param name="assertionConsumerServiceUrl">The SAML assertion URL.</param>
|
||||
public AuthRequest(string issuer, string assertionConsumerServiceUrl)
|
||||
{
|
||||
_id = "_" + Guid.NewGuid().ToString();
|
||||
@@ -230,6 +321,9 @@ public class AuthRequest
|
||||
_assertionConsumerServiceUrl = assertionConsumerServiceUrl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The formatting of the AuthRequest.
|
||||
/// </summary>
|
||||
public enum AuthRequestFormat
|
||||
{
|
||||
/// <summary>
|
||||
@@ -238,6 +332,11 @@ public class AuthRequest
|
||||
Base64 = 1
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SAML request.
|
||||
/// </summary>
|
||||
/// <param name="format">The format the request should be returned in.</param>
|
||||
/// <returns>The request as a string, either Base64 or not, depending on the format parameter.</returns>
|
||||
public string GetRequest(AuthRequestFormat format)
|
||||
{
|
||||
using var sw = new StringWriter();
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
namespace Jellyfin.Plugin.SSO_Auth;
|
||||
|
||||
/// <summary>
|
||||
/// A helper class to return HTML for the client's auth flow.
|
||||
/// </summary>
|
||||
public static class WebResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The shared HTML between all of the responses.
|
||||
/// </summary>
|
||||
public static readonly string Base = @"<!DOCTYPE html>
|
||||
<html><head></head><body>
|
||||
<p>Logging in...</p>
|
||||
@@ -399,6 +405,14 @@ const sleep = (milliseconds) => {
|
||||
|
||||
";
|
||||
|
||||
/// <summary>
|
||||
/// A generator for the web response that incorporates the data from the server.
|
||||
/// </summary>
|
||||
/// <param name="data">The data of the auth flow. Is signed XML for SAML and a state ID for OpenID.</param>
|
||||
/// <param name="provider">The ID of the provider to callback to.</param>
|
||||
/// <param name="baseUrl">The base URL of the Jellyfin installation.</param>
|
||||
/// <param name="mode">The mode of the function; SAML or OID.</param>
|
||||
/// <returns>A string with the HTML to serve to the client.</returns>
|
||||
public static string Generator(string data, string provider, string baseUrl, string mode)
|
||||
{
|
||||
return Base + @"
|
||||
|
||||
Reference in New Issue
Block a user