mirror of
https://github.com/9p4/jellyfin-plugin-sso.git
synced 2026-09-19 13:12:19 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bab3ee1a4b | ||
|
|
dfc427e34b | ||
|
|
444d93b02a | ||
|
|
b188e4ddc3 | ||
|
|
4ade45e037 | ||
|
|
b2e3a7704a | ||
|
|
456df2346c | ||
|
|
330002282c | ||
|
|
2f828f1210 | ||
|
|
d05af0cfa9 | ||
|
|
a23b00f02f | ||
|
|
144a0223ca |
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user