Compare commits

...
9 changed files with 791 additions and 574 deletions
+24 -4
View File
@@ -10,6 +10,11 @@ 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!
## Tested Providers
- Google OpenID: Works, but usernames are all numeric
- Keycloak OpenID and SAML: Works
## Supported Protocols
- [OpenID](https://openid.net/what-is-openid/)
@@ -19,6 +24,10 @@ There is NO admin configuration! You must use the API to configure the program!
This is my first time writing C# so please take all of the code written here with a grain of salt. This program should be reasonably secure since it validates all information passed from the client with either a certificate or a secret internal state.
## Installing
Add the package repo [https://repo.saggis.com/jellyfin/manifest.json](https://repo.saggis.com/jellyfin/manifest.json) to your Jellyfin configuration. Then, install the package!
## 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`.
@@ -29,13 +38,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/auth/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.
@@ -54,7 +70,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/auth/reapls/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)
@@ -85,7 +101,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.
@@ -94,6 +110,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.
@@ -123,6 +141,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.
@@ -135,7 +155,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.
+384 -261
View File
@@ -1,327 +1,450 @@
using System;
using System.Net.Mime;
using System.Collections.Generic;
using MediaBrowser.Controller.Session;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.Authorization;
using IdentityModel.OidcClient;
using Jellyfin.Plugin.SSO_Auth.Config;
using Saml;
using MediaBrowser.Common;
using System.Net.Mime;
using System.Threading.Tasks;
using IdentityModel.Client;
using IdentityModel.OidcClient;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.SSO_Auth.Config;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Library;
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
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 ILogger<SSOController> _logger;
private static readonly IDictionary<string, TimedAuthorizeState> StateManager = new Dictionary<string, TimedAuthorizeState>();
/// <summary>
/// The sso api controller.
/// Initializes a new instance of the <see cref="SSOController"/> class.
/// </summary>
[ApiController]
[Route("[controller]")]
public class SSOController : ControllerBase
/// <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="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
public SSOController(ILogger<SSOController> logger, ISessionManager sessionManager, IUserManager userManager)
{
private readonly IApplicationHost _applicationHost;
private readonly ISessionManager _sessionManager;
private readonly ILogger<SSOController> _logger;
private static IDictionary<string, TimedAuthorizeState> _stateManager = new Dictionary<string, TimedAuthorizeState>();
_sessionManager = sessionManager;
_userManager = userManager;
_logger = logger;
_logger.LogInformation("SSO Controller initialized");
}
public SSOController(IApplicationHost appHost, ILoggerFactory loggerFactory, ISessionManager sessionManager)
[HttpGet("OID/r/{provider}")]
public ActionResult OIDPost(string provider)
{
// Actually a GET: https://github.com/IdentityModel/IdentityModel.OidcClient/issues/325
foreach (var config in SSOPlugin.Instance.Configuration.OIDConfigs)
{
_applicationHost = appHost;
_sessionManager = sessionManager;
_logger = loggerFactory.CreateLogger<SSOController>();
_logger.LogWarning("SSO Controller initialized");
}
[HttpPost("SAML/p/{provider}")]
public ActionResult SAMLPost(string provider)
{
foreach (SamlConfig config in SSOPlugin.Instance.Configuration.SamlConfigs)
if (config.OIDClientId == provider && config.Enabled)
{
if (config.SamlClientId == provider && config.Enabled)
var options = new OidcClientOptions
{
Saml.Response samlResponse = new Saml.Response(config.SamlCertificate, Request.Form["SAMLResponse"]);
return Content(WebResponse.SamlGenerator(xml: Convert.ToBase64String(System.Text.UTF8Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider), "text/html");
Authority = config.OIDEndpoint,
ClientId = config.OIDClientId,
ClientSecret = config.OIDSecret,
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 = StateManager[Request.Query["state"]].State;
var result = oidcClient.ProcessResponseAsync(Request.QueryString.Value, state).Result;
if (result.IsError)
{
return Content("Something went wrong...", MediaTypeNames.Text.Plain);
}
}
return Content("no active providers found"); // TODO: Return error code as well
}
[HttpGet("SAML/p/{provider}")]
public RedirectResult SAMLChallenge(string provider)
{
foreach (SamlConfig config in SSOPlugin.Instance.Configuration.SamlConfigs)
{
if (config.SamlClientId == provider && config.Enabled)
foreach (var claim in result.User.Claims)
{
var request = new AuthRequest(
config.SamlClientId,
"http://" + Request.Host.Value + "/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)
{
// Actually a GET: https://github.com/IdentityModel/IdentityModel.OidcClient/issues/325
foreach (OIDConfig config in SSOPlugin.Instance.Configuration.OIDConfigs)
{
if (config.OIDClientId == provider && config.Enabled)
{
var options = new OidcClientOptions
if (claim.Type == "preferred_username")
{
Authority = config.OIDEndpoint,
ClientId = config.OIDClientId,
ClientSecret = config.OIDSecret,
RedirectUri = "http://" + Request.Host.Value + "/sso/OID/r/" + provider,
Scope = "openid profile"
};
OidcClient oidcClient = new OidcClient(options);
var state = _stateManager[Request.Query["state"]].State;
var result = oidcClient.ProcessResponseAsync(Request.QueryString.Value, state).Result;
if (result.IsError)
{
return Content("Something went wrong...", "text/plain");
}
foreach (var claim in result.User.Claims)
{
_logger.LogWarning("{0}: {1}", claim.Type, claim.Value);
if (claim.Type == "preferred_username")
StateManager[Request.Query["state"]].Username = claim.Value;
if (config.Roles.Length == 0)
{
_stateManager[Request.Query["state"]].Valid = true;
_stateManager[Request.Query["state"]].Username = claim.Value;
return Content(WebResponse.OIDGenerator(data: Request.Query["state"], provider: provider), "text/html");
StateManager[Request.Query["state"]].Valid = true;
}
}
return Content("Does your OpenID provider not support the preferred_username value?", "text/plain");
}
}
return Content("no active providers found"); // TODO: Return error code as well
}
[HttpGet("OID/p/{provider}")]
public ActionResult OIDChallenge(string provider)
{
Invalidate();
foreach (OIDConfig config in SSOPlugin.Instance.Configuration.OIDConfigs)
{
if (config.OIDClientId == provider && config.Enabled)
{
var options = new OidcClientOptions
// Check if allowed to login based on realm roles
if (config.Roles.Length != 0)
{
Authority = config.OIDEndpoint,
ClientId = config.OIDClientId,
ClientSecret = config.OIDSecret,
RedirectUri = "http://" + Request.Host.Value + "/sso/OID/r/" + provider,
Scope = "openid profile"
};
OidcClient oidcClient = new OidcClient(options);
AuthorizeState state = oidcClient.PrepareLoginAsync().Result;
_stateManager.Add(state.State, new TimedAuthorizeState(state, DateTime.Now));
return Redirect(state.StartUrl);
}
}
throw new ArgumentException("Provider does not exist");
}
[Authorize(Policy = "RequiresElevation")]
[HttpPost("OID/Add")]
public void OIDAdd([FromBody] OIDConfig oidConfig)
{
var configuration = SSOPlugin.Instance.Configuration;
for (int i = 0; i < configuration.OIDConfigs.Count; i++)
{
if (configuration.OIDConfigs[i].OIDClientId.Equals(oidConfig.OIDClientId))
{
configuration.OIDConfigs.RemoveAt(i);
}
}
configuration.OIDConfigs.Add(oidConfig);
SSOPlugin.Instance.UpdateConfiguration(configuration);
}
[Authorize(Policy = "RequiresElevation")]
[HttpGet("OID/Del/{provider}")]
public void OIDDel(string provider)
{
var configuration = SSOPlugin.Instance.Configuration;
for (int i = 0; i < configuration.OIDConfigs.Count; i++)
{
if (configuration.OIDConfigs[i].OIDClientId.Equals(provider))
{
configuration.OIDConfigs.RemoveAt(i);
}
}
SSOPlugin.Instance.UpdateConfiguration(configuration);
}
[Authorize(Policy = "RequiresElevation")]
[HttpGet("OID/Get")]
public ActionResult OIDProviders()
{
return Ok(SSOPlugin.Instance.Configuration.OIDConfigs);
}
[Authorize(Policy = "RequiresElevation")]
[HttpGet("OID/States")]
public ActionResult OIDStates()
{
return Ok(_stateManager);
}
[HttpPost("OID/Auth")]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
public ActionResult OIDAuth([FromBody] AuthResponse response)
{
foreach (OIDConfig oidConfig in SSOPlugin.Instance.Configuration.OIDConfigs)
{
if (oidConfig.OIDClientId == response.Provider && oidConfig.Enabled)
{
foreach (KeyValuePair<string, TimedAuthorizeState> kvp in _stateManager)
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 (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid) {
AuthenticationResult authenticationResult = Authenticate(kvp.Value.Username, false, oidConfig.EnableAllFolders, oidConfig.EnabledFolders, response).Result;
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;
}
}
}
}
}
}
// 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
}
}
}
return Content("no active providers found"); // TODO: Return error code as well
}
[HttpGet("OID/p/{provider}")]
public async Task<ActionResult> OIDChallenge(string provider)
{
Invalidate();
foreach (var config in SSOPlugin.Instance.Configuration.OIDConfigs)
{
if (config.OIDClientId == provider && config.Enabled)
{
var options = new OidcClientOptions
{
Authority = config.OIDEndpoint,
ClientId = config.OIDClientId,
ClientSecret = config.OIDSecret,
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));
return Redirect(state.StartUrl);
}
}
throw new ArgumentException("Provider does not exist");
}
[Authorize(Policy = "RequiresElevation")]
[HttpPost("OID/Add")]
public void OIDAdd([FromBody] OIDConfig oidConfig)
{
var configuration = SSOPlugin.Instance.Configuration;
for (var i = 0; i < configuration.OIDConfigs.Count; i++)
{
if (configuration.OIDConfigs[i].OIDClientId.Equals(oidConfig.OIDClientId))
{
configuration.OIDConfigs.RemoveAt(i);
}
}
configuration.OIDConfigs.Add(oidConfig);
SSOPlugin.Instance.UpdateConfiguration(configuration);
}
[Authorize(Policy = "RequiresElevation")]
[HttpGet("OID/Del/{provider}")]
public void OIDDel(string provider)
{
var configuration = SSOPlugin.Instance.Configuration;
for (var i = 0; i < configuration.OIDConfigs.Count; i++)
{
if (configuration.OIDConfigs[i].OIDClientId.Equals(provider))
{
configuration.OIDConfigs.RemoveAt(i);
}
}
SSOPlugin.Instance.UpdateConfiguration(configuration);
}
[Authorize(Policy = "RequiresElevation")]
[HttpGet("OID/Get")]
public ActionResult OIDProviders()
{
return Ok(SSOPlugin.Instance.Configuration.OIDConfigs);
}
[Authorize(Policy = "RequiresElevation")]
[HttpGet("OID/States")]
public ActionResult OIDStates()
{
return Ok(StateManager);
}
[HttpPost("OID/Auth")]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult> OIDAuth([FromBody] AuthResponse response)
{
foreach (var oidConfig in SSOPlugin.Instance.Configuration.OIDConfigs)
{
if (oidConfig.OIDClientId == response.Provider && oidConfig.Enabled)
{
foreach (var kvp in StateManager)
{
if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid)
{
var authenticationResult = await Authenticate(kvp.Value.Username, kvp.Value.Admin, oidConfig.EnableAllFolders, oidConfig.EnabledFolders, response)
.ConfigureAwait(false);
return Ok(authenticationResult);
}
}
}
}
return Problem("Something went wrong");
}
[Authorize(Policy = "RequiresElevation")]
[HttpPost("SAML/Add")]
public void SamlAdd([FromBody] SamlConfig samlConfig)
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)
{
var configuration = SSOPlugin.Instance.Configuration;
for (int i = 0; i < configuration.SamlConfigs.Count; i++)
if (samlConfig.SamlClientId == provider && samlConfig.Enabled)
{
if (configuration.SamlConfigs[i].SamlClientId.Equals(samlConfig.SamlClientId))
var samlResponse = new Response(samlConfig.SamlCertificate, Request.Form["SAMLResponse"]);
if (samlConfig.Roles.Length == 0)
{
configuration.SamlConfigs.RemoveAt(i);
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");
}
configuration.SamlConfigs.Add(samlConfig);
SSOPlugin.Instance.UpdateConfiguration(configuration);
}
[Authorize(Policy = "RequiresElevation")]
[HttpGet("SAML/Del/{provider}")]
public void SamlDel(string provider)
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)
{
var configuration = SSOPlugin.Instance.Configuration;
for (int i = 0; i < configuration.SamlConfigs.Count; i++)
if (config.SamlClientId == provider && config.Enabled)
{
if (configuration.SamlConfigs[i].SamlClientId.Equals(provider))
{
configuration.SamlConfigs.RemoveAt(i);
}
var request = new AuthRequest(
config.SamlClientId,
GetRequestBase() + "/sso/SAML/p/" + provider);
return Redirect(request.GetRedirectUrl(config.SamlEndpoint));
}
SSOPlugin.Instance.UpdateConfiguration(configuration);
}
throw new ArgumentException("Provider does not exist");
}
[Authorize(Policy = "RequiresElevation")]
[HttpGet("SAML/Get")]
public ActionResult SamlProviders()
[Authorize(Policy = "RequiresElevation")]
[HttpPost("SAML/Add")]
public void SamlAdd([FromBody] SamlConfig samlConfig)
{
var configuration = SSOPlugin.Instance.Configuration;
for (var i = 0; i < configuration.SamlConfigs.Count; i++)
{
return Ok(SSOPlugin.Instance.Configuration.SamlConfigs);
}
[HttpPost("SAML/Auth")]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
public ActionResult SamlAuth([FromBody] AuthResponse response)
{
foreach (SamlConfig samlConfig in SSOPlugin.Instance.Configuration.SamlConfigs)
if (configuration.SamlConfigs[i].SamlClientId.Equals(samlConfig.SamlClientId))
{
if (samlConfig.SamlClientId == response.Provider && samlConfig.Enabled)
{
Saml.Response samlResponse = new Saml.Response(samlConfig.SamlCertificate, response.Data);
AuthenticationResult authenticationResult = Authenticate(samlResponse.GetNameID(), false, samlConfig.EnableAllFolders, samlConfig.EnabledFolders, response).Result;
return Ok(authenticationResult);
}
configuration.SamlConfigs.RemoveAt(i);
}
return Problem("Something went wrong");
}
private async Task<AuthenticationResult> Authenticate(string username, bool isAdmin, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse)
configuration.SamlConfigs.Add(samlConfig);
SSOPlugin.Instance.UpdateConfiguration(configuration);
}
[Authorize(Policy = "RequiresElevation")]
[HttpGet("SAML/Del/{provider}")]
public void SamlDel(string provider)
{
var configuration = SSOPlugin.Instance.Configuration;
for (var i = 0; i < configuration.SamlConfigs.Count; i++)
{
_logger.LogWarning("Authenticating");
var userManager = _applicationHost.Resolve<IUserManager>();
User user = null;
user = userManager.GetUserByName(username);
if (user == null)
if (configuration.SamlConfigs[i].SamlClientId.Equals(provider))
{
_logger.LogWarning("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);
configuration.SamlConfigs.RemoveAt(i);
}
AuthenticationRequest 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.LogWarning("Auth request created...");
return _sessionManager.AuthenticateDirect(authRequest).Result;
}
private void Invalidate()
SSOPlugin.Instance.UpdateConfiguration(configuration);
}
[Authorize(Policy = "RequiresElevation")]
[HttpGet("SAML/Get")]
public ActionResult SamlProviders()
{
return Ok(SSOPlugin.Instance.Configuration.SamlConfigs);
}
[HttpPost("SAML/Auth")]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult> SamlAuth([FromBody] AuthResponse response)
{
foreach (var samlConfig in SSOPlugin.Instance.Configuration.SamlConfigs)
{
foreach (KeyValuePair<string, TimedAuthorizeState> kvp in _stateManager)
if (samlConfig.SamlClientId == response.Provider && samlConfig.Enabled)
{
DateTime now = DateTime.Now;
if (now.Subtract(kvp.Value.Created).TotalMinutes > 1)
bool isAdmin = false;
var samlResponse = new Response(samlConfig.SamlCertificate, response.Data);
foreach (string role in samlResponse.GetCustomAttributes("Role"))
{
_stateManager.Remove(kvp.Key);
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);
}
}
return Problem("Something went wrong");
}
private async Task<AuthenticationResult> Authenticate(string username, bool isAdmin, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse)
{
User user = null;
user = _userManager.GetUserByName(username);
if (user == null)
{
_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);
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...");
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);
}
}
}
public class AuthResponse
private string GetRequestBase()
{
public string DeviceID { get; set; }
public string DeviceName { get; set; }
public string AppName { get; set; }
public string AppVersion { get; set; }
public string Data { get; set; }
public string Provider { get; set; }
}
public class TimedAuthorizeState
{
public TimedAuthorizeState(AuthorizeState state, DateTime created)
{
this.State = state;
this.Created = created;
this.Valid = false;
}
public AuthorizeState State { get; set; }
public DateTime Created { get; set; }
public bool Valid { get; set; }
public string Username { get; set; }
return Request.Scheme + "://" + Request.Host + Request.PathBase;
}
}
public class AuthResponse
{
public string DeviceID { get; set; }
public string DeviceName { get; set; }
public string AppName { get; set; }
public string AppVersion { get; set; }
public string Data { get; set; }
public string Provider { get; set; }
}
public class TimedAuthorizeState
{
public TimedAuthorizeState(AuthorizeState state, DateTime created)
{
State = state;
Created = created;
Valid = false;
Admin = false;
}
public AuthorizeState State { get; set; }
public DateTime Created { get; set; }
public bool Valid { get; set; }
public string Username { get; set; }
public bool Admin { get; set; }
public string Email { get; set; }
}
+53 -43
View File
@@ -1,57 +1,67 @@
using System.Collections.Generic;
using System.Xml.Serialization;
namespace Jellyfin.Plugin.SSO_Auth.Config {
namespace Jellyfin.Plugin.SSO_Auth.Config;
/// <summary>
/// Plugin Configuration.
/// </summary>
public class PluginConfiguration : MediaBrowser.Model.Plugins.BasePluginConfiguration
{
/// <summary>
/// Plugin Configuration.
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
/// </summary>
public class PluginConfiguration : MediaBrowser.Model.Plugins.BasePluginConfiguration {
/// <summary>
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
/// </summary>
public PluginConfiguration()
{
SamlConfigs = new List<SamlConfig>();
OIDConfigs = new List<OIDConfig>();
}
[XmlArray("SamlConfigs"), XmlArrayItem(typeof(SamlConfig), ElementName = "SamlConfigs")]
public List<SamlConfig> SamlConfigs { get; set; }
[XmlArray("OIDConfigs"), XmlArrayItem(typeof(OIDConfig), ElementName = "OIDConfigs")]
public List<OIDConfig> OIDConfigs { get; set; }
}
[XmlRoot("PluginConfiguration")]
public class SamlConfig
public PluginConfiguration()
{
public string SamlEndpoint { get; set; }
public string SamlClientId { get; set; }
public string SamlCertificate { get; set; }
public bool Enabled { get; set; }
public bool EnableAllFolders { get; set; }
public string[] EnabledFolders { get; set; }
SamlConfigs = new List<SamlConfig>();
OIDConfigs = new List<OIDConfig>();
}
[XmlRoot("PluginConfiguration")]
public class OIDConfig
{
public string OIDEndpoint { get; set; }
[XmlArray("SamlConfigs")]
[XmlArrayItem(typeof(SamlConfig), ElementName = "SamlConfigs")]
public List<SamlConfig> SamlConfigs { get; set; }
public string OIDClientId { get; set; }
[XmlArray("OIDConfigs")]
[XmlArrayItem(typeof(OIDConfig), ElementName = "OIDConfigs")]
public List<OIDConfig> OIDConfigs { get; set; }
}
public string OIDSecret { get; set; }
[XmlRoot("PluginConfiguration")]
public class SamlConfig
{
public string SamlEndpoint { get; set; }
public bool Enabled { get; set; }
public string SamlClientId { get; set; }
public bool EnableAllFolders { get; set; }
public string SamlCertificate { get; set; }
public string[] EnabledFolders { get; set; }
}
public bool Enabled { get; set; }
public bool EnableAllFolders { get; set; }
public string[] EnabledFolders { get; set; }
public string[] AdminRoles { get; set; }
public string[] Roles { get; set; }
}
[XmlRoot("PluginConfiguration")]
public class OIDConfig
{
public string OIDEndpoint { get; set; }
public string OIDClientId { get; set; }
public string OIDSecret { get; set; }
public bool Enabled { get; set; }
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>
+2 -1
View File
@@ -19,12 +19,13 @@
<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>
<ItemGroup>
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
</ItemGroup>
+15 -12
View File
@@ -6,10 +6,14 @@ using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
namespace Jellyfin.Plugin.SSO_Auth {
public class SSOPlugin : BasePlugin<PluginConfiguration>, IHasWebPages {
public SSOPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer) {
Instance = this;
namespace Jellyfin.Plugin.SSO_Auth;
public class SSOPlugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
public SSOPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
public static SSOPlugin Instance { get; private set; }
@@ -18,13 +22,12 @@ namespace Jellyfin.Plugin.SSO_Auth {
public override Guid Id => Guid.Parse("505ce9d1-d916-42fa-86ca-673ef241d7df");
public IEnumerable<PluginPageInfo> GetPages() {
return new[] {
new PluginPageInfo {
Name = Name,
EmbeddedResourcePath = $"{GetType().Namespace}.Config.configPage.html"
}
};
public IEnumerable<PluginPageInfo> GetPages()
{
yield return new PluginPageInfo
{
Name = Name,
EmbeddedResourcePath = $"{GetType().Namespace}.Config.configPage.html"
};
}
}
}
+271 -231
View File
@@ -1,273 +1,313 @@
/* 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/)
version 1.2.3
/*
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/)
version 1.2.3
*/
using System;
using System.Web;
using System.Collections.Generic;
using System.IO;
using System.Xml;
using System.IO.Compression;
using System.Security.Cryptography.X509Certificates;
using System.Security.Cryptography.Xml;
using System.IO.Compression;
using System.Text;
using System.Web;
using System.Xml;
namespace Saml
namespace Jellyfin.Plugin.SSO_Auth;
public class Response
{
public partial class Response
{
protected XmlDocument _xmlDoc;
protected readonly X509Certificate2 _certificate;
protected XmlNamespaceManager _xmlNameSpaceManager; //we need this one to run our XPath queries on the SAML XML
private readonly X509Certificate2 _certificate;
private XmlDocument _xmlDoc;
private XmlNamespaceManager _xmlNameSpaceManager; // we need this one to run our XPath queries on the SAML XML
public string Xml { get { return _xmlDoc.OuterXml; } }
public Response(string certificateStr, string responseString)
: this(Convert.FromBase64String(certificateStr), responseString)
{
}
public Response(string certificateStr, string responseString)
: this(Convert.FromBase64String(certificateStr), responseString) { }
public Response(byte[] certificateBytes, string responseString) : this(certificateBytes)
{
LoadXmlFromBase64(responseString);
}
public Response(byte[] certificateBytes, string responseString) : this(certificateBytes)
{
LoadXmlFromBase64(responseString);
}
public Response(string certificateStr) : this(Convert.FromBase64String(certificateStr))
{
}
public Response(string certificateStr) : this(Convert.FromBase64String(certificateStr)) { }
public Response(byte[] certificateBytes)
{
_certificate = new X509Certificate2(certificateBytes);
}
public Response(byte[] certificateBytes)
{
_certificate = new X509Certificate2(certificateBytes);
}
public string Xml => _xmlDoc.OuterXml;
public void LoadXml(string xml)
{
_xmlDoc = new XmlDocument();
_xmlDoc.PreserveWhitespace = true;
_xmlDoc.XmlResolver = null;
_xmlDoc.LoadXml(xml);
public void LoadXml(string xml)
{
_xmlDoc = new XmlDocument();
_xmlDoc.PreserveWhitespace = true;
_xmlDoc.XmlResolver = null;
_xmlDoc.LoadXml(xml);
_xmlNameSpaceManager = GetNamespaceManager(); //lets construct a "manager" for XPath queries
}
_xmlNameSpaceManager = GetNamespaceManager(); // lets construct a "manager" for XPath queries
}
public void LoadXmlFromBase64(string response)
{
LoadXml(System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(response)));
}
public void LoadXmlFromBase64(string response)
{
LoadXml(Encoding.UTF8.GetString(Convert.FromBase64String(response)));
}
public bool IsValid()
{
XmlNodeList nodeList = _xmlDoc.SelectNodes("//ds:Signature", _xmlNameSpaceManager);
public bool IsValid()
{
var nodeList = _xmlDoc.SelectNodes("//ds:Signature", _xmlNameSpaceManager);
SignedXml signedXml = new SignedXml(_xmlDoc);
var signedXml = new SignedXml(_xmlDoc);
if (nodeList.Count == 0) return false;
if (nodeList.Count == 0)
{
return false;
}
signedXml.LoadXml((XmlElement)nodeList[0]);
return ValidateSignatureReference(signedXml) && signedXml.CheckSignature(_certificate, true) && !IsExpired();
}
signedXml.LoadXml((XmlElement)nodeList[0]);
return ValidateSignatureReference(signedXml) && signedXml.CheckSignature(_certificate, true) && !IsExpired();
}
//an XML signature can "cover" not the whole document, but only a part of it
//.NET's built in "CheckSignature" does not cover this case, it will validate to true.
//We should check the signature reference, so it "references" the id of the root document element! If not - it's a hack
private bool ValidateSignatureReference(SignedXml signedXml)
{
if (signedXml.SignedInfo.References.Count != 1) //no ref at all
return false;
// an XML signature can "cover" not the whole document, but only a part of it
// .NET's built in "CheckSignature" does not cover this case, it will validate to true.
// We should check the signature reference, so it "references" the id of the root document element! If not - it's a hack
private bool ValidateSignatureReference(SignedXml signedXml)
{
if (signedXml.SignedInfo.References.Count != 1) // no ref at all
{
return false;
}
var reference = (Reference)signedXml.SignedInfo.References[0];
var id = reference.Uri.Substring(1);
var reference = (Reference)signedXml.SignedInfo.References[0];
var id = reference.Uri.Substring(1);
var idElement = signedXml.GetIdElement(_xmlDoc, id);
var idElement = signedXml.GetIdElement(_xmlDoc, id);
if (idElement == _xmlDoc.DocumentElement)
return true;
else //sometimes its not the "root" doc-element that is being signed, but the "assertion" element
{
var assertionNode = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion", _xmlNameSpaceManager) as XmlElement;
if (assertionNode != idElement)
return false;
}
if (idElement == _xmlDoc.DocumentElement)
{
return true;
}
else // sometimes its not the "root" doc-element that is being signed, but the "assertion" element
{
var assertionNode = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion", _xmlNameSpaceManager) as XmlElement;
if (assertionNode != idElement)
{
return false;
}
}
return true;
}
return true;
}
private bool IsExpired()
{
DateTime expirationDate = DateTime.MaxValue;
XmlNode node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:Subject/saml:SubjectConfirmation/saml:SubjectConfirmationData", _xmlNameSpaceManager);
if (node != null && node.Attributes["NotOnOrAfter"] != null)
{
DateTime.TryParse(node.Attributes["NotOnOrAfter"].Value, out expirationDate);
}
return DateTime.UtcNow > expirationDate.ToUniversalTime();
}
private bool IsExpired()
{
var expirationDate = DateTime.MaxValue;
var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:Subject/saml:SubjectConfirmation/saml:SubjectConfirmationData", _xmlNameSpaceManager);
if (node != null && node.Attributes["NotOnOrAfter"] != null)
{
DateTime.TryParse(node.Attributes["NotOnOrAfter"].Value, out expirationDate);
}
public string GetNameID()
{
XmlNode node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:Subject/saml:NameID", _xmlNameSpaceManager);
return node.InnerText;
}
return DateTime.UtcNow > expirationDate.ToUniversalTime();
}
public virtual string GetUpn()
{
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn");
}
public string GetNameID()
{
var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:Subject/saml:NameID", _xmlNameSpaceManager);
return node.InnerText;
}
public virtual string GetEmail()
{
return GetCustomAttribute("User.email")
?? GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress") //some providers (for example Azure AD) put last name into an attribute named "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
?? GetCustomAttribute("mail"); //some providers put last name into an attribute named "mail"
}
public virtual string GetUpn()
{
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn");
}
public virtual string GetFirstName()
{
return GetCustomAttribute("first_name")
?? GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname") //some providers (for example Azure AD) put last name into an attribute named "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"
?? GetCustomAttribute("User.FirstName")
?? GetCustomAttribute("givenName"); //some providers put last name into an attribute named "givenName"
}
public virtual string GetEmail()
{
return GetCustomAttribute("User.email")
// some providers (for example Azure AD) put last name into an attribute named "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
?? GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress")
// some providers put last name into an attribute named "mail"
?? GetCustomAttribute("mail");
}
public virtual string GetLastName()
{
return GetCustomAttribute("last_name")
?? GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname") //some providers (for example Azure AD) put last name into an attribute named "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"
?? GetCustomAttribute("User.LastName")
?? GetCustomAttribute("sn"); //some providers put last name into an attribute named "sn"
}
public virtual string GetFirstName()
{
return GetCustomAttribute("first_name")
// some providers (for example Azure AD) put last name into an attribute named "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"
?? GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname")
?? GetCustomAttribute("User.FirstName")
// some providers put last name into an attribute named "givenName"
?? GetCustomAttribute("givenName");
}
public virtual string GetDepartment()
{
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department")
?? GetCustomAttribute("department");
}
public virtual string GetLastName()
{
return GetCustomAttribute("last_name")
// some providers (for example Azure AD) put last name into an attribute named "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"
?? GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname")
?? GetCustomAttribute("User.LastName")
// some providers put last name into an attribute named "sn"
?? GetCustomAttribute("sn");
}
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");
}
public virtual string GetDepartment()
{
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department")
?? GetCustomAttribute("department");
}
public virtual string GetCompany()
{
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/companyname")
?? GetCustomAttribute("organization")
?? GetCustomAttribute("User.CompanyName");
}
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");
}
public virtual string GetLocation()
{
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/location")
?? GetCustomAttribute("physicalDeliveryOfficeName");
}
public virtual string GetCompany()
{
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/companyname")
?? GetCustomAttribute("organization")
?? GetCustomAttribute("User.CompanyName");
}
public string GetCustomAttribute(string attr)
{
XmlNode node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager);
return node == null ? null : node.InnerText;
}
public virtual string GetLocation()
{
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/location")
?? GetCustomAttribute("physicalDeliveryOfficeName");
}
//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()
{
XmlNamespaceManager manager = new XmlNamespaceManager(_xmlDoc.NameTable);
manager.AddNamespace("ds", SignedXml.XmlDsigNamespaceUrl);
manager.AddNamespace("saml", "urn:oasis:names:tc:SAML:2.0:assertion");
manager.AddNamespace("samlp", "urn:oasis:names:tc:SAML:2.0:protocol");
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;
}
return manager;
}
}
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;
}
public class AuthRequest
{
public string _id;
private string _issue_instant;
// 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()
{
var manager = new XmlNamespaceManager(_xmlDoc.NameTable);
manager.AddNamespace("ds", SignedXml.XmlDsigNamespaceUrl);
manager.AddNamespace("saml", "urn:oasis:names:tc:SAML:2.0:assertion");
manager.AddNamespace("samlp", "urn:oasis:names:tc:SAML:2.0:protocol");
private string _issuer;
private string _assertionConsumerServiceUrl;
public enum AuthRequestFormat
{
Base64 = 1
}
public AuthRequest(string issuer, string assertionConsumerServiceUrl)
{
_id = "_" + Guid.NewGuid().ToString();
_issue_instant = DateTime.Now.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture);
_issuer = issuer;
_assertionConsumerServiceUrl = assertionConsumerServiceUrl;
}
public string GetRequest(AuthRequestFormat format)
{
using (StringWriter sw = new StringWriter())
{
XmlWriterSettings xws = new XmlWriterSettings();
xws.OmitXmlDeclaration = true;
using (XmlWriter xw = XmlWriter.Create(sw, xws))
{
xw.WriteStartElement("samlp", "AuthnRequest", "urn:oasis:names:tc:SAML:2.0:protocol");
xw.WriteAttributeString("ID", _id);
xw.WriteAttributeString("Version", "2.0");
xw.WriteAttributeString("IssueInstant", _issue_instant);
xw.WriteAttributeString("ProtocolBinding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST");
xw.WriteAttributeString("AssertionConsumerServiceURL", _assertionConsumerServiceUrl);
xw.WriteStartElement("saml", "Issuer", "urn:oasis:names:tc:SAML:2.0:assertion");
xw.WriteString(_issuer);
xw.WriteEndElement();
xw.WriteStartElement("samlp", "NameIDPolicy", "urn:oasis:names:tc:SAML:2.0:protocol");
xw.WriteAttributeString("Format", "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified");
xw.WriteAttributeString("AllowCreate", "true");
xw.WriteEndElement();
/*xw.WriteStartElement("samlp", "RequestedAuthnContext", "urn:oasis:names:tc:SAML:2.0:protocol");
xw.WriteAttributeString("Comparison", "exact");
xw.WriteStartElement("saml", "AuthnContextClassRef", "urn:oasis:names:tc:SAML:2.0:assertion");
xw.WriteString("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport");
xw.WriteEndElement();
xw.WriteEndElement();*/
xw.WriteEndElement();
}
if (format == AuthRequestFormat.Base64)
{
//byte[] toEncodeAsBytes = System.Text.ASCIIEncoding.ASCII.GetBytes(sw.ToString());
//return System.Convert.ToBase64String(toEncodeAsBytes);
//https://stackoverflow.com/questions/25120025/acs75005-the-request-is-not-a-valid-saml2-protocol-message-is-showing-always%3C/a%3E
var memoryStream = new MemoryStream();
var writer = new StreamWriter(new DeflateStream(memoryStream, CompressionMode.Compress, true), new UTF8Encoding(false));
writer.Write(sw.ToString());
writer.Close();
string result = Convert.ToBase64String(memoryStream.GetBuffer(), 0, (int)memoryStream.Length, Base64FormattingOptions.None);
return result;
}
return null;
}
}
//returns the URL you should redirect your users to (i.e. your SAML-provider login URL with the Base64-ed request in the querystring
public string GetRedirectUrl(string samlEndpoint, string relayState = null)
{
var queryStringSeparator = samlEndpoint.Contains("?") ? "&" : "?";
var url = samlEndpoint + queryStringSeparator + "SAMLRequest=" + HttpUtility.UrlEncode(GetRequest(AuthRequestFormat.Base64));
if (!string.IsNullOrEmpty(relayState))
{
url += "&RelayState=" + HttpUtility.UrlEncode(relayState);
}
return url;
}
}
return manager;
}
}
public class AuthRequest
{
private readonly string _id;
private readonly string _issueInstant;
private readonly string _issuer;
private readonly string _assertionConsumerServiceUrl;
public AuthRequest(string issuer, string assertionConsumerServiceUrl)
{
_id = "_" + Guid.NewGuid().ToString();
_issueInstant = DateTime.Now.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture);
_issuer = issuer;
_assertionConsumerServiceUrl = assertionConsumerServiceUrl;
}
public enum AuthRequestFormat
{
/// <summary>
/// Base64 request.
/// </summary>
Base64 = 1
}
public string GetRequest(AuthRequestFormat format)
{
using var sw = new StringWriter();
var xws = new XmlWriterSettings();
xws.OmitXmlDeclaration = true;
using (var xw = XmlWriter.Create(sw, xws))
{
xw.WriteStartElement("samlp", "AuthnRequest", "urn:oasis:names:tc:SAML:2.0:protocol");
xw.WriteAttributeString("ID", _id);
xw.WriteAttributeString("Version", "2.0");
xw.WriteAttributeString("IssueInstant", _issueInstant);
xw.WriteAttributeString("ProtocolBinding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST");
xw.WriteAttributeString("AssertionConsumerServiceURL", _assertionConsumerServiceUrl);
xw.WriteStartElement("saml", "Issuer", "urn:oasis:names:tc:SAML:2.0:assertion");
xw.WriteString(_issuer);
xw.WriteEndElement();
xw.WriteStartElement("samlp", "NameIDPolicy", "urn:oasis:names:tc:SAML:2.0:protocol");
xw.WriteAttributeString("Format", "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified");
xw.WriteAttributeString("AllowCreate", "true");
xw.WriteEndElement();
/*
xw.WriteStartElement("samlp", "RequestedAuthnContext", "urn:oasis:names:tc:SAML:2.0:protocol");
xw.WriteAttributeString("Comparison", "exact");
xw.WriteStartElement("saml", "AuthnContextClassRef", "urn:oasis:names:tc:SAML:2.0:assertion");
xw.WriteString("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport");
xw.WriteEndElement();
xw.WriteEndElement();
*/
xw.WriteEndElement();
}
if (format == AuthRequestFormat.Base64)
{
// byte[] toEncodeAsBytes = System.Text.ASCIIEncoding.ASCII.GetBytes(sw.ToString());
// return System.Convert.ToBase64String(toEncodeAsBytes);
// https://stackoverflow.com/questions/25120025/acs75005-the-request-is-not-a-valid-saml2-protocol-message-is-showing-always%3C/a%3E
var memoryStream = new MemoryStream();
var writer = new StreamWriter(new DeflateStream(memoryStream, CompressionMode.Compress, true), new UTF8Encoding(false));
writer.Write(sw.ToString());
writer.Close();
var result = Convert.ToBase64String(memoryStream.GetBuffer(), 0, (int)memoryStream.Length, Base64FormattingOptions.None);
return result;
}
return null;
}
/// <summary>
/// Gets the the URL you should redirect your users to (i.e. your SAML-provider login URL with the Base64-ed request in the querystring.
/// </summary>
/// <param name="samlEndpoint">The SAML endpoint.</param>
/// <param name="relayState">The relay state.</param>
/// <returns>The redirect url.</returns>
public string GetRedirectUrl(string samlEndpoint, string relayState = null)
{
var queryStringSeparator = samlEndpoint.Contains('?') ? "&" : "?";
var url = samlEndpoint + queryStringSeparator + "SAMLRequest=" + HttpUtility.UrlEncode(GetRequest(AuthRequestFormat.Base64));
if (!string.IsNullOrEmpty(relayState))
{
url += "&RelayState=" + HttpUtility.UrlEncode(relayState);
}
return url;
}
}
+15 -14
View File
@@ -1,8 +1,8 @@
namespace Jellyfin.Plugin.SSO_Auth
namespace Jellyfin.Plugin.SSO_Auth;
public static class WebResponse
{
class WebResponse
{
public static string Base = @"<!DOCTYPE html>
public static readonly string Base = @"<!DOCTYPE html>
<html><head></head><body><script>
function isTv() {
// This is going to be really difficult to get right
@@ -390,9 +390,10 @@ function getDeviceName() {
}
";
public static string OIDGenerator(string data, string provider)
{
return Base + @"
public static string OIDGenerator(string data, string provider, string baseUrl)
{
return Base + @"
async function main() {
var data = '" + data + @"';
var deviceId = localStorage.getItem(""_deviceId2"");
@@ -403,7 +404,7 @@ async function main() {
var request = {'deviceID': deviceId, 'appName': appName, 'appVersion': appVersion, deviceName: 'deviceName', data: data, provider: '" + provider + @"'};
var url = '/sso/OID/Auth';
var url = '" + baseUrl + @"/sso/OID/Auth';
let response = await new Promise(resolve => {
var xhr = new XMLHttpRequest();
@@ -435,10 +436,11 @@ document.addEventListener('DOMContentLoaded', function () {
});
</script></body></html>";
}
public static string SamlGenerator(string xml, string provider)
{
return Base + @"
}
public static string SamlGenerator(string xml, string provider, string baseUrl)
{
return Base + @"
async function main() {
var xml = '" + xml + @"';
var deviceId = localStorage.getItem(""_deviceId2"");
@@ -449,7 +451,7 @@ async function main() {
var request = {'deviceID': deviceId, 'appName': appName, 'appVersion': appVersion, deviceName: 'deviceName', data: xml, provider: '" + provider + @"'};
var url = '/sso/SAML/Auth';
var url = '" + baseUrl + @"/sso/SAML/Auth';
let response = await new Promise(resolve => {
var xhr = new XMLHttpRequest();
@@ -481,6 +483,5 @@ document.addEventListener('DOMContentLoaded', function () {
});
</script></body></html>";
}
}
}
+6 -4
View File
@@ -1,12 +1,14 @@
name: "SSO Authentication"
guid: "505ce9d1-d916-42fa-86ca-673ef241d7df"
imageUrl: "https://raw.githubusercontent.com/sambhavsaggi/jellyfin-plugin-sso/main/img/logo.png"
imageUrl: "https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/main/img/logo.png"
version: "1"
targetAbi: "10.8.0.0"
framework: "net6.0"
owner: "sambhavsaggi"
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.
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/9p4/jellyfin-plugin-sso
category: "Authentication"
artifacts:
- "SSO-Auth.dll"