Compare commits

...
7 changed files with 223 additions and 69 deletions
+25 -7
View File
@@ -10,6 +10,13 @@ This is 100% alpha software! PRs are welcome to improve the code.
There is NO admin configuration! You must use the API to configure the program!
**This is for Jellyfin 10.8**
## Tested Providers
- Google OpenID: Works, but usernames are all numeric
- Keycloak OpenID and SAML: Works
## Supported Protocols
- [OpenID](https://openid.net/what-is-openid/)
@@ -25,7 +32,7 @@ Add the package repo [https://repo.saggis.com/jellyfin/manifest.json](https://re
## Building
This is built with .NET 6.0. Build with `dotnet publish .` for the debug release in the `SSO-Auth` directory. Copy over the `IdentityModel.OidcClient.dll` and the `SSO-Auth.dll` files in the `/bin/Debug/net6.0/publish` directory to a new folder in your Jellyfin configuration: `config/plugins/sso`.
This is built with .NET 6.0. Build with `dotnet publish .` for the debug release in the `SSO-Auth` directory. Copy over the `IdentityModel.OidcClient.dll`, the `IdentityModel.dll` and the `SSO-Auth.dll` files in the `/bin/Debug/net6.0/publish` directory to a new folder in your Jellyfin configuration: `config/plugins/sso`.
## Releasing
@@ -33,13 +40,20 @@ This plugin uses [JPRM](https://github.com/oddstr13/jellyfin-plugin-repository-m
Build the zipped plugin with `jprm --verbosity=debug plugin build .`.
## Roadmap
- [ ] Admin page
- [ ] Automated tests
- [x] Add role/claims support
- [ ] Use canonical usernames instead of preferred usernames
## Examples
### SAML
Example for adding a SAML configuration with the API using [curl](https://curl.se/):
`curl -v -X POST -H "Content-Type: application/json" -d '{"samlEndpoint": "https://keycloak.example.com/auth/realms/test/protocol/saml", "samlClientId": "jellyfin-saml", "samlCertificate": "Very long base64 encoded string here", "enabled": true, "enableAllFolders": true, "enabledFolders": ["folder1", "folder2"]}' "https://myjellyfin.example.com/sso/SAML/Add?api_key=API_KEY_HERE"`
`curl -v -X POST -H "Content-Type: application/json" -d '{"samlEndpoint": "https://keycloak.example.com/realms/test/protocol/saml", "samlClientId": "jellyfin-saml", "samlCertificate": "Very long base64 encoded string here", "enabled": true, "enableAllFolders": true, "enabledFolders": ["folder1", "folder2"], "adminRoles": [], "roles": []}' "https://myjellyfin.example.com/sso/SAML/Add?api_key=API_KEY_HERE"`
Make sure that the JSON is the same as the configuration you would like.
@@ -48,9 +62,9 @@ The SAML provider must have the following configuration (I am using Keycloak, an
- Sign Documents on
- Sign Assertions off
- Client Signature Required off
- Redirect URI: [https://myjellyfin.example.com/sso/OID/p/clientid](https://myjellyfin.example.com/sso/OID/p/clientid)
- Redirect URI: [https://myjellyfin.example.com/sso/SAML/p/clientid](https://myjellyfin.example.com/sso/OID/p/clientid)
- Base URL: [https://myjellyfin.example.com](https://myjellyfin.example.com)
- Master SAML processing URL: [https://myjellyfin.example.com/sso/saml/p/clientid](https://myjellyfin.example.com/sso/SAML/p/clientid)
- Master SAML processing URL: [https://myjellyfin.example.com/sso/SAML/p/clientid](https://myjellyfin.example.com/sso/SAML/p/clientid)
Make sure that `clientid` is replaced with the actual client ID!
@@ -58,7 +72,7 @@ Make sure that `clientid` is replaced with the actual client ID!
Example for adding an OpenID configuration with the API using [curl](https://curl.se/)
`curl -v -X POST -H "Content-Type: application/json" -d '{"oidEndpoint": "https://keycloak.example.com/auth/reapls/test", "oidClientId": "jellyfin-oid", "oidSecret": "short secret here", "enabled": true, "enableAllFolders": true, "enabledFolders": ["folder3", "folder4"]}' "https://myjellyfin.example.com/sso/OID/Add?api_key=API_KEY_HERE"`
`curl -v -X POST -H "Content-Type: application/json" -d '{"oidEndpoint": "https://keycloak.example.com/realms/test", "oidClientId": "jellyfin-oid", "oidSecret": "short secret here", "enabled": true, "enableAllFolders": true, "enabledFolders": ["folder3", "folder4"], "adminRoles": [], "roles": []}' "https://myjellyfin.example.com/sso/OID/Add?api_key=API_KEY_HERE"`
The OpenID provider must have the following configuration (again, I am using Keycloak)
@@ -89,7 +103,7 @@ The API is all done from a base URL of `/sso/`
#### Configuration
These all require authorization. Append an API key to the end of the request: `curl "http://myjellyfin.example.com/sso/SAML/Get?api_key=9c6e5fae4ae145669e6b7a3942f813b7"`
These all require authorization. Append an API key to the end of the request: `curl "http://myjellyfin.example.com/sso/SAML/Get?api_key=API_KEY_HERE"`
- POST `SAML/Add`: This adds a configuration for SAML. It accepts JSON with the following keys and format:
- `samlEndpoint`: string. The SAML endpoint.
@@ -98,6 +112,8 @@ These all require authorization. Append an API key to the end of the request: `c
- `enabled`: boolean. Determines if the provider is enabled or not.
- `enableAllFolders`: boolean. Determines if the client logging in is allowed access to all folders.
- `enabledFolders`: array of strings. If `enableAllFolders` is set to false, then this will be used to determine what folders the users who log in through this provider are allowed to use.
- `roles`: array of strings. This validates the SAML response against the `Role` attribute. If a user has any of these roles, then the user is authenticated. Leave blank to disable role checking.
- `adminRoles`: array of strings. This uses SAML response's `Role` attributes. 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).
- GET `SAML/Del/clientId`: This removes a configuration for SAML for a given client ID.
- GET `SAML/Get`: Lists the configurations currently available.
@@ -127,6 +143,8 @@ These all require authorization. Append an API key to the end of the request: `c
- `enabled`: boolean. Determines if the provider is enabled or not.
- `enableAllFolders`: boolean. Determines if the client logging in is allowed access to all folders.
- `enabledFolders`: array of strings. If `enableAllFolders` is set to false, then this will be used to determine what folders the users who log in through this provider are allowed to use.
- `roles`: array of strings. This validates the OpenID response against the `realm_access` claim. If a user has any of these roles, then the user is authenticated. Leave blank to disable role checking. This currently only works for Keycloak (to my knowledge).
- `adminRoles`: array of strings. This uses the OpenID response against the `realm_access` claim. 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).
- 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.
@@ -139,7 +157,7 @@ Furthermore, there is no functional admin page (yet). PRs for this are welcome.
There is also no logout callback. Logging out of Jellyfin will log you out of Jellyfin only, instead of the SSO provider as well.
This only supports Jellyfin on it's own domain (for now). This is because I'm using string concatenation for generating some URLs. A PR is welcome to patch this.
~~This only supports Jellyfin on it's own domain (for now). This is because I'm using string concatenation for generating some URLs. A PR is welcome to patch this.~~ Fixed in [PR #1](https://github.com/9p4/jellyfin-plugin-sso/pull/1).
**This only works on the web UI**. The user must open the Jellyfin web UI BEFORE using the SSO program to populate some values in the localStorage.
+144 -50
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Net.Mime;
using System.Threading.Tasks;
using IdentityModel.Client;
using IdentityModel.OidcClient;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
@@ -12,6 +13,7 @@ using MediaBrowser.Controller.Session;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Jellyfin.Plugin.SSO_Auth.Api;
@@ -41,39 +43,6 @@ public class SSOController : ControllerBase
_logger.LogInformation("SSO Controller initialized");
}
[HttpPost("SAML/p/{provider}")]
public ActionResult SAMLPost(string provider)
{
foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs)
{
if (config.SamlClientId == provider && config.Enabled)
{
var samlResponse = new Response(config.SamlCertificate, Request.Form["SAMLResponse"]);
return Content(WebResponse.SamlGenerator(xml: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html);
}
}
return Content("no active providers found"); // TODO: Return error code as well
}
[HttpGet("SAML/p/{provider}")]
public RedirectResult SAMLChallenge(string provider)
{
foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs)
{
if (config.SamlClientId == provider && config.Enabled)
{
var request = new AuthRequest(
config.SamlClientId,
GetRequestBase() + "/sso/SAML/p/" + provider);
return Redirect(request.GetRedirectUrl(config.SamlEndpoint));
}
}
throw new ArgumentException("Provider does not exist");
}
[HttpGet("OID/r/{provider}")]
public ActionResult OIDPost(string provider)
{
@@ -88,8 +57,9 @@ public class SSOController : ControllerBase
ClientId = config.OIDClientId,
ClientSecret = config.OIDSecret,
RedirectUri = GetRequestBase() + "/sso/OID/r/" + provider,
Scope = "openid profile"
Scope = "openid profile",
};
options.Policy.Discovery.ValidateEndpoints = false; // For Google and other providers with different endpoints
var oidcClient = new OidcClient(options);
var state = StateManager[Request.Query["state"]].State;
var result = oidcClient.ProcessResponseAsync(Request.QueryString.Value, state).Result;
@@ -100,16 +70,76 @@ public class SSOController : ControllerBase
foreach (var claim in result.User.Claims)
{
_logger.LogInformation("{0}: {1}", claim.Type, claim.Value);
if (claim.Type == "preferred_username")
{
StateManager[Request.Query["state"]].Valid = true;
StateManager[Request.Query["state"]].Username = claim.Value;
return Content(WebResponse.OIDGenerator(data: Request.Query["state"], provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html);
if (config.Roles.Length == 0)
{
StateManager[Request.Query["state"]].Valid = true;
}
}
// Check if allowed to login based on realm roles
if (config.Roles.Length != 0)
{
if (claim.Type == "realm_access") // This is specific to Keycloak. Don't use roles without Keycloak, I guess
{
List<string> roles = JsonConvert.DeserializeObject<IDictionary<string, List<string>>>(claim.Value)["roles"]; // Might need error handling here
foreach (string validRoles in config.Roles)
{
foreach (string role in roles)
{
if (role.Equals(validRoles))
{
StateManager[Request.Query["state"]].Valid = true;
}
}
}
}
}
// Check if admin
if (config.AdminRoles.Length != 0)
{
if (claim.Type == "realm_access") // This is specific to Keycloak. Don't use roles without Keycloak, I guess
{
List<string> roles = JsonConvert.DeserializeObject<IDictionary<string, List<string>>>(claim.Value)["roles"]; // Might need error handling here
foreach (string validAdminRoles in config.AdminRoles)
{
foreach (string role in roles)
{
if (role.Equals(validAdminRoles))
{
StateManager[Request.Query["state"]].Admin = true;
}
}
}
}
}
}
return Content("Does your OpenID provider not support the preferred_username value?", MediaTypeNames.Text.Plain);
// If the provider doesn't support preferred_username, then use sub
if (!StateManager[Request.Query["state"]].Valid)
{
foreach (var claim in result.User.Claims)
{
if (claim.Type == "sub")
{
StateManager[Request.Query["state"]].Username = claim.Value;
if (config.Roles.Length == 0)
{
StateManager[Request.Query["state"]].Valid = true;
}
}
}
}
if (StateManager[Request.Query["state"]].Valid)
{
return Content(WebResponse.OIDGenerator(data: Request.Query["state"], provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html);
}
else
{
return Content("Error. Check permissions."); // TODO: Return error code as well
}
}
}
@@ -132,6 +162,7 @@ public class SSOController : ControllerBase
RedirectUri = GetRequestBase() + "/sso/OID/r/" + provider,
Scope = "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));
@@ -202,7 +233,7 @@ public class SSOController : ControllerBase
{
if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid)
{
var authenticationResult = await Authenticate(kvp.Value.Username, false, oidConfig.EnableAllFolders, oidConfig.EnabledFolders, response)
var authenticationResult = await Authenticate(kvp.Value.Username, kvp.Value.Admin, oidConfig.EnableAllFolders, oidConfig.EnabledFolders, response)
.ConfigureAwait(false);
return Ok(authenticationResult);
}
@@ -213,6 +244,54 @@ public class SSOController : ControllerBase
return Problem("Something went wrong");
}
[HttpPost("SAML/p/{provider}")]
public ActionResult SAMLPost(string provider)
{
// I'm sure there's a better way than using nested for loops but eh whatever
foreach (var samlConfig in SSOPlugin.Instance.Configuration.SamlConfigs)
{
if (samlConfig.SamlClientId == provider && samlConfig.Enabled)
{
var samlResponse = new Response(samlConfig.SamlCertificate, Request.Form["SAMLResponse"]);
if (samlConfig.Roles.Length == 0)
{
return Content(WebResponse.SamlGenerator(xml: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html);
}
foreach (string role in samlResponse.GetCustomAttributes("Role"))
{
foreach (string allowedRole in samlConfig.Roles)
{
if (allowedRole.Equals(role))
{
return Content(WebResponse.SamlGenerator(xml: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html);
}
}
}
return Content("401 Forbidden");
}
}
return Content("no active providers found"); // TODO: Return error code as well
}
[HttpGet("SAML/p/{provider}")]
public RedirectResult SAMLChallenge(string provider)
{
foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs)
{
if (config.SamlClientId == provider && config.Enabled)
{
var request = new AuthRequest(
config.SamlClientId,
GetRequestBase() + "/sso/SAML/p/" + provider);
return Redirect(request.GetRedirectUrl(config.SamlEndpoint));
}
}
throw new ArgumentException("Provider does not exist");
}
[Authorize(Policy = "RequiresElevation")]
[HttpPost("SAML/Add")]
public void SamlAdd([FromBody] SamlConfig samlConfig)
@@ -262,8 +341,19 @@ public class SSOController : ControllerBase
{
if (samlConfig.SamlClientId == response.Provider && samlConfig.Enabled)
{
bool isAdmin = false;
var samlResponse = new Response(samlConfig.SamlCertificate, response.Data);
var authenticationResult = await Authenticate(samlResponse.GetNameID(), false, samlConfig.EnableAllFolders, samlConfig.EnabledFolders, response)
foreach (string role in samlResponse.GetCustomAttributes("Role"))
{
foreach (string allowedRole in samlConfig.AdminRoles)
{
if (allowedRole.Equals(role))
{
isAdmin = true;
}
}
}
var authenticationResult = await Authenticate(samlResponse.GetNameID(), isAdmin, samlConfig.EnableAllFolders, samlConfig.EnabledFolders, response)
.ConfigureAwait(false);
return Ok(authenticationResult);
}
@@ -274,7 +364,6 @@ public class SSOController : ControllerBase
private async Task<AuthenticationResult> Authenticate(string username, bool isAdmin, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse)
{
_logger.LogInformation("Authenticating");
User user = null;
user = _userManager.GetUserByName(username);
@@ -282,16 +371,16 @@ public class SSOController : ControllerBase
{
_logger.LogInformation("SSO user doesn't exist, creating...");
user = await _userManager.CreateUserAsync(username).ConfigureAwait(false);
user.AuthenticationProviderId = GetType().FullName;
user.SetPermission(PermissionKind.IsAdministrator, isAdmin);
user.SetPermission(PermissionKind.EnableAllFolders, enableAllFolders);
if (!enableAllFolders)
{
user.SetPreference(PreferenceKind.EnabledFolders, enabledFolders);
}
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
}
user.AuthenticationProviderId = GetType().FullName;
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;
@@ -344,6 +433,7 @@ public class TimedAuthorizeState
State = state;
Created = created;
Valid = false;
Admin = false;
}
public AuthorizeState State { get; set; }
@@ -353,4 +443,8 @@ public class TimedAuthorizeState
public bool Valid { get; set; }
public string Username { get; set; }
public bool Admin { get; set; }
public string Email { get; set; }
}
+8
View File
@@ -40,6 +40,10 @@ public class SamlConfig
public bool EnableAllFolders { get; set; }
public string[] EnabledFolders { get; set; }
public string[] AdminRoles { get; set; }
public string[] Roles { get; set; }
}
[XmlRoot("PluginConfiguration")]
@@ -56,4 +60,8 @@ public class OIDConfig
public bool EnableAllFolders { get; set; }
public string[] EnabledFolders { get; set; }
public string[] AdminRoles { get; set; }
public string[] Roles { get; set; }
}
+21 -4
View File
@@ -11,10 +11,15 @@
<div class="verticalSection verticalSection-extrabottompadding">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">SSO Settings:</h2>
<a is="emby-button" class="raised button-alt headerHelpButton" target="_blank" href="https://github.com/sambhavsaggi/jellyfin-plugin-sso">${Help}</a>
<a is="emby-button" class="raised button-alt headerHelpButton" target="_blank" href="https://github.com/9p4/jellyfin-plugin-sso">${Help}</a>
</div>
<p><i>Note:</i> Making changes to this configuration requires a restart of Jellyfin.</p>
<div class="verticalSection" is="emby-collapse" title="SSO Server Settings">
<button id="newSaml" is="emby-button">
<span>Add new SAML provider</span>
</button>
<button id="newOID" is="emby-button">
<span>Add new OpenID provider</span>
</button>
<div class="collapseContent" id="default">
<div class="samlProviderWrapper">
<div class="samlProvider">
@@ -37,7 +42,7 @@
</div>
<button id="btnSaveSettings" is="emby-button" type="submit" value="submit" class="raised button block">
<span>Save SSO Settings</span>
<span>Update Provider</span>
</button>
</div>
</div>
@@ -50,7 +55,6 @@
<script type="text/javascript">
var SSOConfigurationPage = {
pluginUniqueId: "505ce9d1-d916-42fa-86ca-673ef241d7df",
samlProviderWrapper: document.querySelector("#samlProviderWrapper")
};
document.querySelector('.esqConfigurationPage').addEventListener("pageshow", function () {
@@ -80,6 +84,19 @@
// Disable default form submission
return false;
});
var newSaml = document.getElementById("newSaml");
newSaml.addEventListener("click", function(e) {
e.preventDefault();
Dashboard.showLoadingMsg();
fetch(window.ApiClient.getUrl("sso/SAML/Get?api_key=" + window.ApiClient.accessToken()))
.then(response => {
if (!response.ok) {
throw new Error("HTTP error " + response.status);
}
return response.json();
}).then(json => {console.log(json)});
});
</script>
</div>
</body>
+3 -2
View File
@@ -3,8 +3,8 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.SSO_Auth</RootNamespace>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<AssemblyVersion>2.0.1.0</AssemblyVersion>
<FileVersion>2.0.1.0</FileVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
@@ -19,6 +19,7 @@
<PackageReference Include="Jellyfin.Controller" Version="10.*-*" />
<PackageReference Include="Jellyfin.Model" Version="10.*-*" />
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="6.0.0" />
</ItemGroup>
+13 -1
View File
@@ -1,5 +1,5 @@
/*
Jitbit's simple SAML 2.0 component for ASP.NET
Was Jitbit's simple SAML 2.0 component for ASP.NET
https://github.com/jitbit/AspNetSaml/
(c) Jitbit LP, 2016
Use this freely under the Apache license (see https://choosealicense.com/licenses/apache-2.0/)
@@ -7,6 +7,7 @@
*/
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Security.Cryptography.X509Certificates;
@@ -188,6 +189,17 @@ public class Response
return node?.InnerText;
}
public List<string> GetCustomAttributes(string attr)
{
var node = _xmlDoc.SelectNodes("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager);
List<string> output = new List<string>();
foreach (XmlNode item in node)
{
output.Add(item?.InnerText);
}
return output;
}
// returns namespace manager, we need one b/c MS says so... Otherwise XPath doesnt work in an XML doc with namespaces
// see https://stackoverflow.com/questions/7178111/why-is-xmlnamespacemanager-necessary
private XmlNamespaceManager GetNamespaceManager()
+9 -5
View File
@@ -1,16 +1,20 @@
name: "SSO Authentication"
guid: "505ce9d1-d916-42fa-86ca-673ef241d7df"
imageUrl: "https://raw.githubusercontent.com/sambhavsaggi/jellyfin-plugin-sso/main/img/logo.png"
version: "1"
imageUrl: "https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/main/img/logo.png"
version: "2.0.1.0"
targetAbi: "10.8.0.0"
framework: "net6.0"
owner: "sambhavsaggi"
owner: "9p4"
overview: "Authenticate users against an SSO provider."
description: |
This plugin allows users to sign in through an SSO provider (such as Google, Facebook, or your own provider). This enables one-click signin.
Review documentation at https://github.com/sambhavsaggi/jellyfin-plugin-sso
Review documentation at https://github.com/9p4/jellyfin-plugin-sso
category: "Authentication"
artifacts:
- "SSO-Auth.dll"
- "IdentityModel.OidcClient.dll"
changelog: "2022-01-16: Initial Release"
- "IdentityModel.dll"
changelog: |
2.0.1.0: Fix improper artifact loading
2.0.0.0: Add RBAC and Google support
1.0.0.0: Initial Release