Skip to content

Commit

Permalink
Add roles + claim support. Update admin page.
Browse files Browse the repository at this point in the history
  • Loading branch information
9p4 committed Jan 23, 2022
1 parent 4ade45e commit b188e4d
Show file tree
Hide file tree
Showing 6 changed files with 173 additions and 57 deletions.
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Build the zipped plugin with `jprm --verbosity=debug plugin build .`.

- [ ] Admin page
- [ ] Automated tests
- [ ] Add role/claims support
- [x] Add role/claims support
- [ ] Use canonical usernames instead of preferred usernames

## Examples
Expand All @@ -46,7 +46,7 @@ Build the zipped plugin with `jprm --verbosity=debug plugin build .`.

Example for adding a SAML configuration with the API using [curl](https://curl.se/):

`curl -v -X POST -H "Content-Type: application/json" -d '{"samlEndpoint": "https://keycloak.example.com/auth/realms/test/protocol/saml", "samlClientId": "jellyfin-saml", "samlCertificate": "Very long base64 encoded string here", "enabled": true, "enableAllFolders": true, "enabledFolders": ["folder1", "folder2"]}' "https://myjellyfin.example.com/sso/SAML/Add?api_key=API_KEY_HERE"`
`curl -v -X POST -H "Content-Type: application/json" -d '{"samlEndpoint": "https://keycloak.example.com/auth/realms/test/protocol/saml", "samlClientId": "jellyfin-saml", "samlCertificate": "Very long base64 encoded string here", "enabled": true, "enableAllFolders": true, "enabledFolders": ["folder1", "folder2"], "adminRoles": [], "roles": []}' "https://myjellyfin.example.com/sso/SAML/Add?api_key=API_KEY_HERE"`

Make sure that the JSON is the same as the configuration you would like.

Expand All @@ -65,7 +65,7 @@ Make sure that `clientid` is replaced with the actual client ID!

Example for adding an OpenID configuration with the API using [curl](https://curl.se/)

`curl -v -X POST -H "Content-Type: application/json" -d '{"oidEndpoint": "https://keycloak.example.com/auth/reapls/test", "oidClientId": "jellyfin-oid", "oidSecret": "short secret here", "enabled": true, "enableAllFolders": true, "enabledFolders": ["folder3", "folder4"]}' "https://myjellyfin.example.com/sso/OID/Add?api_key=API_KEY_HERE"`
`curl -v -X POST -H "Content-Type: application/json" -d '{"oidEndpoint": "https://keycloak.example.com/auth/reapls/test", "oidClientId": "jellyfin-oid", "oidSecret": "short secret here", "enabled": true, "enableAllFolders": true, "enabledFolders": ["folder3", "folder4"], "adminRoles": [], "roles": []}' "https://myjellyfin.example.com/sso/OID/Add?api_key=API_KEY_HERE"`

The OpenID provider must have the following configuration (again, I am using Keycloak)

Expand Down Expand Up @@ -96,7 +96,7 @@ The API is all done from a base URL of `/sso/`

#### Configuration

These all require authorization. Append an API key to the end of the request: `curl "http://myjellyfin.example.com/sso/SAML/Get?api_key=9c6e5fae4ae145669e6b7a3942f813b7"`
These all require authorization. Append an API key to the end of the request: `curl "http://myjellyfin.example.com/sso/SAML/Get?api_key=API_KEY_HERE"`

- POST `SAML/Add`: This adds a configuration for SAML. It accepts JSON with the following keys and format:
- `samlEndpoint`: string. The SAML endpoint.
Expand All @@ -105,6 +105,8 @@ These all require authorization. Append an API key to the end of the request: `c
- `enabled`: boolean. Determines if the provider is enabled or not.
- `enableAllFolders`: boolean. Determines if the client logging in is allowed access to all folders.
- `enabledFolders`: array of strings. If `enableAllFolders` is set to false, then this will be used to determine what folders the users who log in through this provider are allowed to use.
- `roles`: array of strings. This validates the SAML response against the `Role` attribute. If a user has any of these roles, then the user is authenticated. Leave blank to disable role checking.
- `adminRoles`: array of strings. This uses SAML response's `Role` attributes. If a user has any of these roles, then the user is an admin. Leave blank to disable (default is to not enable admin permissions).
- GET `SAML/Del/clientId`: This removes a configuration for SAML for a given client ID.
- GET `SAML/Get`: Lists the configurations currently available.

Expand Down Expand Up @@ -134,6 +136,8 @@ These all require authorization. Append an API key to the end of the request: `c
- `enabled`: boolean. Determines if the provider is enabled or not.
- `enableAllFolders`: boolean. Determines if the client logging in is allowed access to all folders.
- `enabledFolders`: array of strings. If `enableAllFolders` is set to false, then this will be used to determine what folders the users who log in through this provider are allowed to use.
- `roles`: array of strings. This validates the OpenID response against the `realm_access` claim. If a user has any of these roles, then the user is authenticated. Leave blank to disable role checking. This currently only works for Keycloak (to my knowledge).
- `adminRoles`: array of strings. This uses the OpenID response against the `realm_access` claim. If a user has any of these roles, then the user is an admin. Leave blank to disable (default is to not enable admin permissions).
- GET `OID/Del/clientId`: This removes a configuration for OpenID for a given client ID.
- GET `OID/Get`: Lists the configurations currently available.
- GET `OID/States`: Lists currently active OpenID flows in progress.
Expand Down
173 changes: 123 additions & 50 deletions SSO-Auth/Api/SSOController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;

namespace Jellyfin.Plugin.SSO_Auth.Api;

Expand Down Expand Up @@ -41,39 +42,6 @@ public SSOController(ILogger<SSOController> logger, ISessionManager sessionManag
_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)
{
Expand All @@ -100,16 +68,60 @@ public ActionResult OIDPost(string provider)

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;
}
}
}

return Content("Does your OpenID provider not support the preferred_username value?", MediaTypeNames.Text.Plain);
// Check if allowed to login based on realm roles
if (config.Roles.Length != 0)
{
if (claim.Type == "realm_access") // This is specific to Keycloak. Don't use roles without Keycloak, I guess
{
List<string> roles = JsonConvert.DeserializeObject<IDictionary<string, List<string>>>(claim.Value)["roles"]; // Might need error handling here
foreach (string validRoles in config.Roles)
{
foreach (string role in roles)
{
if (role.Equals(validRoles))
{
StateManager[Request.Query["state"]].Valid = true;
}
}
}
}
}
// Check if admin
if (config.AdminRoles.Length != 0)
{
if (claim.Type == "realm_access") // This is specific to Keycloak. Don't use roles without Keycloak, I guess
{
List<string> roles = JsonConvert.DeserializeObject<IDictionary<string, List<string>>>(claim.Value)["roles"]; // Might need error handling here
foreach (string validAdminRoles in config.AdminRoles)
{
foreach (string role in roles)
{
if (role.Equals(validAdminRoles))
{
StateManager[Request.Query["state"]].Admin = true;
}
}
}
}
}
}
if (StateManager[Request.Query["state"]].Valid)
{
return Content(WebResponse.OIDGenerator(data: Request.Query["state"], provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html);
}
else
{
return Content("Error. Check permissions");
}
}
}

Expand Down Expand Up @@ -202,7 +214,7 @@ public async Task<ActionResult> OIDAuth([FromBody] AuthResponse response)
{
if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid)
{
var authenticationResult = await Authenticate(kvp.Value.Username, false, oidConfig.EnableAllFolders, oidConfig.EnabledFolders, response)
var authenticationResult = await Authenticate(kvp.Value.Username, kvp.Value.Admin, oidConfig.EnableAllFolders, oidConfig.EnabledFolders, response)
.ConfigureAwait(false);
return Ok(authenticationResult);
}
Expand All @@ -213,6 +225,54 @@ public async Task<ActionResult> OIDAuth([FromBody] AuthResponse response)
return Problem("Something went wrong");
}

[HttpPost("SAML/p/{provider}")]
public ActionResult SAMLPost(string provider)
{
// I'm sure there's a better way than using nested for loops but eh whatever
foreach (var samlConfig in SSOPlugin.Instance.Configuration.SamlConfigs)
{
if (samlConfig.SamlClientId == provider && samlConfig.Enabled)
{
var samlResponse = new Response(samlConfig.SamlCertificate, Request.Form["SAMLResponse"]);
if (samlConfig.Roles.Length == 0)
{
return Content(WebResponse.SamlGenerator(xml: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html);
}
foreach (string role in samlResponse.GetCustomAttributes("Role"))
{
foreach (string allowedRole in samlConfig.Roles)
{
if (allowedRole.Equals(role))
{
return Content(WebResponse.SamlGenerator(xml: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html);
}
}
}
return Content("401 Forbidden");
}
}

return Content("no active providers found"); // TODO: Return error code as well
}

[HttpGet("SAML/p/{provider}")]
public RedirectResult SAMLChallenge(string provider)
{
foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs)
{
if (config.SamlClientId == provider && config.Enabled)
{
var request = new AuthRequest(
config.SamlClientId,
GetRequestBase() + "/sso/SAML/p/" + provider);

return Redirect(request.GetRedirectUrl(config.SamlEndpoint));
}
}

throw new ArgumentException("Provider does not exist");
}

[Authorize(Policy = "RequiresElevation")]
[HttpPost("SAML/Add")]
public void SamlAdd([FromBody] SamlConfig samlConfig)
Expand Down Expand Up @@ -262,8 +322,19 @@ public async Task<ActionResult> SamlAuth([FromBody] AuthResponse response)
{
if (samlConfig.SamlClientId == response.Provider && samlConfig.Enabled)
{
bool isAdmin = false;
var samlResponse = new Response(samlConfig.SamlCertificate, response.Data);
var authenticationResult = await Authenticate(samlResponse.GetNameID(), false, samlConfig.EnableAllFolders, samlConfig.EnabledFolders, response)
foreach (string role in samlResponse.GetCustomAttributes("Role"))
{
foreach (string allowedRole in samlConfig.AdminRoles)
{
if (allowedRole.Equals(role))
{
isAdmin = true;
}
}
}
var authenticationResult = await Authenticate(samlResponse.GetNameID(), isAdmin, samlConfig.EnableAllFolders, samlConfig.EnabledFolders, response)
.ConfigureAwait(false);
return Ok(authenticationResult);
}
Expand All @@ -274,25 +345,24 @@ public async Task<ActionResult> SamlAuth([FromBody] AuthResponse response)

private async Task<AuthenticationResult> Authenticate(string username, bool isAdmin, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse)
{
_logger.LogInformation("Authenticating");
User user = null;
user = _userManager.GetUserByName(username);

if (user == null)
{
_logger.LogInformation("SSO user doesn't exist, creating...");
user = await _userManager.CreateUserAsync(username).ConfigureAwait(false);
user.AuthenticationProviderId = GetType().FullName;
user.SetPermission(PermissionKind.IsAdministrator, isAdmin);
user.SetPermission(PermissionKind.EnableAllFolders, enableAllFolders);
if (!enableAllFolders)
{
user.SetPreference(PreferenceKind.EnabledFolders, enabledFolders);
}

await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
}
user.AuthenticationProviderId = GetType().FullName;
user.SetPermission(PermissionKind.IsAdministrator, isAdmin);
user.SetPermission(PermissionKind.EnableAllFolders, enableAllFolders);
if (!enableAllFolders)
{
user.SetPreference(PreferenceKind.EnabledFolders, enabledFolders);
}

await _userManager.UpdateUserAsync(user).ConfigureAwait(false);

var authRequest = new AuthenticationRequest();
authRequest.UserId = user.Id;
authRequest.Username = user.Username;
Expand Down Expand Up @@ -344,6 +414,7 @@ public TimedAuthorizeState(AuthorizeState state, DateTime created)
State = state;
Created = created;
Valid = false;
Admin = false;
}

public AuthorizeState State { get; set; }
Expand All @@ -353,4 +424,6 @@ public TimedAuthorizeState(AuthorizeState state, DateTime created)
public bool Valid { get; set; }

public string Username { get; set; }

public bool Admin { get; set; }
}
8 changes: 8 additions & 0 deletions SSO-Auth/Config/PluginConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ public class SamlConfig
public bool EnableAllFolders { get; set; }

public string[] EnabledFolders { get; set; }

public string[] AdminRoles { get; set; }

public string[] Roles { get; set; }
}

[XmlRoot("PluginConfiguration")]
Expand All @@ -56,4 +60,8 @@ public class OIDConfig
public bool EnableAllFolders { get; set; }

public string[] EnabledFolders { get; set; }

public string[] AdminRoles { get; set; }

public string[] Roles { get; set; }
}
22 changes: 20 additions & 2 deletions SSO-Auth/Config/configPage.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ <h2 class="sectionTitle">SSO Settings:</h2>
<a is="emby-button" class="raised button-alt headerHelpButton" target="_blank" href="https://github.com/9p4/jellyfin-plugin-sso">${Help}</a>
</div>
<div class="verticalSection" is="emby-collapse" title="SSO Server Settings">
<button id="newSaml" is="emby-button">
<span>Add new SAML provider</span>
</button>
<button id="newOID" is="emby-button">
<span>Add new OpenID provider</span>
</button>
<div class="collapseContent" id="default">
<div class="samlProviderWrapper">
<div class="samlProvider">
Expand All @@ -36,7 +42,7 @@ <h2 class="sectionTitle">SSO Settings:</h2>
</div>

<button id="btnSaveSettings" is="emby-button" type="submit" value="submit" class="raised button block">
<span>Save SSO Settings</span>
<span>Update Provider</span>
</button>
</div>
</div>
Expand All @@ -49,7 +55,6 @@ <h2 class="sectionTitle">SSO Settings:</h2>
<script type="text/javascript">
var SSOConfigurationPage = {
pluginUniqueId: "505ce9d1-d916-42fa-86ca-673ef241d7df",
samlProviderWrapper: document.querySelector("#samlProviderWrapper")
};

document.querySelector('.esqConfigurationPage').addEventListener("pageshow", function () {
Expand Down Expand Up @@ -79,6 +84,19 @@ <h2 class="sectionTitle">SSO Settings:</h2>
// Disable default form submission
return false;
});

var newSaml = document.getElementById("newSaml");
newSaml.addEventListener("click", function(e) {
e.preventDefault();
Dashboard.showLoadingMsg();
fetch(window.ApiClient.getUrl("sso/SAML/Get?api_key=" + window.ApiClient.accessToken()))
.then(response => {
if (!response.ok) {
throw new Error("HTTP error " + response.status);
}
return response.json();
}).then(json => {console.log(json)});
});
</script>
</div>
</body>
Expand Down
1 change: 1 addition & 0 deletions SSO-Auth/SSO-Auth.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down
Loading

0 comments on commit b188e4d

Please sign in to comment.