Compare commits

...

32 Commits

Author SHA1 Message Date
Sambhav Saggi 54586640fb 3.1.0.1 release 2022-02-18 14:31:02 -05:00
Sambhav Saggi ee26556ddf Fix #7 2022-02-18 14:29:17 -05:00
Sambhav Saggi f19cd74c19 3.1.0.0 release 2022-02-18 13:43:32 -05:00
9p4 8700686e2b Remove Facebook since they don't support OIDC
Facebook only officially supports oauth2, not OpenID. Therefore, they are not officially compatible with this plugin.
2022-02-18 13:11:20 -05:00
9p4 9c8f6c64c7 Merge pull request #6 from Pfuenzle/patch-1
Fixed DeviceName and inconsistent SAML and OID behaviour
2022-02-18 13:07:13 -05:00
Sambhav Saggi 075b4fb69f Consolidate responses and improve localstorage checks 2022-02-18 13:02:04 -05:00
Sambhav Saggi 09bbe3aa47 Finalize client login flow via WebResponse 2022-02-18 12:52:01 -05:00
Sambhav Saggi aa135cdc65 Clean up and simplify JS in WebResponse 2022-02-18 12:38:55 -05:00
Pfuenzle dae0043192 Fixed DeviceName
Replaced the string "deviceName" with the correct variable for obvious reasons.
2022-02-17 15:09:55 +01:00
Sambhav Saggi 32e009969b Wait until iFrame is loaded properly (all browsers) 2022-02-16 22:40:40 -05:00
Sambhav Saggi 9cb3057c90 Update README to reflect proper Keycloak defaults 2022-02-16 21:49:55 -05:00
Sambhav Saggi d5315a5263 Clarify config values, add iframe for localstorage-less login 2022-02-16 19:16:53 -05:00
Sambhav Saggi e9273ca82c Add error message when nojs or localstorage isn't populated 2022-02-15 21:38:39 -05:00
Sambhav Saggi e13c403f95 3.0.0.0 release 2022-02-15 21:01:04 -05:00
Sambhav Saggi 387fa5c67b Add option to remove user from the SSO login 2022-02-15 20:52:54 -05:00
Sambhav Saggi 7f69d4481e Update README 2022-02-15 20:23:40 -05:00
Sambhav Saggi 052e78b44d Add more RBAC 2022-02-15 20:20:15 -05:00
Sambhav Saggi fc22b72f43 Fix improper artifact loading in plugin release + docs 2022-02-14 17:30:41 -05:00
Sambhav Saggi 8ca36794e3 Documentation corrections 2022-02-14 17:03:14 -05:00
Sambhav Saggi 54f9251f19 Release + docs 2022-01-23 17:03:22 -05:00
Sambhav Saggi bab3ee1a4b Add list of supported providers 2022-01-23 16:45:37 -05:00
Sambhav Saggi dfc427e34b Add Google support, sub fallback 2022-01-23 16:43:21 -05:00
Sambhav Saggi 444d93b02a Only Keycloak so far... 2022-01-23 13:36:17 -05:00
Sambhav Saggi b188e4ddc3 Add roles + claim support. Update admin page. 2022-01-23 13:31:47 -05:00
Sambhav Saggi 4ade45e037 Begin admin page, update repo path 2022-01-23 01:26:27 -05:00
Sambhav Saggi b2e3a7704a Update README for PR and roadmap 2022-01-17 13:30:27 -05:00
Sambhav Saggi 456df2346c Merge branch 'crobibero-clean' 2022-01-17 13:25:53 -05:00
Sambhav Saggi 330002282c Merge branch 'clean' of https://github.com/crobibero/jellyfin-plugin-sso into crobibero-clean 2022-01-17 13:25:43 -05:00
Sambhav Saggi 2f828f1210 Base URL patch 2022-01-17 13:23:50 -05:00
Cody Robibero d05af0cfa9 Remove all warnings
Signed-off-by: Cody Robibero <cody@robibe.ro>
2022-01-17 08:12:31 -07:00
Sambhav Saggi a23b00f02f Update README.md 2022-01-17 00:05:30 -05:00
Sambhav Saggi 144a0223ca Update build to include link to repo 2022-01-16 23:55:06 -05:00
9 changed files with 886 additions and 694 deletions
+49 -9
View File
@@ -1,6 +1,6 @@
# Jellyfin SSO Plugin
This plugin allows users to sign in through an SSO provider (such as Google, Facebook, or your own provider). This enables one-click signin.
This plugin allows users to sign in through an SSO provider (such as Google, Microsoft, or your own provider). This enables one-click signin.
https://user-images.githubusercontent.com/17993169/149681516-f93b43f5-fa5c-4c1f-a909-e5414878a864.mp4
@@ -10,6 +10,15 @@ This is 100% alpha software! PRs are welcome to improve the code.
There is NO admin configuration! You must use the API to configure the program!
**This is for Jellyfin 10.8**
**This README reflects the __main__ branch! Switch tags to view version-specific documentation!**
## 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,9 +28,13 @@ 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`.
This is built with .NET 6.0. Build with `dotnet publish .` for the debug release in the `SSO-Auth` directory. Copy over the `IdentityModel.OidcClient.dll`, the `IdentityModel.dll` and the `SSO-Auth.dll` files in the `/bin/Debug/net6.0/publish` directory to a new folder in your Jellyfin configuration: `config/plugins/sso`.
## Releasing
@@ -29,13 +42,21 @@ 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
- [ ] Finalize RBAC access for all user properties
## Examples
### SAML
Example for adding a SAML configuration with the API using [curl](https://curl.se/):
`curl -v -X POST -H "Content-Type: application/json" -d '{"samlEndpoint": "https://keycloak.example.com/auth/realms/test/protocol/saml", "samlClientId": "jellyfin-saml", "samlCertificate": "Very long base64 encoded string here", "enabled": true, "enableAllFolders": true, "enabledFolders": ["folder1", "folder2"]}' "https://myjellyfin.example.com/sso/SAML/Add?api_key=API_KEY_HERE"`
`curl -v -X POST -H "Content-Type: application/json" -d '{"samlEndpoint": "https://keycloak.example.com/realms/test/protocol/saml", "samlClientId": "jellyfin-saml", "samlCertificate": "Very long base64 encoded string here", "enabled": true, "enableAuthorization": true, "enableAllFolders": false, "enabledFolders": [], "adminRoles": ["jellyfin-admin"], "roles": ["allowed-to-use-jellyfin"], "enableFolderRoles": true, "folderRoleMapping": [{"role": "allowed-to-watch-movies", "folders": ["cc7df17e2f3509a4b5fc1d1ff0a6c4d0", "f137a2dd21bbc1b99aa5c0f6bf02a805"]}]}' "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.
@@ -44,9 +65,9 @@ The SAML provider must have the following configuration (I am using Keycloak, an
- Sign Documents on
- Sign Assertions off
- Client Signature Required off
- Redirect URI: [https://myjellyfin.example.com/sso/OID/p/clientid](https://myjellyfin.example.com/sso/OID/p/clientid)
- Redirect URI: [https://myjellyfin.example.com/sso/SAML/p/clientid](https://myjellyfin.example.com/sso/OID/p/clientid)
- Base URL: [https://myjellyfin.example.com](https://myjellyfin.example.com)
- Master SAML processing URL: [https://myjellyfin.example.com/sso/saml/p/clientid](https://myjellyfin.example.com/sso/SAML/p/clientid)
- Master SAML processing URL: [https://myjellyfin.example.com/sso/SAML/p/clientid](https://myjellyfin.example.com/sso/SAML/p/clientid)
Make sure that `clientid` is replaced with the actual client ID!
@@ -54,7 +75,7 @@ Make sure that `clientid` is replaced with the actual client ID!
Example for adding an OpenID configuration with the API using [curl](https://curl.se/)
`curl -v -X POST -H "Content-Type: application/json" -d '{"oidEndpoint": "https://keycloak.example.com/auth/reapls/test", "oidClientId": "jellyfin-oid", "oidSecret": "short secret here", "enabled": true, "enableAllFolders": true, "enabledFolders": ["folder3", "folder4"]}' "https://myjellyfin.example.com/sso/OID/Add?api_key=API_KEY_HERE"`
`curl -v -X POST -H "Content-Type: application/json" -d '{"oidEndpoint": "https://keycloak.example.com/realms/test", "oidClientId": "jellyfin-oid", "oidSecret": "short secret here", "enabled": true, "enableAuthorization": true, "enableAllFolders": false, "enabledFolders": [], "adminRoles": ["jellyfin-admin"], "roles": ["allowed-to-use-jellyfin"], "enableFolderRoles": true, "folderRoleMapping": [{"role": "allowed-to-watch-movies", "folders": ["cc7df17e2f3509a4b5fc1d1ff0a6c4d0", "f137a2dd21bbc1b99aa5c0f6bf02a805"]}], "roleClaim": "realm_access"}' "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,15 +106,20 @@ 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.
- `samlClientId`: string. The SAML client ID.
- `samlCertificate`: string. The base64 encoded SAML certificate.
- `enabled`: boolean. Determines if the provider is enabled or not.
- `enableAuthorization`: boolean: Determines if the plugin sets permissions for the user. If false, the user will start with no permissions and an administrator will add permissions. The permissions of existing users will not be rewritten on subsequent logins.
- `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).
- `enableFolderRoles`: boolean. Determines if role-based folder access should be used.
- `folderRoleMapping`: object in the format "role": string and "folders": array of strings. The user with this role will have access to the following folders if `enableFolderRoles` is enabled. To get the IDs of the folders, GET the `/Library/MediaFolders` URL with an API key. Look for the `Id` attribute.
- GET `SAML/Del/clientId`: This removes a configuration for SAML for a given client ID.
- GET `SAML/Get`: Lists the configurations currently available.
@@ -121,12 +147,22 @@ These all require authorization. Append an API key to the end of the request: `c
- `oidClientId`: string. The OpenID client ID.
- `oidSecret`: string. The OpenID secret.
- `enabled`: boolean. Determines if the provider is enabled or not.
- `enableAuthorization`: boolean: Determines if the plugin sets permissions for the user. If false, the user will start with no permissions and an administrator will add permissions. The permissions of existing users will not be rewritten on subsequent logins.
- `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 claim set in `roleClaim`. 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 claim set in `roleClaim`. If a user has any of these roles, then the user is an admin. Leave blank to disable (default is to not enable admin permissions).
- `enableFolderRoles`: boolean. Determines if role-based folder access should be used.
- `folderRoleMapping`: object in the format "role": string and "folders": array of strings. The user with this role will have access to the following folders if `enableFolderRoles` is enabled. To get the IDs of the folders, GET the `/Library/MediaFolders` URL with an API key. Look for the `Id` attribute.
- `roleClaim`: string. This is the value in the OpenID response to check for roles. For Keycloak, it is `realm_access` by default.
- 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.
### Misc
- POST `Unregister/username`: This "unregisters" a user from SSO. A JSON-formatted string must be posted with the new authentication provider. To reset to the default provider, use `Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider` like so: `curl -X POST -H "Content-Type: application/json" -d '"Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider"' "https://myjellyfin.example.com/sso/Unregister/username?api_key=API_KEY`
## Limitations
There is no GUI to sign in. You have to make it yourself! The buttons should redirect to something like this: [https://myjellyfin.example.com/sso/SAML/p/clientid](https://myjellyfin.example.com/sso/SAML/p/clientid) replacing `clientid` with the provider client ID and `SAML` with the auth scheme (either `SAML` or `OID`).
@@ -135,9 +171,9 @@ 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.
**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.~~ Fixed by implementing a comment by [Pfuenzle](https://github.com/Pfuenzle) in [Issue #5](https://github.com/9p4/jellyfin-plugin-sso/issues/5#issuecomment-1041864820).
## Credits and Thanks
@@ -148,3 +184,7 @@ I use the [AspNet SAML](https://github.com/jitbit/AspNetSaml/) library for the S
I use the [IdentityModel OIDC Client](https://github.com/IdentityModel/IdentityModel.OidcClient/) library for the OpenID side of things.
Thanks to these projects, without which I would have been pulling my hair out implementing these protocols from scratch.
## Something funny about the origins of this plugin
It totally slipped my mind, but I had [requested this functionality a few years back](https://github.com/jellyfin/jellyfin/issues/2012). What goes around comes around, I guess.
+421 -255
View File
@@ -1,327 +1,493 @@
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.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)
if (!config.EnableFolderRoles)
{
var request = new AuthRequest(
config.SamlClientId,
"http://" + Request.Host.Value + "/sso/SAML/p/" + provider
);
return Redirect(request.GetRedirectUrl(config.SamlEndpoint));
StateManager[Request.Query["state"]].Folders = new List<string>(config.EnabledFolders);
} else {
StateManager[Request.Query["state"]].Folders = new List<string>();
}
}
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)
foreach (var claim in result.User.Claims)
{
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
// Role processing
if (claim.Type == config.RoleClaim)
{
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);
List<string> roles = JsonConvert.DeserializeObject<IDictionary<string, List<string>>>(claim.Value)["roles"]; // Might need error handling here
foreach (string role in roles)
{
// Check if allowed to login based on roles
if (config.Roles.Length != 0)
{
foreach (string validRoles in config.Roles)
{
if (role.Equals(validRoles))
{
StateManager[Request.Query["state"]].Valid = true;
}
}
}
// Check if admin based on roles
if (config.AdminRoles.Length != 0)
{
foreach (string validAdminRoles in config.AdminRoles)
{
if (role.Equals(validAdminRoles))
{
StateManager[Request.Query["state"]].Admin = true;
}
}
}
// Get allowed folders from roles
if (config.EnableFolderRoles)
{
foreach (FolderRoleMap folderRoleMap in config.FolderRoleMapping)
{
if (role.Equals(folderRoleMap.Role))
{
StateManager[Request.Query["state"]].Folders.AddRange(folderRoleMap.Folders);
}
}
}
}
}
}
}
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))
// If the provider doesn't support preferred_username, then use sub
if (!StateManager[Request.Query["state"]].Valid)
{
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)
foreach (var claim in result.User.Claims)
{
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 == "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.Generator(data: Request.Query["state"], provider: provider, baseUrl: GetRequestBase(), mode: "OID"), MediaTypeNames.Text.Html);
}
else
{
return Content("Error. Check permissions."); // TODO: Return error code as well
}
}
}
return 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 config)
{
var configuration = SSOPlugin.Instance.Configuration;
for (var i = 0; i < configuration.OIDConfigs.Count; i++)
{
if (configuration.OIDConfigs[i].OIDClientId.Equals(config.OIDClientId))
{
configuration.OIDConfigs.RemoveAt(i);
}
}
configuration.OIDConfigs.Add(config);
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 config in SSOPlugin.Instance.Configuration.OIDConfigs)
{
if (config.OIDClientId == response.Provider && config.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, config.EnableAuthorization, config.EnableAllFolders, kvp.Value.Folders.ToArray(), 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 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(samlConfig.SamlClientId))
var samlResponse = new Response(config.SamlCertificate, Request.Form["SAMLResponse"]);
// If no roles are configured, don't use RBAC
if (config.Roles.Length == 0)
{
configuration.SamlConfigs.RemoveAt(i);
return Content(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML"), MediaTypeNames.Text.Html);
}
// Check if user is allowed to log in based on roles
foreach (string role in samlResponse.GetCustomAttributes("Role"))
{
foreach (string allowedRole in config.Roles)
{
if (allowedRole.Equals(role))
{
return Content(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML"), MediaTypeNames.Text.Html);
}
}
}
return Content("401 Forbidden"); // TODO: Return error code as well
}
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 config)
{
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(config.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(config);
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 config in SSOPlugin.Instance.Configuration.SamlConfigs)
{
foreach (KeyValuePair<string, TimedAuthorizeState> kvp in _stateManager)
if (config.SamlClientId == response.Provider && config.Enabled)
{
DateTime now = DateTime.Now;
if (now.Subtract(kvp.Value.Created).TotalMinutes > 1)
bool isAdmin = false;
var samlResponse = new Response(config.SamlCertificate, response.Data);
List<string> folders;
if (!config.EnableFolderRoles)
{
_stateManager.Remove(kvp.Key);
folders = new List<string>(config.EnabledFolders);
} else {
folders = new List<string>();
}
foreach (string role in samlResponse.GetCustomAttributes("Role"))
{
foreach (string allowedRole in config.AdminRoles)
{
if (allowedRole.Equals(role))
{
isAdmin = true;
}
}
if (config.EnableFolderRoles) {
foreach (FolderRoleMap folderRoleMap in config.FolderRoleMapping)
{
if (folderRoleMap.Role.Equals(role)) {
folders.AddRange(folderRoleMap.Folders);
}
}
}
}
var authenticationResult = await Authenticate(samlResponse.GetNameID(), isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), response)
.ConfigureAwait(false);
return Ok(authenticationResult);
}
}
return Problem("Something went wrong");
}
[Authorize(Policy = "RequiresElevation")]
[HttpPost("Unregister/{username}")]
public ActionResult Unregister(string username, [FromBody] string provider)
{
User user = _userManager.GetUserByName(username);
user.AuthenticationProviderId = provider;
return Ok();
}
private async Task<AuthenticationResult> Authenticate(string username, bool isAdmin, bool enableAuthorization, 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;
if (enableAuthorization) {
user.SetPermission(PermissionKind.IsAdministrator, isAdmin);
user.SetPermission(PermissionKind.EnableAllFolders, enableAllFolders);
if (!enableAllFolders)
{
user.SetPreference(PreferenceKind.EnabledFolders, enabledFolders);
}
}
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
var authRequest = new AuthenticationRequest();
authRequest.UserId = user.Id;
authRequest.Username = user.Username;
authRequest.App = authResponse.AppName;
authRequest.AppVersion = authResponse.AppVersion;
authRequest.DeviceId = authResponse.DeviceID;
authRequest.DeviceName = authResponse.DeviceName;
_logger.LogInformation("Auth request created...");
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; }
public List<string> Folders { get; set; }
}
+82 -47
View File
@@ -1,57 +1,92 @@
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; }
public string OIDSecret { get; set; }
public bool Enabled { get; set; }
public bool EnableAllFolders { get; set; }
public string[] EnabledFolders { get; set; }
}
[XmlArray("OIDConfigs")]
[XmlArrayItem(typeof(OIDConfig), ElementName = "OIDConfigs")]
public List<OIDConfig> OIDConfigs { get; set; }
}
[XmlRoot("PluginConfiguration")]
public class SamlConfig
{
public string SamlEndpoint { get; set; }
public string SamlClientId { get; set; }
public string SamlCertificate { get; set; }
public bool Enabled { get; set; }
public bool EnableAuthorization { get; set; }
public bool EnableAllFolders { get; set; }
public string[] EnabledFolders { get; set; }
public string[] AdminRoles { get; set; }
public string[] Roles { get; set; }
public bool EnableFolderRoles { get; set; }
[XmlArray("FolderRoleMappings")]
[XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMappings")]
public List<FolderRoleMap> FolderRoleMapping { 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 EnableAuthorization { get; set; }
public bool EnableAllFolders { get; set; }
public string[] EnabledFolders { get; set; }
public string[] AdminRoles { get; set; }
public string[] Roles { get; set; }
public bool EnableFolderRoles { get; set; }
[XmlArray("FolderRoleMappings")]
[XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMap")]
public List<FolderRoleMap> FolderRoleMapping { get; set; }
public string RoleClaim { get; set; }
}
public class FolderRoleMap
{
public string Role { get; set; }
public List<string> Folders { get; set; }
}
+1 -72
View File
@@ -7,80 +7,9 @@
<div data-role="page" class="page type-interior pluginConfigurationPage esqConfigurationPage">
<div data-role="content">
<div class="content-primary">
<form class="esqConfigurationForm">
<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>
</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">
<div class="collapseContent" id="default">
<div class="samlProviderWrapper">
<div class="samlProvider">
<div class="inputContainer">
<input is="emby-input" type="text" id="txtSamlEndpoint" required placeholder="https://saml-provider.example.com/login" label="SAML Endpoint" />
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtSamlClientId" required placeholder="myjellyfin.example.com" label="SAML Client ID" />
</div>
<div class="inputContainer">
<textarea is="emby-textarea" id="txtSamlCertificate" placeholder="Base64-encoded realm certificate" label="SAML Certificate"></textarea>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkSamlEnable" />
<span>Enable Provider</span>
</label>
</div>
</div>
</div>
<button id="btnSaveSettings" is="emby-button" type="submit" value="submit" class="raised button block">
<span>Save SSO Settings</span>
</button>
</div>
</div>
</div>
</form>
<a href="https://github.com/9p4/jellyfin-plugin-sso/blob/main/README.md">Review the documentation. This plugin is configured via the API.</a>
</div>
</div>
<script type="text/javascript">
var SSOConfigurationPage = {
pluginUniqueId: "505ce9d1-d916-42fa-86ca-673ef241d7df",
samlProviderWrapper: document.querySelector("#samlProviderWrapper")
};
document.querySelector('.esqConfigurationPage').addEventListener("pageshow", function () {
Dashboard.showLoadingMsg();
window.ApiClient.getPluginConfiguration(SSOConfigurationPage.pluginUniqueId).then(function (config) {
SSOConfigurationPage.txtSamlEndpoint.value = config.SamlEndpoint;
SSOConfigurationPage.txtSamlCertificate.value = config.SamlCertificate;
SSOConfigurationPage.txtSamlClientId.value = config.SamlClientId;
});
Dashboard.hideLoadingMsg();
});
var form = document.querySelector(".esqConfigurationForm");
form.addEventListener("submit", function(e){
e.preventDefault();
Dashboard.showLoadingMsg();
window.ApiClient.getPluginConfiguration(SSOConfigurationPage.pluginUniqueId).then(function (config) {
config.SamlCertificate = SSOConfigurationPage.txtSamlCertificate.value;
config.SamlEndpoint = SSOConfigurationPage.txtSamlEndpoint.value;
config.SamlClientId = SSOConfigurationPage.txtSamlClientId.value;
window.ApiClient.updatePluginConfiguration(SSOConfigurationPage.pluginUniqueId, config).then(Dashboard.processPluginConfigurationUpdateResult);
});
// Disable default form submission
return false;
});
</script>
</div>
</body>
</html>
+4 -3
View File
@@ -3,8 +3,8 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.SSO_Auth</RootNamespace>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<AssemblyVersion>3.1.0.1</AssemblyVersion>
<FileVersion>3.1.0.1</FileVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
@@ -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;
}
}
+28 -59
View File
@@ -1,9 +1,13 @@
namespace Jellyfin.Plugin.SSO_Auth
namespace Jellyfin.Plugin.SSO_Auth;
public static class WebResponse
{
class WebResponse
{
public static string Base = @"<!DOCTYPE html>
<html><head></head><body><script>
public static readonly string Base = @"<!DOCTYPE html>
<html><head></head><body>
<p>Logging in...</p>
<noscript>Please enable Javascript to complete the login</noscript>
<script>
function isTv() {
// This is going to be really difficult to get right
const userAgent = navigator.userAgent.toLowerCase();
@@ -389,21 +393,32 @@ function getDeviceName() {
return deviceName;
}
const sleep = (milliseconds) => {
return new Promise(resolve => setTimeout(resolve, milliseconds))
}
";
public static string OIDGenerator(string data, string provider)
{
return Base + @"
public static string Generator(string data, string provider, string baseUrl, string mode)
{
return Base + @"
async function main() {
var data = '" + data + @"';
while (localStorage.getItem(""_deviceId2"") == null ||
localStorage.getItem(""jellyfin_credentials"") == null ||
JSON.parse(localStorage.getItem(""jellyfin_credentials""))['Servers'][0]['Id'] == null) {
// If localStorage isn't initialized yet, try again.
await sleep(100);
}
var deviceId = localStorage.getItem(""_deviceId2"");
var appName = ""Jellyfin Web"";
var appVersion = ""10.8.0"";
var deviceName = getDeviceName();
var provider = '" + provider + @"';
var request = {'deviceID': deviceId, 'appName': appName, 'appVersion': appVersion, deviceName: 'deviceName', data: data, provider: '" + provider + @"'};
var request = {deviceId, appName, appVersion, deviceName, data, provider: '" + provider + @"'};
var url = '/sso/OID/Auth';
var url = '" + baseUrl + "/sso/" + mode + @"/Auth';
let response = await new Promise(resolve => {
var xhr = new XMLHttpRequest();
@@ -427,60 +442,14 @@ async function main() {
jfCreds['Servers'][0]['UserId'] = responseJson['User']['Id'];
localStorage.setItem('jellyfin_credentials', JSON.stringify(jfCreds));
localStorage.setItem('enableAutoLogin', 'true');
window.location.replace('/');
window.location.replace('" + baseUrl + @"');
}
document.addEventListener('DOMContentLoaded', function () {
main();
});
</script></body></html>";
}
public static string SamlGenerator(string xml, string provider)
{
return Base + @"
async function main() {
var xml = '" + xml + @"';
var deviceId = localStorage.getItem(""_deviceId2"");
var appName = ""Jellyfin Web"";
var appVersion = ""10.8.0"";
var deviceName = getDeviceName();
var provider = '" + provider + @"';
var request = {'deviceID': deviceId, 'appName': appName, 'appVersion': appVersion, deviceName: 'deviceName', data: xml, provider: '" + provider + @"'};
var url = '/sso/SAML/Auth';
let response = await new Promise(resolve => {
var xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Accept', 'application/json');
xhr.onload = function(e) {
resolve(xhr.response);
};
xhr.onerror = function () {
resolve(undefined);
};
xhr.send(JSON.stringify(request));
})
var responseJson = JSON.parse(response);
var userId = 'user-' + responseJson['User']['Id'] + '-' + responseJson['User']['ServerId'];
responseJson['User']['EnableAutoLogin'] = true;
localStorage.setItem(userId, JSON.stringify(responseJson['User']));
var jfCreds = JSON.parse(localStorage.getItem('jellyfin_credentials'));
jfCreds['Servers'][0]['AccessToken'] = responseJson['AccessToken'];
jfCreds['Servers'][0]['UserId'] = responseJson['User']['Id'];
localStorage.setItem('jellyfin_credentials', JSON.stringify(jfCreds));
localStorage.setItem('enableAutoLogin', 'true');
window.location.replace('/');
}
document.addEventListener('DOMContentLoaded', function () {
main();
});
</script></body></html>";
}
// https://stackoverflow.com/a/25435165
</script><iframe class='docs-texteventtarget-iframe' sandbox='allow-same-origin allow-forms allow-scripts' src='" + baseUrl + "' style='position: absolute;width:0;height:0;border:0;'></iframe></body></html>";
}
}
+15 -6
View File
@@ -1,14 +1,23 @@
name: "SSO Authentication"
guid: "505ce9d1-d916-42fa-86ca-673ef241d7df"
imageUrl: "https://raw.githubusercontent.com/sambhavsaggi/jellyfin-plugin-sso/main/img/logo.png"
version: "1"
imageUrl: "https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/main/img/logo.png"
version: "3.1.0.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"
- "IdentityModel.OidcClient.dll"
changelog: "2022-01-16: Initial Release"
- "IdentityModel.dll"
changelog: |
3.1.0.1: Fix redirect bug in WebResponse (#7)
3.1.0.0: Simplify auth flow so loading the web UI is not required
3.0.0.0: Add more RBAC features and option to unregister user from SSO
2.0.1.0: Fix improper artifact loading
2.0.0.0: Add RBAC and Google support
1.0.0.0: Initial Release