Compare commits

...
Author SHA1 Message Date
Sambhav Saggi 31cd57829b 3.2.0.0 release 2022-03-13 15:09:47 -04:00
Sambhav Saggi 8d3b0ea5eb Merge branch 'main' of https://github.com/9p4/jellyfin-plugin-sso 2022-03-13 15:02:00 -04:00
Sambhav Saggi 7f66f44743 Use hashmaps instead of lists for performance 2022-03-13 15:01:26 -04:00
9p4 0ad890088d [skip ci] Update README.md
Fix RSS link for commits
2022-03-06 17:10:57 -05:00
9p4 bceb448c2d Create FUNDING.yml
Might as well.
2022-03-01 22:25:27 -05:00
Sambhav Saggi 0d44515442 Merge branch 'main' of https://github.com/9p4/jellyfin-plugin-sso 2022-02-25 19:52:11 -05:00
Sambhav Saggi f748bf7ef7 Add expected roles for debug 2022-02-25 19:51:54 -05:00
Sambhav Saggi b752f6ffaa Log permissions on permission error 2022-02-25 19:47:06 -05:00
9p4 880a5494ec [skip ci] Add why only Jellyfin 10.8 is supported. 2022-02-21 15:30:04 -05:00
9p4 495fbcdda4 [skip ci] Make README even prettier 2022-02-21 14:59:44 -05:00
9p4 93c5529138 [skip ci] Make README prettier 2022-02-21 14:50:24 -05:00
Sambhav Saggi ff0f6caa3c Merge branch 'main' of https://github.com/9p4/jellyfin-plugin-sso 2022-02-21 14:36:34 -05:00
Sambhav Saggi 7d1b3fa97d Add XML docs + no more warnings 2022-02-21 14:36:29 -05:00
9p4 4709a65c00 [CI SKIP] Error on warn 2022-02-21 12:44:48 -05:00
9p4 1945ae8683 Add GH action for CI/CD 2022-02-21 12:42:09 -05:00
Sambhav Saggi fad64fec7c Clean up logging, add better errors 2022-02-21 11:29:46 -05:00
Sambhav Saggi 1a736d9707 Fix docs and escaped characters 2022-02-21 11:16:57 -05:00
Sambhav Saggi 7106af19ec Add nested OIDC claim support 2022-02-21 11:16:12 -05:00
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
11 changed files with 911 additions and 325 deletions
+13
View File
@@ -0,0 +1,13 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: a055
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+25
View File
@@ -0,0 +1,25 @@
name: .NET
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup .NET
uses: actions/setup-dotnet@v1
with:
dotnet-version: 6.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore --warnaserror
- name: Test
run: dotnet test --no-build --verbosity normal
+50 -25
View File
@@ -1,6 +1,29 @@
# Jellyfin SSO Plugin
<h1 align="center">Jellyfin SSO Plugin</h1>
This plugin allows users to sign in through an SSO provider (such as Google, Facebook, or your own provider). This enables one-click signin.
<p align="center">
<img alt="Logo" src="https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/main/img/logo.png"/>
<br/>
<br/>
<a href="https://github.com/9p4/jellyfin-plugin-sso">
<img alt="GPL 3.0 License" src="https://img.shields.io/github/license/9p4/jellyfin-plugin-sso.svg"/>
</a>
<a href="https://github.com/9p4/jellyfin-plugin-sso/actions/workflows/dotnet.yml">
<img alt="GitHub Actions Build Status" src="https://github.com/9p4/jellyfin-plugin-sso/actions/workflows/dotnet.yml/badge.svg"/>
</a>
<a href="https://github.com/9p4/jellyfin-plugin-sso/releases">
<img alt="Current Release" src="https://img.shields.io/github/release/9p4/jellyfin-plugin-sso.svg"/>
</a>
<a href="https://github.com/9p4/jellyfin-plugin-sso/releases.atom">
<img alt="Release RSS Feed" src="https://img.shields.io/badge/rss-releases-ffa500?logo=rss" />
</a>
<a href="https://github.com/9p4/jellyfin-plugin-sso/commits/main.atom">
<img alt="Main Commits RSS Feed" src="https://img.shields.io/badge/rss-commits-ffa500?logo=rss" />
</a>
</p>
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,9 +33,9 @@ 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 is for Jellyfin 10.8](https://github.com/9p4/jellyfin-plugin-sso/issues/3) and only on the Web UI!**
**This README reflects the __main__ branch! Switch tags to view version-specific documentation!**
**This README reflects the branch it is currently on! Switch tags to view version-specific documentation!**
## Tested Providers
@@ -56,7 +79,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/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"`
`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/PROVIDER_NAME?api_key=API_KEY_HERE"`
Make sure that the JSON is the same as the configuration you would like.
@@ -65,26 +88,26 @@ 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/SAML/p/clientid](https://myjellyfin.example.com/sso/OID/p/clientid)
- Redirect URI: [https://myjellyfin.example.com/sso/SAML/p/PROVIDER_NAME](https://myjellyfin.example.com/sso/OID/p/PROVIDER_NAME)
- 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/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/p/PROVIDER_NAME)
Make sure that `clientid` is replaced with the actual client ID!
Make sure that `clientid` is replaced with the actual client ID and `PROVIDER_NAME` is replaced with the chosen provider name!
### OpenID
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/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"]}]' "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/PROVIDER_NAME?api_key=API_KEY_HERE"`
The OpenID provider must have the following configuration (again, I am using Keycloak)
- Access Type: Confidential
- Standard Flow Enabled
- Redirect URI: [https://myjellyfin.example.com/sso/OID/r/clientid](https://myjellyfin.example.com/sso/OID/r/clientid)
- Redirect URI: [https://myjellyfin.example.com/sso/OID/r/PROVIDER_NAME](https://myjellyfin.example.com/sso/OID/r/PROVIDER_NAME)
- Base URL: [https://myjellyfin.example.com](https://myjellyfin.example.com)
Make sure that `clientid` is replaced with the actual client ID!
Make sure that `clientid` is replaced with the actual client ID and `PROVIDER_NAME` is replaced with the chosen provider name!
## API Endpoints
@@ -94,21 +117,20 @@ The API is all done from a base URL of `/sso/`
#### Flow
- POST `SAML/p/clientid`: This is the SAML POST endpoint. It accepts a form response from the SAML provider and returns HTML and JavaScript for the client to login.
- GET `SAML/p/clientid`: This is the SAML initiator: it will begin the authorization flow for SAML with a given client ID.
- POST `SAML/Auth`: This is the SAML client-side API: the HTML and JavaScript client will call this endpoint to receive Jellyfin credentials. Post format is in JSON with the following keys:
- POST `SAML/p/PROVIDER_NAME`: This is the SAML POST endpoint. It accepts a form response from the SAML provider and returns HTML and JavaScript for the client to login with a given provider name.
- GET `SAML/p/PROVIDER_NAME`: This is the SAML initiator: it will begin the authorization flow for SAML with a given provider name.
- POST `SAML/Auth/PROVIDER_NAME`: This is the SAML client-side API: the HTML and JavaScript client will call this endpoint to receive Jellyfin credentials given a provider name. Post format is in JSON with the following keys:
- `deviceId`: string. Device ID.
- `deviceName`: string. Device name.
- `appName`: string. App name.
- `appVersion`: string. App version.
- `data`: string. The signed SAML XML request. Used to verify a request.
- `provider`: string. The current SAML client ID.
#### 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=API_KEY_HERE"`
- POST `SAML/Add`: This adds a configuration for SAML. It accepts JSON with the following keys and format:
- POST `SAML/Add/PROVIDER_NAME`: This adds or overwrites a configuration for SAML for the given provider name. 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.
@@ -120,7 +142,7 @@ These all require authorization. Append an API key to the end of the request: `c
- `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/Del/PROVIDER_NAME`: This removes a configuration for SAML for a given provider name.
- GET `SAML/Get`: Lists the configurations currently available.
@@ -128,21 +150,20 @@ These all require authorization. Append an API key to the end of the request: `c
#### Flow
- GET `OID/r/clientId`: This is the OpenID callback path. This will return HTML and JavaScript for the client to login.
- GET `OID/p/clientId`: This is the OpenID initiator: it will begin the authorization flow for OpenID with a given client ID.
- POST `OID/Auth`: This is the OpenID client-side API: the HTML and JavaScript client will call this endpoint to receive Jellyfin credentials. Post format is in JSON with the following keys:
- GET `OID/r/PROVIDER_NAME`: This is the OpenID callback path. This will return HTML and JavaScript for the client to login with a given provider name.
- GET `OID/p/PROVIDER_NAME`: This is the OpenID initiator: it will begin the authorization flow for OpenID with a given provider name.
- POST `OID/Auth/PROVIDER_NAME`: This is the OpenID client-side API: the HTML and JavaScript client will call this endpoint to receive Jellyfin credentials for a given provider name. Post format is in JSON with the following keys:
- `deviceId`: string. Device ID.
- `deviceName`: string. Device name.
- `appName`: string. App name.
- `appVersion`: string. App version.
- `data`: string. The OpenID state. Used to verify a request.
- `provider`: string. The current OpenID client ID.
#### Configuration
These all require authorization. Append an API key to the end of the request: `curl "http://myjellyfin.example.com/sso/OID/Get?api_key=9c6e5fae4ae145669e6b7a3942f813b7"`
- POST `OID/Add`: This adds a configuration for OpenID. It accepts JSON with the following keys and format:
- POST `OID/Add/PROVIDERNAME`: This adds or overwrites a configuration for OpenID with a given provider name. It accepts JSON with the following keys and format:
- `oidEndpoint`: string. The OpenID endpoint. Must have a `.well-known` path available.
- `oidClientId`: string. The OpenID client ID.
- `oidSecret`: string. The OpenID secret.
@@ -154,8 +175,8 @@ These all require authorization. Append an API key to the end of the request: `c
- `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_roles` by default.
- GET `OID/Del/clientId`: This removes a configuration for OpenID for a given client ID.
- `roleClaim`: string. This is the value in the OpenID response to check for roles. For Keycloak, it is `realm_access.roles` by default. The first element is the claim type, the subsequent values are to parse the JSON of the claim value. Use a "\\." to denote a literal ".". This expects a list of strings from the OIDC server.
- GET `OID/Del/PROVIDER_NAME`: This removes a configuration for OpenID for a given provider name.
- GET `OID/Get`: Lists the configurations currently available.
- GET `OID/States`: Lists currently active OpenID flows in progress.
@@ -173,7 +194,7 @@ There is also no logout callback. Logging out of Jellyfin will log you out of Je
~~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
@@ -184,3 +205,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 -231
View File
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Mime;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using IdentityModel.OidcClient;
using Jellyfin.Data.Entities;
@@ -13,6 +15,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Jellyfin.Plugin.SSO_Auth.Api;
@@ -42,41 +45,143 @@ public class SSOController : ControllerBase
_logger.LogInformation("SSO Controller initialized");
}
/// <summary>
/// The GET endpoint for OpenID provider to callback to. Returns a webpage that parses client data and completes auth.
/// </summary>
/// <param name="provider">The ID of the provider which will use the callback information.</param>
/// <returns>A webpage that will complete the client-side flow.</returns>
// Actually a GET: https://github.com/IdentityModel/IdentityModel.OidcClient/issues/325
[HttpGet("OID/r/{provider}")]
public ActionResult OIDPost(string provider)
public ActionResult OidPost(string provider) // Although this is a GET function, this function is called `Post` for consistency with SAML
{
// Actually a GET: https://github.com/IdentityModel/IdentityModel.OidcClient/issues/325
foreach (var config in SSOPlugin.Instance.Configuration.OIDConfigs)
OidConfig config;
try
{
if (config.OIDClientId == provider && config.Enabled)
config = SSOPlugin.Instance.Configuration.OidConfigs[provider];
}
catch (KeyNotFoundException)
{
return BadRequest("No matching provider found");
}
if (config.Enabled)
{
var options = new OidcClientOptions
{
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 = StateManager[Request.Query["state"]].State;
var result = oidcClient.ProcessResponseAsync(Request.QueryString.Value, state).Result;
if (result.IsError)
{
return ReturnError(400, result.Error + " Try logging in again.");
}
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)
{
if (claim.Type == "preferred_username")
{
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);
StateManager[Request.Query["state"]].Username = claim.Value;
if (config.Roles.Length == 0)
{
StateManager[Request.Query["state"]].Valid = true;
}
}
if (!config.EnableFolderRoles)
// Role processing
// The regex matches any "." not preceded by a "\": a.b.c will be split into a, b, and c, but a.b\.c will be split into a, b.c (after processing the escaped dots)
// We have to first process the RoleClaim string
string[] segments = Regex.Split(config.RoleClaim, "(?<!\\\\)\\.");
// Now we make sure that any escaped "."s ("\.") are replaced with "."
for (int i = 0; i < segments.Length; i++)
{
StateManager[Request.Query["state"]].Folders = new List<string>(config.EnabledFolders);
} else {
StateManager[Request.Query["state"]].Folders = new List<string>();
segments[i] = segments[i].Replace("\\.", ".");
}
if (claim.Type == segments[0])
{
List<string> roles;
// If we are not using JSON values, just use the raw info from the claim value
if (segments.Length == 1)
{
roles = new List<string> { claim.Value };
}
else
{
// We recursively traverse through the JSON data for the roles and parse it
var json = JsonConvert.DeserializeObject<IDictionary<string, object>>(claim.Value);
for (int i = 1; i < segments.Length - 1; i++)
{
var segment = segments[i];
json = (json[segment] as JObject).ToObject<IDictionary<string, object>>();
}
// The final step is to take the JSON and turn it from a dictionary into a string
roles = (json[segments[segments.Length - 1]] as JArray).ToObject<List<string>>();
}
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);
}
}
}
}
}
}
// 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 == "preferred_username")
if (claim.Type == "sub")
{
StateManager[Request.Query["state"]].Username = claim.Value;
if (config.Roles.Length == 0)
@@ -84,170 +189,142 @@ public class SSOController : ControllerBase
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);
}
}
}
}
}
}
// If the provider doesn't support preferred_username, then use sub
if (!StateManager[Request.Query["state"]].Valid)
{
foreach (var claim in result.User.Claims)
{
if (claim.Type == "sub")
{
StateManager[Request.Query["state"]].Username = claim.Value;
if (config.Roles.Length == 0)
{
StateManager[Request.Query["state"]].Valid = true;
}
}
}
}
if (StateManager[Request.Query["state"]].Valid)
{
return Content(WebResponse.OIDGenerator(data: Request.Query["state"], provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html);
}
else
{
return Content("Error. Check permissions."); // TODO: Return error code as well
}
if (StateManager[Request.Query["state"]].Valid)
{
return Content(WebResponse.Generator(data: Request.Query["state"], provider: provider, baseUrl: GetRequestBase(), mode: "OID"), MediaTypeNames.Text.Html);
}
else
{
_logger.LogWarning("OpenID user " + StateManager[Request.Query["state"]].Username + " has one or more incorrect role claims: " + string.Join(", ", result.User.Claims.Select(o => new { o.Type, o.Value })) + ". Expected any one of: " + string.Join(", ", config.Roles) + ".");
return ReturnError(401, "Error. Check permissions.");
}
}
return Content("no active providers found"); // TODO: Return error code as well
// If the config doesn't have an active provider matching the requeset, show an error
return BadRequest("No matching provider found");
}
/// <summary>
/// Initiates the login flow for OpenID. This redirects the user to the auth provider.
/// </summary>
/// <param name="provider">The name of the provider.</param>
/// <returns>An asynchronous result for the authentication.</returns>
[HttpGet("OID/p/{provider}")]
public async Task<ActionResult> OIDChallenge(string provider)
public async Task<ActionResult> OidChallenge(string provider)
{
Invalidate();
foreach (var config in SSOPlugin.Instance.Configuration.OIDConfigs)
OidConfig config;
try
{
if (config.OIDClientId == provider && config.Enabled)
config = SSOPlugin.Instance.Configuration.OidConfigs[provider];
}
catch (KeyNotFoundException)
{
throw new ArgumentException("Provider does not exist");
}
if (config.Enabled)
{
var options = new OidcClientOptions
{
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);
}
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");
}
/// <summary>
/// Adds an OpenID auth configuration. Requires administrator privileges. If the provider already exists, it will be removed and readded.
/// </summary>
/// <param name="provider">The name of the provider to add.</param>
/// <param name="config">The OID configuration (deserialized from a JSON post).</param>
[Authorize(Policy = "RequiresElevation")]
[HttpPost("OID/Add")]
public void OIDAdd([FromBody] OIDConfig config)
[HttpPost("OID/Add/{provider}")]
public void OidAdd(string provider, [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);
configuration.OidConfigs[provider] = config;
SSOPlugin.Instance.UpdateConfiguration(configuration);
}
/// <summary>
/// Deletes an OpenID provider.
/// </summary>
/// <param name="provider">Name of provider to delete.</param>
[Authorize(Policy = "RequiresElevation")]
[HttpGet("OID/Del/{provider}")]
public void OIDDel(string 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);
}
}
configuration.OidConfigs.Remove(provider);
SSOPlugin.Instance.UpdateConfiguration(configuration);
}
/// <summary>
/// Lists the OpenID providers configured. Requires administrator privileges.
/// </summary>
/// <returns>The list of OpenID configurations.</returns>
[Authorize(Policy = "RequiresElevation")]
[HttpGet("OID/Get")]
public ActionResult OIDProviders()
public ActionResult OidProviders()
{
return Ok(SSOPlugin.Instance.Configuration.OIDConfigs);
return Ok(SSOPlugin.Instance.Configuration.OidConfigs);
}
/// <summary>
/// This is a debug endpoint to list all running OpenID flows. Requires administrator privileges.
/// </summary>
/// <returns>The list of OpenID flows in progress.</returns>
[Authorize(Policy = "RequiresElevation")]
[HttpGet("OID/States")]
public ActionResult OIDStates()
public ActionResult OidStates()
{
return Ok(StateManager);
}
[HttpPost("OID/Auth")]
/// <summary>
/// This endpoint accepts JSON and will authorize the user from the device values passed from the client.
/// </summary>
/// <param name="provider">Name of provider to authenticate against.</param>
/// <param name="response">The data passed to the client to ensure it is the right one.</param>
/// <returns>JSON for the client to populate information with.</returns>
[HttpPost("OID/Auth/{provider}")]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult> OIDAuth([FromBody] AuthResponse response)
public async Task<ActionResult> OidAuth(string provider, [FromBody] AuthResponse response)
{
foreach (var config in SSOPlugin.Instance.Configuration.OIDConfigs)
OidConfig config;
try
{
if (config.OIDClientId == response.Provider && config.Enabled)
config = SSOPlugin.Instance.Configuration.OidConfigs[provider];
}
catch (KeyNotFoundException)
{
return BadRequest("No matching provider found");
}
if (config.Enabled)
{
foreach (var kvp in StateManager)
{
foreach (var kvp in StateManager)
if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid)
{
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);
}
var authenticationResult = await Authenticate(kvp.Value.Username, kvp.Value.Admin, config.EnableAuthorization, config.EnableAllFolders, kvp.Value.Folders.ToArray(), response)
.ConfigureAwait(false);
return Ok(authenticationResult);
}
}
}
@@ -255,89 +332,117 @@ public class SSOController : ControllerBase
return Problem("Something went wrong");
}
/// <summary>
/// This is the callback for the SAML flow. This creates a webpage to complete auth.
/// </summary>
/// <param name="provider">The provider that is calling back.</param>
/// <returns>A webpage that will complete the client-side flow.</returns>
[HttpPost("SAML/p/{provider}")]
public ActionResult SAMLPost(string 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)
SamlConfig config;
try
{
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.SamlGenerator(xml: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase()), 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.SamlGenerator(xml: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html);
}
}
}
return Content("401 Forbidden"); // TODO: Return error code as well
}
config = SSOPlugin.Instance.Configuration.SamlConfigs[provider];
}
catch (KeyNotFoundException)
{
return BadRequest("No matching provider found");
}
return Content("no active providers found"); // TODO: Return error code as well
if (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);
}
}
}
_logger.LogWarning("SAML user " + samlResponse.GetNameID() + " has insufficient roles: " + string.Join(", ", samlResponse.GetCustomAttributes("Role")) + ". Expected any one of: " + string.Join(", ", config.Roles) + ".");
return ReturnError(401, "Error. Check permissions.");
}
return ReturnError(400, "No active providers found");
}
/// <summary>
/// Initializes the SAML flow. This will redirect the user to the SAML provider.
/// </summary>
/// <param name="provider">The provider to being the flow with.</param>
/// <returns>A redirect to the SAML provider's auth page.</returns>
[HttpGet("SAML/p/{provider}")]
public RedirectResult SAMLChallenge(string provider)
public RedirectResult SamlChallenge(string provider)
{
foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs)
SamlConfig config;
try
{
if (config.SamlClientId == provider && config.Enabled)
{
var request = new AuthRequest(
config.SamlClientId,
GetRequestBase() + "/sso/SAML/p/" + provider);
config = SSOPlugin.Instance.Configuration.SamlConfigs[provider];
}
catch (KeyNotFoundException)
{
throw new ArgumentException("Provider does not exist");
}
return Redirect(request.GetRedirectUrl(config.SamlEndpoint));
}
if (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");
}
/// <summary>
/// Adds a SAML configuration. If the provider already exists, overwrite it.
/// </summary>
/// <param name="provider">The provider name to add.</param>
/// <param name="newConfig">The SAML configuration object (deserialized) from JSON.</param>
/// <returns>The success result.</returns>
[Authorize(Policy = "RequiresElevation")]
[HttpPost("SAML/Add")]
public void SamlAdd([FromBody] SamlConfig config)
[HttpPost("SAML/Add/{provider}")]
public OkResult SamlAdd(string provider, [FromBody] SamlConfig newConfig)
{
var configuration = SSOPlugin.Instance.Configuration;
for (var i = 0; i < configuration.SamlConfigs.Count; i++)
{
if (configuration.SamlConfigs[i].SamlClientId.Equals(config.SamlClientId))
{
configuration.SamlConfigs.RemoveAt(i);
}
}
configuration.SamlConfigs.Add(config);
configuration.SamlConfigs[provider] = newConfig;
SSOPlugin.Instance.UpdateConfiguration(configuration);
return Ok();
}
/// <summary>
/// Deletes a provider from the configuration with a given ID.
/// </summary>
/// <param name="provider">The ID of the provider to delete.</param>
/// <returns>The success result.</returns>
[Authorize(Policy = "RequiresElevation")]
[HttpGet("SAML/Del/{provider}")]
public void SamlDel(string provider)
public OkResult SamlDel(string provider)
{
var configuration = SSOPlugin.Instance.Configuration;
for (var i = 0; i < configuration.SamlConfigs.Count; i++)
{
if (configuration.SamlConfigs[i].SamlClientId.Equals(provider))
{
configuration.SamlConfigs.RemoveAt(i);
}
}
configuration.SamlConfigs.Remove(provider);
SSOPlugin.Instance.UpdateConfiguration(configuration);
return Ok();
}
/// <summary>
/// Returns a list of all SAML providers configured. Requires administrator privileges.
/// </summary>
/// <returns>A list of all of the Saml providers available.</returns>
[Authorize(Policy = "RequiresElevation")]
[HttpGet("SAML/Get")]
public ActionResult SamlProviders()
@@ -345,52 +450,77 @@ public class SSOController : ControllerBase
return Ok(SSOPlugin.Instance.Configuration.SamlConfigs);
}
[HttpPost("SAML/Auth")]
/// <summary>
/// This endpoint accepts JSON and will authorize the user from the device values passed from the client.
/// </summary>
/// <param name="provider">The provider to authenticate against.</param>
/// <param name="response">The data passed to the client to ensure it is the right one.</param>
/// <returns>JSON for the client to populate information with.</returns>
[HttpPost("SAML/Auth/{provider}")]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult> SamlAuth([FromBody] AuthResponse response)
public async Task<ActionResult> SamlAuth(string provider, [FromBody] AuthResponse response)
{
foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs)
SamlConfig config;
try
{
if (config.SamlClientId == response.Provider && config.Enabled)
{
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;
}
}
config = SSOPlugin.Instance.Configuration.SamlConfigs[provider];
}
catch (KeyNotFoundException)
{
return BadRequest("No matching provider found");
}
if (config.EnableFolderRoles) {
foreach (FolderRoleMap folderRoleMap in config.FolderRoleMapping)
if (config.Enabled)
{
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))
{
if (folderRoleMap.Role.Equals(role)) {
folders.AddRange(folderRoleMap.Folders);
}
folders.AddRange(folderRoleMap.Folders);
}
}
}
var authenticationResult = await Authenticate(samlResponse.GetNameID(), isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), response)
.ConfigureAwait(false);
return Ok(authenticationResult);
}
var authenticationResult = await Authenticate(samlResponse.GetNameID(), isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), response)
.ConfigureAwait(false);
return Ok(authenticationResult);
}
return Problem("Something went wrong");
}
/// <summary>
/// Removes a user from SSO auth and switches it back to another auth provider. Requires administrator privileges.
/// </summary>
/// <param name="username">The username to switch to the new provider.</param>
/// <param name="provider">The new provider to switch to.</param>
/// <returns>Whether this API endpoint succeeded.</returns>
[Authorize(Policy = "RequiresElevation")]
[HttpPost("Unregister/{username}")]
public ActionResult Unregister(string username, [FromBody] string provider)
@@ -401,6 +531,15 @@ public class SSOController : ControllerBase
return Ok();
}
/// <summary>
/// Authenticates the user with the given information.
/// </summary>
/// <param name="username">The username of the user to authenticate.</param>
/// <param name="isAdmin">Determines whether this user is an administrator.</param>
/// <param name="enableAuthorization">Determines whether RBAC is used for this user.</param>
/// <param name="enableAllFolders">Determines whether all folders are enabled.</param>
/// <param name="enabledFolders">Determines which folders should be enabled for this client.</param>
/// <param name="authResponse">The client information to authenticate the user with.</param>
private async Task<AuthenticationResult> Authenticate(string username, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse)
{
User user = null;
@@ -411,8 +550,10 @@ public class SSOController : ControllerBase
_logger.LogInformation("SSO user doesn't exist, creating...");
user = await _userManager.CreateUserAsync(username).ConfigureAwait(false);
}
user.AuthenticationProviderId = GetType().FullName;
if (enableAuthorization) {
if (enableAuthorization)
{
user.SetPermission(PermissionKind.IsAdministrator, isAdmin);
user.SetPermission(PermissionKind.EnableAllFolders, enableAllFolders);
if (!enableAllFolders)
@@ -450,25 +591,58 @@ public class SSOController : ControllerBase
{
return Request.Scheme + "://" + Request.Host + Request.PathBase;
}
private ContentResult ReturnError(int code, string message)
{
var errorResult = new ContentResult();
errorResult.Content = message;
errorResult.ContentType = "text/plain";
errorResult.StatusCode = code;
return errorResult;
}
}
/// <summary>
/// The data the client should pass back to the API.
/// </summary>
public class AuthResponse
{
/// <summary>
/// Gets or sets the device ID of the client.
/// </summary>
public string DeviceID { get; set; }
/// <summary>
/// Gets or sets the device name of the client.
/// </summary>
public string DeviceName { get; set; }
/// <summary>
/// Gets or sets the app name of the client.
/// </summary>
public string AppName { get; set; }
/// <summary>
/// Gets or sets the app version of the client.
/// </summary>
public string AppVersion { get; set; }
/// <summary>
/// Gets or sets the auth data of the client (for authorizing the response).
/// </summary>
public string Data { get; set; }
public string Provider { get; set; }
}
/// <summary>
/// A manager for OpenID to manage the state of the clients.
/// </summary>
public class TimedAuthorizeState
{
/// <summary>
/// Initializes a new instance of the <see cref="TimedAuthorizeState"/> class.
/// </summary>
/// <param name="state">The AuthorizeState to time.</param>
/// <param name="created">When this state was created.</param>
public TimedAuthorizeState(AuthorizeState state, DateTime created)
{
State = state;
@@ -477,17 +651,33 @@ public class TimedAuthorizeState
Admin = false;
}
/// <summary>
/// Gets or sets the Authorization State of the client.
/// </summary>
public AuthorizeState State { get; set; }
/// <summary>
/// Gets or sets when this object was created to time it out.
/// </summary>
public DateTime Created { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user is valid.
/// </summary>
public bool Valid { get; set; }
/// <summary>
/// Gets or sets the user tied to the state.
/// </summary>
public string Username { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user is an administrator.
/// </summary>
public bool Admin { get; set; }
public string Email { get; set; }
/// <summary>
/// Gets or sets the folders the user is allowed access to.
/// </summary>
public List<string> Folders { get; set; }
}
+100 -12
View File
@@ -13,80 +13,168 @@ public class PluginConfiguration : MediaBrowser.Model.Plugins.BasePluginConfigur
/// </summary>
public PluginConfiguration()
{
SamlConfigs = new List<SamlConfig>();
OIDConfigs = new List<OIDConfig>();
SamlConfigs = new SerializableDictionary<string, SamlConfig>();
OidConfigs = new SerializableDictionary<string, OidConfig>();
}
[XmlArray("SamlConfigs")]
[XmlArrayItem(typeof(SamlConfig), ElementName = "SamlConfigs")]
public List<SamlConfig> SamlConfigs { get; set; }
/// <summary>
/// Gets or sets the SAML configurations available.
/// </summary>
[XmlElement("SamlConfigs")]
public SerializableDictionary<string, SamlConfig> SamlConfigs { get; set; }
[XmlArray("OIDConfigs")]
[XmlArrayItem(typeof(OIDConfig), ElementName = "OIDConfigs")]
public List<OIDConfig> OIDConfigs { get; set; }
/// <summary>
/// Gets or sets the OpenID configurations available.
/// </summary>
[XmlElement("OidConfigs")]
public SerializableDictionary<string, OidConfig> OidConfigs { get; set; }
}
/// <summary>
/// The configuration required for a SAML flow.
/// </summary>
[XmlRoot("PluginConfiguration")]
public class SamlConfig
{
/// <summary>
/// Gets or sets the SAML information endpoint.
/// </summary>
public string SamlEndpoint { get; set; }
/// <summary>
/// Gets or sets the SAML provider's client ID.
/// </summary>
public string SamlClientId { get; set; }
/// <summary>
/// Gets or sets the SAML public key.
/// </summary>
public string SamlCertificate { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the provider is enabled.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets a value indicating whether RBAC is enabled.
/// </summary>
public bool EnableAuthorization { get; set; }
/// <summary>
/// Gets or sets a value indicating whether all folders are allowed by default.
/// </summary>
public bool EnableAllFolders { get; set; }
/// <summary>
/// Gets or sets what folders should users have access to by default.
/// </summary>
public string[] EnabledFolders { get; set; }
/// <summary>
/// Gets or sets the roles that are checked to determine whether the user is an administrator.
/// </summary>
public string[] AdminRoles { get; set; }
/// <summary>
/// Gets or sets what roles are checked to determine whether the user is allowed to use Jellyfin.
/// </summary>
public string[] Roles { get; set; }
/// <summary>
/// Gets or sets a value indicating whether RBAC is used to manage folder access.
/// </summary>
public bool EnableFolderRoles { get; set; }
/// <summary>
/// Gets or sets which folders map to what roles in RBAC.
/// </summary>
[XmlArray("FolderRoleMappings")]
[XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMappings")]
public List<FolderRoleMap> FolderRoleMapping { get; set; }
}
/// <summary>
/// The configuration required for a OpenID flow.
/// </summary>
[XmlRoot("PluginConfiguration")]
public class OIDConfig
public class OidConfig
{
public string OIDEndpoint { get; set; }
/// <summary>
/// Gets or sets the OpenID well-known information endpoint.
/// </summary>
public string OidEndpoint { get; set; }
public string OIDClientId { get; set; }
/// <summary>
/// Gets or sets OpenID client ID.
/// </summary>
public string OidClientId { get; set; }
public string OIDSecret { get; set; }
/// <summary>
/// Gets or sets OpenID shared secret.
/// </summary>
public string OidSecret { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the provider is enabled.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets a value indicating whether RBAC is enabled.
/// </summary>
public bool EnableAuthorization { get; set; }
/// <summary>
/// Gets or sets a value indicating whether all folders are allowed by default.
/// </summary>
public bool EnableAllFolders { get; set; }
/// <summary>
/// Gets or sets what folders should users have access to by default.
/// </summary>
public string[] EnabledFolders { get; set; }
/// <summary>
/// Gets or sets the roles that are checked to determine whether the user is an administrator.
/// </summary>
public string[] AdminRoles { get; set; }
/// <summary>
/// Gets or sets what roles are checked to determine whether the user is allowed to use Jellyfin.
/// </summary>
public string[] Roles { get; set; }
/// <summary>
/// Gets or sets a value indicating whether RBAC is used to manage folder access.
/// </summary>
public bool EnableFolderRoles { get; set; }
/// <summary>
/// Gets or sets which folders map to what roles in RBAC.
/// </summary>
[XmlArray("FolderRoleMappings")]
[XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMappings")]
public List<FolderRoleMap> FolderRoleMapping { get; set; }
/// <summary>
/// Gets or sets the claim to check roles against. Separated by "."s.
/// </summary>
public string RoleClaim { get; set; }
}
/// <summary>
/// The OpenID client ID.
/// </summary>
public class FolderRoleMap
{
/// <summary>
/// Gets or sets the role of the mapping.
/// </summary>
public string Role { get; set; }
/// <summary>
/// Gets or sets the folders that are allowed from the given role.
/// </summary>
public List<string> Folders { get; set; }
}
+2 -2
View File
@@ -3,8 +3,8 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.SSO_Auth</RootNamespace>
<AssemblyVersion>3.0.0.0</AssemblyVersion>
<FileVersion>3.0.0.0</FileVersion>
<AssemblyVersion>3.2.0.0</AssemblyVersion>
<FileVersion>3.2.0.0</FileVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
+21
View File
@@ -8,20 +8,41 @@ using MediaBrowser.Model.Serialization;
namespace Jellyfin.Plugin.SSO_Auth;
/// <summary>
/// The SSO plugin class.
/// </summary>
public class SSOPlugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>
/// Initializes a new instance of the <see cref="SSOPlugin"/> class.
/// </summary>
/// <param name="applicationPaths">Internal Jellyfin interface for the ApplicationPath.</param>
/// <param name="xmlSerializer">Internal Jellyfin interface for the XML information.</param>
public SSOPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
/// <summary>
/// Gets the instance of the SSO plugin.
/// </summary>
public static SSOPlugin Instance { get; private set; }
/// <summary>
/// Gets the name of the SSO plugin.
/// </summary>
public override string Name => "SSO-Auth";
/// <summary>
/// Gets the GUID of the SSO plugin.
/// </summary>
public override Guid Id => Guid.Parse("505ce9d1-d916-42fa-86ca-673ef241d7df");
/// <summary>
/// Returns the available internal web pages of this plugin.
/// </summary>
/// <returns>A list of internal webpages in this application.</returns>
public IEnumerable<PluginPageInfo> GetPages()
{
yield return new PluginPageInfo
+99
View File
@@ -18,33 +18,61 @@ using System.Xml;
namespace Jellyfin.Plugin.SSO_Auth;
/// <summary>
/// Represents a SAML response.
/// </summary>
public class Response
{
private readonly X509Certificate2 _certificate;
private XmlDocument _xmlDoc;
private XmlNamespaceManager _xmlNameSpaceManager; // we need this one to run our XPath queries on the SAML XML
/// <summary>
/// Initializes a new instance of the <see cref="Response"/> class.
/// </summary>
/// <param name="certificateStr">The certificate formatted as a Base64 string.</param>
/// <param name="responseString">The SAML response formatted as a string.</param>
public Response(string certificateStr, string responseString)
: this(Convert.FromBase64String(certificateStr), responseString)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="Response"/> class.
/// </summary>
/// <param name="certificateBytes">The certificate formatted as an array of bytes.</param>
/// <param name="responseString">The SAML response formatted as a string.</param>
public Response(byte[] certificateBytes, string responseString) : this(certificateBytes)
{
LoadXmlFromBase64(responseString);
}
/// <summary>
/// Initializes a new instance of the <see cref="Response"/> class.
/// </summary>
/// <param name="certificateStr">The certificate formatted as a Base64 string.</param>
public Response(string certificateStr) : this(Convert.FromBase64String(certificateStr))
{
}
/// <summary>
/// Initializes a new instance of the <see cref="Response"/> class.
/// </summary>
/// <param name="certificateBytes">The certificate formatted as an array of bytes.</param>
public Response(byte[] certificateBytes)
{
_certificate = new X509Certificate2(certificateBytes);
}
/// <summary>
/// Gets the SAML response's XML data.
/// </summary>
public string Xml => _xmlDoc.OuterXml;
/// <summary>
/// Loads XML from the parameter into the instance's XML data.
/// </summary>
/// <param name="xml">The XML string to put into the class.</param>
public void LoadXml(string xml)
{
_xmlDoc = new XmlDocument();
@@ -55,11 +83,19 @@ public class Response
_xmlNameSpaceManager = GetNamespaceManager(); // lets construct a "manager" for XPath queries
}
/// <summary>
/// Loads Base64 encoded XML from the parameter into the instance's XML data.
/// </summary>
/// <param name="response">The Base64 encoded XML string to put into the class.</param>
public void LoadXmlFromBase64(string response)
{
LoadXml(Encoding.UTF8.GetString(Convert.FromBase64String(response)));
}
/// <summary>
/// Checks whether the XML response is valid by verifying the signature.
/// </summary>
/// <returns>Whether the XML response is valid.</returns>
public bool IsValid()
{
var nodeList = _xmlDoc.SelectNodes("//ds:Signature", _xmlNameSpaceManager);
@@ -118,17 +154,29 @@ public class Response
return DateTime.UtcNow > expirationDate.ToUniversalTime();
}
/// <summary>
/// Gets the name ID attribute from the XML response.
/// </summary>
/// <returns>The name ID attribute.</returns>
public string GetNameID()
{
var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:Subject/saml:NameID", _xmlNameSpaceManager);
return node.InnerText;
}
/// <summary>
/// Gets the UPN attribute from the XML response.
/// </summary>
/// <returns>The UPN attribute.</returns>
public virtual string GetUpn()
{
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn");
}
/// <summary>
/// Gets the email attribute from the XML response.
/// </summary>
/// <returns>The email attribute.</returns>
public virtual string GetEmail()
{
return GetCustomAttribute("User.email")
@@ -138,6 +186,10 @@ public class Response
?? GetCustomAttribute("mail");
}
/// <summary>
/// Gets the First Name attribute from the XML response.
/// </summary>
/// <returns>The First Name attribute.</returns>
public virtual string GetFirstName()
{
return GetCustomAttribute("first_name")
@@ -148,6 +200,10 @@ public class Response
?? GetCustomAttribute("givenName");
}
/// <summary>
/// Gets the Last Name attribute from the XML response.
/// </summary>
/// <returns>The Last Name attribute.</returns>
public virtual string GetLastName()
{
return GetCustomAttribute("last_name")
@@ -158,18 +214,30 @@ public class Response
?? GetCustomAttribute("sn");
}
/// <summary>
/// Gets the department attribute from the XML response.
/// </summary>
/// <returns>The department attribute.</returns>
public virtual string GetDepartment()
{
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department")
?? GetCustomAttribute("department");
}
/// <summary>
/// Gets the phone attribute from the XML response.
/// </summary>
/// <returns>The phone attribute.</returns>
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");
}
/// <summary>
/// Gets the company attribute from the XML response.
/// </summary>
/// <returns>The company attribute.</returns>
public virtual string GetCompany()
{
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/companyname")
@@ -177,18 +245,32 @@ public class Response
?? GetCustomAttribute("User.CompanyName");
}
/// <summary>
/// Gets the location attribute from the XML response.
/// </summary>
/// <returns>The location attribute.</returns>
public virtual string GetLocation()
{
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/location")
?? GetCustomAttribute("physicalDeliveryOfficeName");
}
/// <summary>
/// Gets the first custom attribute from the XML response.
/// </summary>
/// <param name="attr">The custom attribute to query.</param>
/// <returns>The custom attribute.</returns>
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;
}
/// <summary>
/// Gets the values for a custom attribute from the XML response.
/// </summary>
/// <param name="attr">The custom attribute to query.</param>
/// <returns>The custom attributes.</returns>
public List<string> GetCustomAttributes(string attr)
{
var node = _xmlDoc.SelectNodes("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager);
@@ -197,6 +279,7 @@ public class Response
{
output.Add(item?.InnerText);
}
return output;
}
@@ -213,6 +296,9 @@ public class Response
}
}
/// <summary>
/// Represents a SAML request.
/// </summary>
public class AuthRequest
{
private readonly string _id;
@@ -221,6 +307,11 @@ public class AuthRequest
private readonly string _issuer;
private readonly string _assertionConsumerServiceUrl;
/// <summary>
/// Initializes a new instance of the <see cref="AuthRequest"/> class..
/// </summary>
/// <param name="issuer">The issuer of the SAML request.</param>
/// <param name="assertionConsumerServiceUrl">The SAML assertion URL.</param>
public AuthRequest(string issuer, string assertionConsumerServiceUrl)
{
_id = "_" + Guid.NewGuid().ToString();
@@ -230,6 +321,9 @@ public class AuthRequest
_assertionConsumerServiceUrl = assertionConsumerServiceUrl;
}
/// <summary>
/// The formatting of the AuthRequest.
/// </summary>
public enum AuthRequestFormat
{
/// <summary>
@@ -238,6 +332,11 @@ public class AuthRequest
Base64 = 1
}
/// <summary>
/// Gets the SAML request.
/// </summary>
/// <param name="format">The format the request should be returned in.</param>
/// <returns>The request as a string, either Base64 or not, depending on the format parameter.</returns>
public string GetRequest(AuthRequestFormat format)
{
using var sw = new StringWriter();
+141
View File
@@ -0,0 +1,141 @@
using System.Collections.Generic;
using System.Xml.Serialization;
/// <summary>
/// For some reason, the generic Dictionary in .net 2.0 is not XML serializable. The following code snippet is a xml serializable generic dictionary. The dictionary is serializable by implementing the IXmlSerializable interface.
/// Also see https://weblogs.asp.net/pwelter34/444961 for additional information.
/// </summary>
/// <typeparam name="TKey">Type of the dictionary key.</typeparam>
/// <typeparam name="TValue">Type of the dictionary value.</typeparam>
[XmlRoot("dictionary")]
public class SerializableDictionary<TKey, TValue>
: Dictionary<TKey, TValue>, IXmlSerializable
{
/// <summary>
/// Initializes a new instance of the <see cref="SerializableDictionary{TKey,TValue}"/> class.
/// </summary>
public SerializableDictionary()
{
// Empty
}
/// <summary>
/// Initializes a new instance of the <see cref="SerializableDictionary{TKey,TValue}"/> class.
/// </summary>
/// <param name="dictionary">Dictionary to convert from.</param>
public SerializableDictionary(IDictionary<TKey, TValue> dictionary) : base(dictionary)
{
// Empty
}
/// <summary>
/// Initializes a new instance of the <see cref="SerializableDictionary{TKey,TValue}"/> class.
/// </summary>
/// <param name="dictionary">Dictionary to convert from.</param>
/// <param name="comparer">Comparer for the dictionary.</param>
public SerializableDictionary(IDictionary<TKey, TValue> dictionary, IEqualityComparer<TKey> comparer) : base(dictionary, comparer)
{
// Empty
}
/// <summary>
/// Initializes a new instance of the <see cref="SerializableDictionary{TKey,TValue}"/> class.
/// </summary>
/// <param name="comparer">Comparer for the dictionary.</param>
public SerializableDictionary(IEqualityComparer<TKey> comparer) : base(comparer)
{
// Empty
}
/// <summary>
/// Initializes a new instance of the <see cref="SerializableDictionary{TKey,TValue}"/> class.
/// </summary>
/// <param name="capacity">Capacity of the dictionary.</param>
public SerializableDictionary(int capacity) : base(capacity)
{
// Empty
}
/// <summary>
/// Initializes a new instance of the <see cref="SerializableDictionary{TKey,TValue}"/> class.
/// </summary>
/// <param name="capacity">Capacity of the dictionary.</param>
/// <param name="comparer">Comparer for the dictionary.</param>
public SerializableDictionary(int capacity, IEqualityComparer<TKey> comparer) : base(capacity, comparer)
{
// Empty
}
/// <summary>
/// Gets the schema of the XML object.
/// </summary>
/// <returns>Nothing.</returns>
public System.Xml.Schema.XmlSchema GetSchema()
{
return null;
}
/// <summary>
/// Reads XML and changes this object to be an instance of that data.
/// </summary>
/// <param name="reader">The XML reader to read from.</param>
public void ReadXml(System.Xml.XmlReader reader)
{
XmlSerializer keySerializer = new XmlSerializer(typeof(TKey));
XmlSerializer valueSerializer = new XmlSerializer(typeof(TValue));
bool wasEmpty = reader.IsEmptyElement;
reader.Read();
if (wasEmpty)
{
return;
}
while (reader.NodeType != System.Xml.XmlNodeType.EndElement)
{
reader.ReadStartElement("item");
reader.ReadStartElement("key");
TKey key = (TKey)keySerializer.Deserialize(reader);
reader.ReadEndElement();
reader.ReadStartElement("value");
TValue value = (TValue)valueSerializer.Deserialize(reader);
reader.ReadEndElement();
this.Add(key, value);
reader.ReadEndElement();
reader.MoveToContent();
}
reader.ReadEndElement();
}
/// <summary>
/// Writes XML to the XML writer from this object.
/// </summary>
/// <param name="writer">An instance of the XmlWriter class.</param>
public void WriteXml(System.Xml.XmlWriter writer)
{
XmlSerializer keySerializer = new XmlSerializer(typeof(TKey));
XmlSerializer valueSerializer = new XmlSerializer(typeof(TValue));
foreach (TKey key in this.Keys)
{
writer.WriteStartElement("item");
writer.WriteStartElement("key");
keySerializer.Serialize(writer, key);
writer.WriteEndElement();
writer.WriteStartElement("value");
TValue value = this[key];
valueSerializer.Serialize(writer, value);
writer.WriteEndElement();
writer.WriteEndElement();
}
}
}
+35 -54
View File
@@ -1,9 +1,19 @@
namespace Jellyfin.Plugin.SSO_Auth;
/// <summary>
/// A helper class to return HTML for the client's auth flow.
/// </summary>
public static class WebResponse
{
/// <summary>
/// The shared HTML between all of the responses.
/// </summary>
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();
@@ -389,22 +399,39 @@ function getDeviceName() {
return deviceName;
}
const sleep = (milliseconds) => {
return new Promise(resolve => setTimeout(resolve, milliseconds))
}
";
public static string OIDGenerator(string data, string provider, string baseUrl)
/// <summary>
/// A generator for the web response that incorporates the data from the server.
/// </summary>
/// <param name="data">The data of the auth flow. Is signed XML for SAML and a state ID for OpenID.</param>
/// <param name="provider">The name of the provider to callback to.</param>
/// <param name="baseUrl">The base URL of the Jellyfin installation.</param>
/// <param name="mode">The mode of the function; SAML or OID.</param>
/// <returns>A string with the HTML to serve to the client.</returns>
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};
var url = '" + baseUrl + @"/sso/OID/Auth';
var url = '" + baseUrl + "/sso/" + mode + "/Auth/" + provider + @"';
let response = await new Promise(resolve => {
var xhr = new XMLHttpRequest();
@@ -428,60 +455,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, 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' sandbox='allow-same-origin allow-forms allow-scripts' src='" + baseUrl + "' style='position: absolute;width:0;height:0;border:0;'></iframe></body></html>";
}
}
+4 -1
View File
@@ -1,7 +1,7 @@
name: "SSO Authentication"
guid: "505ce9d1-d916-42fa-86ca-673ef241d7df"
imageUrl: "https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/main/img/logo.png"
version: "3.0.0.0"
version: "3.2.0.0"
targetAbi: "10.8.0.0"
framework: "net6.0"
owner: "9p4"
@@ -15,6 +15,9 @@ artifacts:
- "IdentityModel.OidcClient.dll"
- "IdentityModel.dll"
changelog: |
3.2.0.0: Switch to hashmaps (BREAKING) for performance. Dump expected permissions in logs on error.
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