Compare commits

..

25 Commits

Author SHA1 Message Date
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
8 changed files with 342 additions and 225 deletions
+45 -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/)
@@ -25,7 +34,7 @@ Add the package repo [https://repo.saggis.com/jellyfin/manifest.json](https://re
## Building
This is built with .NET 6.0. Build with `dotnet publish .` for the debug release in the `SSO-Auth` directory. Copy over the `IdentityModel.OidcClient.dll` and the `SSO-Auth.dll` files in the `/bin/Debug/net6.0/publish` directory to a new folder in your Jellyfin configuration: `config/plugins/sso`.
This is built with .NET 6.0. Build with `dotnet publish .` for the debug release in the `SSO-Auth` directory. Copy over the `IdentityModel.OidcClient.dll`, the `IdentityModel.dll` and the `SSO-Auth.dll` files in the `/bin/Debug/net6.0/publish` directory to a new folder in your Jellyfin configuration: `config/plugins/sso`.
## Releasing
@@ -33,13 +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.
@@ -48,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!
@@ -58,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)
@@ -89,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.
@@ -125,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`).
@@ -139,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
@@ -152,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.
+193 -56
View File
@@ -12,6 +12,7 @@ using MediaBrowser.Controller.Session;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Jellyfin.Plugin.SSO_Auth.Api;
@@ -41,39 +42,6 @@ public class SSOController : ControllerBase
_logger.LogInformation("SSO Controller initialized");
}
[HttpPost("SAML/p/{provider}")]
public ActionResult SAMLPost(string provider)
{
foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs)
{
if (config.SamlClientId == provider && config.Enabled)
{
var samlResponse = new Response(config.SamlCertificate, Request.Form["SAMLResponse"]);
return Content(WebResponse.SamlGenerator(xml: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html);
}
}
return Content("no active providers found"); // TODO: Return error code as well
}
[HttpGet("SAML/p/{provider}")]
public RedirectResult SAMLChallenge(string provider)
{
foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs)
{
if (config.SamlClientId == provider && config.Enabled)
{
var request = new AuthRequest(
config.SamlClientId,
GetRequestBase() + "/sso/SAML/p/" + provider);
return Redirect(request.GetRedirectUrl(config.SamlEndpoint));
}
}
throw new ArgumentException("Provider does not exist");
}
[HttpGet("OID/r/{provider}")]
public ActionResult OIDPost(string provider)
{
@@ -88,8 +56,9 @@ public class SSOController : ControllerBase
ClientId = config.OIDClientId,
ClientSecret = config.OIDSecret,
RedirectUri = GetRequestBase() + "/sso/OID/r/" + provider,
Scope = "openid profile"
Scope = "openid profile",
};
options.Policy.Discovery.ValidateEndpoints = false; // For Google and other providers with different endpoints
var oidcClient = new OidcClient(options);
var state = StateManager[Request.Query["state"]].State;
var result = oidcClient.ProcessResponseAsync(Request.QueryString.Value, state).Result;
@@ -98,18 +67,90 @@ public class SSOController : ControllerBase
return Content("Something went wrong...", MediaTypeNames.Text.Plain);
}
if (!config.EnableFolderRoles)
{
StateManager[Request.Query["state"]].Folders = new List<string>(config.EnabledFolders);
} else {
StateManager[Request.Query["state"]].Folders = new List<string>();
}
foreach (var claim in result.User.Claims)
{
_logger.LogInformation("{0}: {1}", claim.Type, claim.Value);
if (claim.Type == "preferred_username")
{
StateManager[Request.Query["state"]].Valid = true;
StateManager[Request.Query["state"]].Username = claim.Value;
return Content(WebResponse.OIDGenerator(data: Request.Query["state"], provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html);
if (config.Roles.Length == 0)
{
StateManager[Request.Query["state"]].Valid = true;
}
}
// Role processing
if (claim.Type == config.RoleClaim)
{
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);
}
}
}
}
}
}
return Content("Does your OpenID provider not support the preferred_username value?", MediaTypeNames.Text.Plain);
// If the provider doesn't support preferred_username, then use sub
if (!StateManager[Request.Query["state"]].Valid)
{
foreach (var claim in result.User.Claims)
{
if (claim.Type == "sub")
{
StateManager[Request.Query["state"]].Username = claim.Value;
if (config.Roles.Length == 0)
{
StateManager[Request.Query["state"]].Valid = true;
}
}
}
}
if (StateManager[Request.Query["state"]].Valid)
{
return Content(WebResponse.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
}
}
}
@@ -132,6 +173,7 @@ public class SSOController : ControllerBase
RedirectUri = GetRequestBase() + "/sso/OID/r/" + provider,
Scope = "openid profile"
};
options.Policy.Discovery.ValidateEndpoints = false; // For Google and other providers with different endpoints
var oidcClient = new OidcClient(options);
var state = await oidcClient.PrepareLoginAsync().ConfigureAwait(false);
StateManager.Add(state.State, new TimedAuthorizeState(state, DateTime.Now));
@@ -144,18 +186,18 @@ public class SSOController : ControllerBase
[Authorize(Policy = "RequiresElevation")]
[HttpPost("OID/Add")]
public void OIDAdd([FromBody] OIDConfig oidConfig)
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(oidConfig.OIDClientId))
if (configuration.OIDConfigs[i].OIDClientId.Equals(config.OIDClientId))
{
configuration.OIDConfigs.RemoveAt(i);
}
}
configuration.OIDConfigs.Add(oidConfig);
configuration.OIDConfigs.Add(config);
SSOPlugin.Instance.UpdateConfiguration(configuration);
}
@@ -194,15 +236,15 @@ public class SSOController : ControllerBase
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult> OIDAuth([FromBody] AuthResponse response)
{
foreach (var oidConfig in SSOPlugin.Instance.Configuration.OIDConfigs)
foreach (var config in SSOPlugin.Instance.Configuration.OIDConfigs)
{
if (oidConfig.OIDClientId == response.Provider && oidConfig.Enabled)
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, false, oidConfig.EnableAllFolders, oidConfig.EnabledFolders, response)
var authenticationResult = await Authenticate(kvp.Value.Username, kvp.Value.Admin, config.EnableAuthorization, config.EnableAllFolders, kvp.Value.Folders.ToArray(), response)
.ConfigureAwait(false);
return Ok(authenticationResult);
}
@@ -213,20 +255,70 @@ public class SSOController : ControllerBase
return Problem("Something went wrong");
}
[HttpPost("SAML/p/{provider}")]
public ActionResult SAMLPost(string provider)
{
// I'm sure there's a better way than using nested for loops but eh whatever
foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs)
{
if (config.SamlClientId == provider && config.Enabled)
{
var samlResponse = new Response(config.SamlCertificate, Request.Form["SAMLResponse"]);
// If no roles are configured, don't use RBAC
if (config.Roles.Length == 0)
{
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
}
}
return Content("no active providers found"); // TODO: Return error code as well
}
[HttpGet("SAML/p/{provider}")]
public RedirectResult SAMLChallenge(string provider)
{
foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs)
{
if (config.SamlClientId == provider && config.Enabled)
{
var request = new AuthRequest(
config.SamlClientId,
GetRequestBase() + "/sso/SAML/p/" + provider);
return Redirect(request.GetRedirectUrl(config.SamlEndpoint));
}
}
throw new ArgumentException("Provider does not exist");
}
[Authorize(Policy = "RequiresElevation")]
[HttpPost("SAML/Add")]
public void SamlAdd([FromBody] SamlConfig samlConfig)
public void SamlAdd([FromBody] SamlConfig config)
{
var configuration = SSOPlugin.Instance.Configuration;
for (var i = 0; i < configuration.SamlConfigs.Count; i++)
{
if (configuration.SamlConfigs[i].SamlClientId.Equals(samlConfig.SamlClientId))
if (configuration.SamlConfigs[i].SamlClientId.Equals(config.SamlClientId))
{
configuration.SamlConfigs.RemoveAt(i);
}
}
configuration.SamlConfigs.Add(samlConfig);
configuration.SamlConfigs.Add(config);
SSOPlugin.Instance.UpdateConfiguration(configuration);
}
@@ -258,12 +350,39 @@ public class SSOController : ControllerBase
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult> SamlAuth([FromBody] AuthResponse response)
{
foreach (var samlConfig in SSOPlugin.Instance.Configuration.SamlConfigs)
foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs)
{
if (samlConfig.SamlClientId == response.Provider && samlConfig.Enabled)
if (config.SamlClientId == response.Provider && config.Enabled)
{
var samlResponse = new Response(samlConfig.SamlCertificate, response.Data);
var authenticationResult = await Authenticate(samlResponse.GetNameID(), false, samlConfig.EnableAllFolders, samlConfig.EnabledFolders, response)
bool isAdmin = false;
var samlResponse = new Response(config.SamlCertificate, response.Data);
List<string> folders;
if (!config.EnableFolderRoles)
{
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);
}
@@ -272,9 +391,18 @@ public class SSOController : ControllerBase
return Problem("Something went wrong");
}
private async Task<AuthenticationResult> Authenticate(string username, bool isAdmin, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse)
[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)
{
_logger.LogInformation("Authenticating");
User user = null;
user = _userManager.GetUserByName(username);
@@ -282,17 +410,19 @@ public class SSOController : ControllerBase
{
_logger.LogInformation("SSO user doesn't exist, creating...");
user = await _userManager.CreateUserAsync(username).ConfigureAwait(false);
user.AuthenticationProviderId = GetType().FullName;
}
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);
}
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
var authRequest = new AuthenticationRequest();
authRequest.UserId = user.Id;
authRequest.Username = user.Username;
@@ -344,6 +474,7 @@ public class TimedAuthorizeState
State = state;
Created = created;
Valid = false;
Admin = false;
}
public AuthorizeState State { get; set; }
@@ -353,4 +484,10 @@ public class TimedAuthorizeState
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; }
}
+33
View File
@@ -37,9 +37,21 @@ public class SamlConfig
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")]
@@ -53,7 +65,28 @@ public class OIDConfig
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>
+3 -2
View File
@@ -3,8 +3,8 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.SSO_Auth</RootNamespace>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<AssemblyVersion>3.1.0.0</AssemblyVersion>
<FileVersion>3.1.0.0</FileVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
@@ -19,6 +19,7 @@
<PackageReference Include="Jellyfin.Controller" Version="10.*-*" />
<PackageReference Include="Jellyfin.Model" Version="10.*-*" />
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="6.0.0" />
</ItemGroup>
+13 -1
View File
@@ -1,5 +1,5 @@
/*
Jitbit's simple SAML 2.0 component for ASP.NET
Was Jitbit's simple SAML 2.0 component for ASP.NET
https://github.com/jitbit/AspNetSaml/
(c) Jitbit LP, 2016
Use this freely under the Apache license (see https://choosealicense.com/licenses/apache-2.0/)
@@ -7,6 +7,7 @@
*/
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Security.Cryptography.X509Certificates;
@@ -188,6 +189,17 @@ public class Response
return node?.InnerText;
}
public List<string> GetCustomAttributes(string attr)
{
var node = _xmlDoc.SelectNodes("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager);
List<string> output = new List<string>();
foreach (XmlNode item in node)
{
output.Add(item?.InnerText);
}
return output;
}
// returns namespace manager, we need one b/c MS says so... Otherwise XPath doesnt work in an XML doc with namespaces
// see https://stackoverflow.com/questions/7178111/why-is-xmlnamespacemanager-necessary
private XmlNamespaceManager GetNamespaceManager()
+43 -80
View File
@@ -3,7 +3,11 @@ namespace Jellyfin.Plugin.SSO_Auth;
public static class WebResponse
{
public static readonly string Base = @"<!DOCTYPE html>
<html><head></head><body><script>
<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();
@@ -391,97 +395,56 @@ function getDeviceName() {
";
public static string OIDGenerator(string data, string provider, string baseUrl)
public static string Generator(string data, string provider, string baseUrl, string mode)
{
return Base + @"
async function main() {
var data = '" + data + @"';
var deviceId = localStorage.getItem(""_deviceId2"");
var appName = ""Jellyfin Web"";
var appVersion = ""10.8.0"";
var deviceName = getDeviceName();
var provider = '" + provider + @"';
if (localStorage.getItem(""_deviceId2"") == null || localStorage.getItem(""jellyfin_credentials"") == null) {
// If localStorage isn't initialized yet, try again.
setTimeout(main, 100);
} else {
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 = '" + baseUrl + @"/sso/OID/Auth';
var url = '" + baseUrl + "/sso/" + mode + @"/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('/');
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('" + baseUrl + @"');
}
}
document.addEventListener('DOMContentLoaded', function () {
main();
});
</script></body></html>";
}
public static string SamlGenerator(string xml, string provider, string baseUrl)
{
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 = '" + baseUrl + @"/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' src='" + baseUrl + "' style='position: absolute;width:0;height:0;border:0;'></iframe></body></html>";
}
}
+11 -5
View File
@@ -1,16 +1,22 @@
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.0"
targetAbi: "10.8.0.0"
framework: "net6.0"
owner: "sambhavsaggi"
owner: "9p4"
overview: "Authenticate users against an SSO provider."
description: |
This plugin allows users to sign in through an SSO provider (such as Google, Facebook, or your own provider). This enables one-click signin.
Review documentation at https://github.com/sambhavsaggi/jellyfin-plugin-sso
Review documentation at https://github.com/9p4/jellyfin-plugin-sso
category: "Authentication"
artifacts:
- "SSO-Auth.dll"
- "IdentityModel.OidcClient.dll"
changelog: "2022-01-16: Initial Release"
- "IdentityModel.dll"
changelog: |
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