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 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! 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 ## 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/): 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. 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 Documents on
- Sign Assertions off - Sign Assertions off
- Client Signature Required 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) - 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 ### OpenID
Example for adding an OpenID configuration with the API using [curl](https://curl.se/) 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) The OpenID provider must have the following configuration (again, I am using Keycloak)
- Access Type: Confidential - Access Type: Confidential
- Standard Flow Enabled - 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) - 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 ## API Endpoints
@@ -94,21 +117,20 @@ The API is all done from a base URL of `/sso/`
#### Flow #### 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. - 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/clientid`: This is the SAML initiator: it will begin the authorization flow for SAML with a given client ID. - 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`: 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/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. - `deviceId`: string. Device ID.
- `deviceName`: string. Device name. - `deviceName`: string. Device name.
- `appName`: string. App name. - `appName`: string. App name.
- `appVersion`: string. App version. - `appVersion`: string. App version.
- `data`: string. The signed SAML XML request. Used to verify a request. - `data`: string. The signed SAML XML request. Used to verify a request.
- `provider`: string. The current SAML client ID.
#### Configuration #### 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"` 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. - `samlEndpoint`: string. The SAML endpoint.
- `samlClientId`: string. The SAML client ID. - `samlClientId`: string. The SAML client ID.
- `samlCertificate`: string. The base64 encoded SAML certificate. - `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). - `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. - `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. - `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. - 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 #### Flow
- GET `OID/r/clientId`: This is the OpenID callback path. This will return HTML and JavaScript for the client to login. - 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/clientId`: This is the OpenID initiator: it will begin the authorization flow for OpenID with a given client ID. - 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`: 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: - 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. - `deviceId`: string. Device ID.
- `deviceName`: string. Device name. - `deviceName`: string. Device name.
- `appName`: string. App name. - `appName`: string. App name.
- `appVersion`: string. App version. - `appVersion`: string. App version.
- `data`: string. The OpenID state. Used to verify a request. - `data`: string. The OpenID state. Used to verify a request.
- `provider`: string. The current OpenID client ID.
#### Configuration #### 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"` 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. - `oidEndpoint`: string. The OpenID endpoint. Must have a `.well-known` path available.
- `oidClientId`: string. The OpenID client ID. - `oidClientId`: string. The OpenID client ID.
- `oidSecret`: string. The OpenID secret. - `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). - `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. - `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. - `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. - `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/clientId`: This removes a configuration for OpenID for a given client ID. - 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/Get`: Lists the configurations currently available.
- GET `OID/States`: Lists currently active OpenID flows in progress. - 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 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 ## 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. 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. 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;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Net.Mime; using System.Net.Mime;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using IdentityModel.OidcClient; using IdentityModel.OidcClient;
using Jellyfin.Data.Entities; using Jellyfin.Data.Entities;
@@ -13,6 +15,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Jellyfin.Plugin.SSO_Auth.Api; namespace Jellyfin.Plugin.SSO_Auth.Api;
@@ -42,41 +45,143 @@ public class SSOController : ControllerBase
_logger.LogInformation("SSO Controller initialized"); _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}")] [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 OidConfig config;
foreach (var config in SSOPlugin.Instance.Configuration.OIDConfigs) 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, StateManager[Request.Query["state"]].Username = claim.Value;
ClientId = config.OIDClientId, if (config.Roles.Length == 0)
ClientSecret = config.OIDSecret, {
RedirectUri = GetRequestBase() + "/sso/OID/r/" + provider, StateManager[Request.Query["state"]].Valid = true;
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);
} }
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); segments[i] = segments[i].Replace("\\.", ".");
} else {
StateManager[Request.Query["state"]].Folders = new List<string>();
} }
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) foreach (var claim in result.User.Claims)
{ {
if (claim.Type == "preferred_username") if (claim.Type == "sub")
{ {
StateManager[Request.Query["state"]].Username = claim.Value; StateManager[Request.Query["state"]].Username = claim.Value;
if (config.Roles.Length == 0) if (config.Roles.Length == 0)
@@ -84,170 +189,142 @@ public class SSOController : ControllerBase
StateManager[Request.Query["state"]].Valid = true; StateManager[Request.Query["state"]].Valid = true;
} }
} }
}
}
// Role processing if (StateManager[Request.Query["state"]].Valid)
if (claim.Type == config.RoleClaim) {
{ return Content(WebResponse.Generator(data: Request.Query["state"], provider: provider, baseUrl: GetRequestBase(), mode: "OID"), MediaTypeNames.Text.Html);
List<string> roles = JsonConvert.DeserializeObject<IDictionary<string, List<string>>>(claim.Value)["roles"]; // Might need error handling here }
foreach (string role in roles) else
{ {
// Check if allowed to login based on roles _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) + ".");
if (config.Roles.Length != 0) return ReturnError(401, "Error. Check permissions.");
{
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
}
} }
} }
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}")] [HttpGet("OID/p/{provider}")]
public async Task<ActionResult> OIDChallenge(string provider) public async Task<ActionResult> OidChallenge(string provider)
{ {
Invalidate(); 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,
Authority = config.OIDEndpoint, ClientSecret = config.OidSecret,
ClientId = config.OIDClientId, RedirectUri = GetRequestBase() + "/sso/OID/r/" + provider,
ClientSecret = config.OIDSecret, Scope = "openid profile"
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);
options.Policy.Discovery.ValidateEndpoints = false; // For Google and other providers with different endpoints var state = await oidcClient.PrepareLoginAsync().ConfigureAwait(false);
var oidcClient = new OidcClient(options); StateManager.Add(state.State, new TimedAuthorizeState(state, DateTime.Now));
var state = await oidcClient.PrepareLoginAsync().ConfigureAwait(false); return Redirect(state.StartUrl);
StateManager.Add(state.State, new TimedAuthorizeState(state, DateTime.Now));
return Redirect(state.StartUrl);
}
} }
throw new ArgumentException("Provider does not exist"); 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")] [Authorize(Policy = "RequiresElevation")]
[HttpPost("OID/Add")] [HttpPost("OID/Add/{provider}")]
public void OIDAdd([FromBody] OIDConfig config) public void OidAdd(string provider, [FromBody] OidConfig config)
{ {
var configuration = SSOPlugin.Instance.Configuration; var configuration = SSOPlugin.Instance.Configuration;
for (var i = 0; i < configuration.OIDConfigs.Count; i++) configuration.OidConfigs[provider] = config;
{
if (configuration.OIDConfigs[i].OIDClientId.Equals(config.OIDClientId))
{
configuration.OIDConfigs.RemoveAt(i);
}
}
configuration.OIDConfigs.Add(config);
SSOPlugin.Instance.UpdateConfiguration(configuration); SSOPlugin.Instance.UpdateConfiguration(configuration);
} }
/// <summary>
/// Deletes an OpenID provider.
/// </summary>
/// <param name="provider">Name of provider to delete.</param>
[Authorize(Policy = "RequiresElevation")] [Authorize(Policy = "RequiresElevation")]
[HttpGet("OID/Del/{provider}")] [HttpGet("OID/Del/{provider}")]
public void OIDDel(string provider) public void OidDel(string provider)
{ {
var configuration = SSOPlugin.Instance.Configuration; var configuration = SSOPlugin.Instance.Configuration;
for (var i = 0; i < configuration.OIDConfigs.Count; i++) configuration.OidConfigs.Remove(provider);
{
if (configuration.OIDConfigs[i].OIDClientId.Equals(provider))
{
configuration.OIDConfigs.RemoveAt(i);
}
}
SSOPlugin.Instance.UpdateConfiguration(configuration); SSOPlugin.Instance.UpdateConfiguration(configuration);
} }
/// <summary>
/// Lists the OpenID providers configured. Requires administrator privileges.
/// </summary>
/// <returns>The list of OpenID configurations.</returns>
[Authorize(Policy = "RequiresElevation")] [Authorize(Policy = "RequiresElevation")]
[HttpGet("OID/Get")] [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")] [Authorize(Policy = "RequiresElevation")]
[HttpGet("OID/States")] [HttpGet("OID/States")]
public ActionResult OIDStates() public ActionResult OidStates()
{ {
return Ok(StateManager); 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)] [Consumes(MediaTypeNames.Application.Json)]
[Produces(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);
var authenticationResult = await Authenticate(kvp.Value.Username, kvp.Value.Admin, config.EnableAuthorization, config.EnableAllFolders, kvp.Value.Folders.ToArray(), response) return Ok(authenticationResult);
.ConfigureAwait(false);
return Ok(authenticationResult);
}
} }
} }
} }
@@ -255,89 +332,117 @@ public class SSOController : ControllerBase
return Problem("Something went wrong"); 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}")] [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 SamlConfig config;
foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs) try
{ {
if (config.SamlClientId == provider && config.Enabled) config = SSOPlugin.Instance.Configuration.SamlConfigs[provider];
{ }
var samlResponse = new Response(config.SamlCertificate, Request.Form["SAMLResponse"]); catch (KeyNotFoundException)
// If no roles are configured, don't use RBAC {
if (config.Roles.Length == 0) return BadRequest("No matching provider found");
{
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
}
} }
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}")] [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) config = SSOPlugin.Instance.Configuration.SamlConfigs[provider];
{ }
var request = new AuthRequest( catch (KeyNotFoundException)
config.SamlClientId, {
GetRequestBase() + "/sso/SAML/p/" + provider); 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"); 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")] [Authorize(Policy = "RequiresElevation")]
[HttpPost("SAML/Add")] [HttpPost("SAML/Add/{provider}")]
public void SamlAdd([FromBody] SamlConfig config) public OkResult SamlAdd(string provider, [FromBody] SamlConfig newConfig)
{ {
var configuration = SSOPlugin.Instance.Configuration; var configuration = SSOPlugin.Instance.Configuration;
for (var i = 0; i < configuration.SamlConfigs.Count; i++) configuration.SamlConfigs[provider] = newConfig;
{
if (configuration.SamlConfigs[i].SamlClientId.Equals(config.SamlClientId))
{
configuration.SamlConfigs.RemoveAt(i);
}
}
configuration.SamlConfigs.Add(config);
SSOPlugin.Instance.UpdateConfiguration(configuration); 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")] [Authorize(Policy = "RequiresElevation")]
[HttpGet("SAML/Del/{provider}")] [HttpGet("SAML/Del/{provider}")]
public void SamlDel(string provider) public OkResult SamlDel(string provider)
{ {
var configuration = SSOPlugin.Instance.Configuration; var configuration = SSOPlugin.Instance.Configuration;
for (var i = 0; i < configuration.SamlConfigs.Count; i++) configuration.SamlConfigs.Remove(provider);
{
if (configuration.SamlConfigs[i].SamlClientId.Equals(provider))
{
configuration.SamlConfigs.RemoveAt(i);
}
}
SSOPlugin.Instance.UpdateConfiguration(configuration); 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")] [Authorize(Policy = "RequiresElevation")]
[HttpGet("SAML/Get")] [HttpGet("SAML/Get")]
public ActionResult SamlProviders() public ActionResult SamlProviders()
@@ -345,52 +450,77 @@ public class SSOController : ControllerBase
return Ok(SSOPlugin.Instance.Configuration.SamlConfigs); 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)] [Consumes(MediaTypeNames.Application.Json)]
[Produces(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) config = SSOPlugin.Instance.Configuration.SamlConfigs[provider];
{ }
bool isAdmin = false; catch (KeyNotFoundException)
var samlResponse = new Response(config.SamlCertificate, response.Data); {
List<string> folders; return BadRequest("No matching provider found");
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) { if (config.Enabled)
foreach (FolderRoleMap folderRoleMap in config.FolderRoleMapping) {
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"); 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")] [Authorize(Policy = "RequiresElevation")]
[HttpPost("Unregister/{username}")] [HttpPost("Unregister/{username}")]
public ActionResult Unregister(string username, [FromBody] string provider) public ActionResult Unregister(string username, [FromBody] string provider)
@@ -401,6 +531,15 @@ public class SSOController : ControllerBase
return Ok(); 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) private async Task<AuthenticationResult> Authenticate(string username, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse)
{ {
User user = null; User user = null;
@@ -411,8 +550,10 @@ public class SSOController : ControllerBase
_logger.LogInformation("SSO user doesn't exist, creating..."); _logger.LogInformation("SSO user doesn't exist, creating...");
user = await _userManager.CreateUserAsync(username).ConfigureAwait(false); user = await _userManager.CreateUserAsync(username).ConfigureAwait(false);
} }
user.AuthenticationProviderId = GetType().FullName; user.AuthenticationProviderId = GetType().FullName;
if (enableAuthorization) { if (enableAuthorization)
{
user.SetPermission(PermissionKind.IsAdministrator, isAdmin); user.SetPermission(PermissionKind.IsAdministrator, isAdmin);
user.SetPermission(PermissionKind.EnableAllFolders, enableAllFolders); user.SetPermission(PermissionKind.EnableAllFolders, enableAllFolders);
if (!enableAllFolders) if (!enableAllFolders)
@@ -450,25 +591,58 @@ public class SSOController : ControllerBase
{ {
return Request.Scheme + "://" + Request.Host + Request.PathBase; 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 public class AuthResponse
{ {
/// <summary>
/// Gets or sets the device ID of the client.
/// </summary>
public string DeviceID { get; set; } public string DeviceID { get; set; }
/// <summary>
/// Gets or sets the device name of the client.
/// </summary>
public string DeviceName { get; set; } public string DeviceName { get; set; }
/// <summary>
/// Gets or sets the app name of the client.
/// </summary>
public string AppName { get; set; } public string AppName { get; set; }
/// <summary>
/// Gets or sets the app version of the client.
/// </summary>
public string AppVersion { get; set; } 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 Data { get; set; }
public string Provider { get; set; }
} }
/// <summary>
/// A manager for OpenID to manage the state of the clients.
/// </summary>
public class TimedAuthorizeState 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) public TimedAuthorizeState(AuthorizeState state, DateTime created)
{ {
State = state; State = state;
@@ -477,17 +651,33 @@ public class TimedAuthorizeState
Admin = false; Admin = false;
} }
/// <summary>
/// Gets or sets the Authorization State of the client.
/// </summary>
public AuthorizeState State { get; set; } public AuthorizeState State { get; set; }
/// <summary>
/// Gets or sets when this object was created to time it out.
/// </summary>
public DateTime Created { get; set; } public DateTime Created { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user is valid.
/// </summary>
public bool Valid { get; set; } public bool Valid { get; set; }
/// <summary>
/// Gets or sets the user tied to the state.
/// </summary>
public string Username { get; set; } 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 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; } public List<string> Folders { get; set; }
} }
+100 -12
View File
@@ -13,80 +13,168 @@ public class PluginConfiguration : MediaBrowser.Model.Plugins.BasePluginConfigur
/// </summary> /// </summary>
public PluginConfiguration() public PluginConfiguration()
{ {
SamlConfigs = new List<SamlConfig>(); SamlConfigs = new SerializableDictionary<string, SamlConfig>();
OIDConfigs = new List<OIDConfig>(); OidConfigs = new SerializableDictionary<string, OidConfig>();
} }
[XmlArray("SamlConfigs")] /// <summary>
[XmlArrayItem(typeof(SamlConfig), ElementName = "SamlConfigs")] /// Gets or sets the SAML configurations available.
public List<SamlConfig> SamlConfigs { get; set; } /// </summary>
[XmlElement("SamlConfigs")]
public SerializableDictionary<string, SamlConfig> SamlConfigs { get; set; }
[XmlArray("OIDConfigs")] /// <summary>
[XmlArrayItem(typeof(OIDConfig), ElementName = "OIDConfigs")] /// Gets or sets the OpenID configurations available.
public List<OIDConfig> OIDConfigs { get; set; } /// </summary>
[XmlElement("OidConfigs")]
public SerializableDictionary<string, OidConfig> OidConfigs { get; set; }
} }
/// <summary>
/// The configuration required for a SAML flow.
/// </summary>
[XmlRoot("PluginConfiguration")] [XmlRoot("PluginConfiguration")]
public class SamlConfig public class SamlConfig
{ {
/// <summary>
/// Gets or sets the SAML information endpoint.
/// </summary>
public string SamlEndpoint { get; set; } public string SamlEndpoint { get; set; }
/// <summary>
/// Gets or sets the SAML provider's client ID.
/// </summary>
public string SamlClientId { get; set; } public string SamlClientId { get; set; }
/// <summary>
/// Gets or sets the SAML public key.
/// </summary>
public string SamlCertificate { get; set; } public string SamlCertificate { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the provider is enabled.
/// </summary>
public bool Enabled { get; set; } public bool Enabled { get; set; }
/// <summary>
/// Gets or sets a value indicating whether RBAC is enabled.
/// </summary>
public bool EnableAuthorization { get; set; } 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; } public bool EnableAllFolders { get; set; }
/// <summary>
/// Gets or sets what folders should users have access to by default.
/// </summary>
public string[] EnabledFolders { get; set; } 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; } 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; } 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; } public bool EnableFolderRoles { get; set; }
/// <summary>
/// Gets or sets which folders map to what roles in RBAC.
/// </summary>
[XmlArray("FolderRoleMappings")] [XmlArray("FolderRoleMappings")]
[XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMappings")] [XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMappings")]
public List<FolderRoleMap> FolderRoleMapping { get; set; } public List<FolderRoleMap> FolderRoleMapping { get; set; }
} }
/// <summary>
/// The configuration required for a OpenID flow.
/// </summary>
[XmlRoot("PluginConfiguration")] [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; } public bool Enabled { get; set; }
/// <summary>
/// Gets or sets a value indicating whether RBAC is enabled.
/// </summary>
public bool EnableAuthorization { get; set; } 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; } public bool EnableAllFolders { get; set; }
/// <summary>
/// Gets or sets what folders should users have access to by default.
/// </summary>
public string[] EnabledFolders { get; set; } 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; } 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; } 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; } public bool EnableFolderRoles { get; set; }
/// <summary>
/// Gets or sets which folders map to what roles in RBAC.
/// </summary>
[XmlArray("FolderRoleMappings")] [XmlArray("FolderRoleMappings")]
[XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMappings")] [XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMappings")]
public List<FolderRoleMap> FolderRoleMapping { get; set; } 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; } public string RoleClaim { get; set; }
} }
/// <summary>
/// The OpenID client ID.
/// </summary>
public class FolderRoleMap public class FolderRoleMap
{ {
/// <summary>
/// Gets or sets the role of the mapping.
/// </summary>
public string Role { get; set; } 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; } public List<string> Folders { get; set; }
} }
+2 -2
View File
@@ -3,8 +3,8 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.SSO_Auth</RootNamespace> <RootNamespace>Jellyfin.Plugin.SSO_Auth</RootNamespace>
<AssemblyVersion>3.0.0.0</AssemblyVersion> <AssemblyVersion>3.2.0.0</AssemblyVersion>
<FileVersion>3.0.0.0</FileVersion> <FileVersion>3.2.0.0</FileVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors> <TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup> </PropertyGroup>
+21
View File
@@ -8,20 +8,41 @@ using MediaBrowser.Model.Serialization;
namespace Jellyfin.Plugin.SSO_Auth; namespace Jellyfin.Plugin.SSO_Auth;
/// <summary>
/// The SSO plugin class.
/// </summary>
public class SSOPlugin : BasePlugin<PluginConfiguration>, IHasWebPages 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) public SSOPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer) : base(applicationPaths, xmlSerializer)
{ {
Instance = this; Instance = this;
} }
/// <summary>
/// Gets the instance of the SSO plugin.
/// </summary>
public static SSOPlugin Instance { get; private set; } public static SSOPlugin Instance { get; private set; }
/// <summary>
/// Gets the name of the SSO plugin.
/// </summary>
public override string Name => "SSO-Auth"; 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"); 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() public IEnumerable<PluginPageInfo> GetPages()
{ {
yield return new PluginPageInfo yield return new PluginPageInfo
+99
View File
@@ -18,33 +18,61 @@ using System.Xml;
namespace Jellyfin.Plugin.SSO_Auth; namespace Jellyfin.Plugin.SSO_Auth;
/// <summary>
/// Represents a SAML response.
/// </summary>
public class Response public class Response
{ {
private readonly X509Certificate2 _certificate; private readonly X509Certificate2 _certificate;
private XmlDocument _xmlDoc; private XmlDocument _xmlDoc;
private XmlNamespaceManager _xmlNameSpaceManager; // we need this one to run our XPath queries on the SAML XML 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) public Response(string certificateStr, string responseString)
: this(Convert.FromBase64String(certificateStr), 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) public Response(byte[] certificateBytes, string responseString) : this(certificateBytes)
{ {
LoadXmlFromBase64(responseString); 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)) 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) public Response(byte[] certificateBytes)
{ {
_certificate = new X509Certificate2(certificateBytes); _certificate = new X509Certificate2(certificateBytes);
} }
/// <summary>
/// Gets the SAML response's XML data.
/// </summary>
public string Xml => _xmlDoc.OuterXml; 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) public void LoadXml(string xml)
{ {
_xmlDoc = new XmlDocument(); _xmlDoc = new XmlDocument();
@@ -55,11 +83,19 @@ public class Response
_xmlNameSpaceManager = GetNamespaceManager(); // lets construct a "manager" for XPath queries _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) public void LoadXmlFromBase64(string response)
{ {
LoadXml(Encoding.UTF8.GetString(Convert.FromBase64String(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() public bool IsValid()
{ {
var nodeList = _xmlDoc.SelectNodes("//ds:Signature", _xmlNameSpaceManager); var nodeList = _xmlDoc.SelectNodes("//ds:Signature", _xmlNameSpaceManager);
@@ -118,17 +154,29 @@ public class Response
return DateTime.UtcNow > expirationDate.ToUniversalTime(); 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() public string GetNameID()
{ {
var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:Subject/saml:NameID", _xmlNameSpaceManager); var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:Subject/saml:NameID", _xmlNameSpaceManager);
return node.InnerText; return node.InnerText;
} }
/// <summary>
/// Gets the UPN attribute from the XML response.
/// </summary>
/// <returns>The UPN attribute.</returns>
public virtual string GetUpn() public virtual string GetUpn()
{ {
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"); 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() public virtual string GetEmail()
{ {
return GetCustomAttribute("User.email") return GetCustomAttribute("User.email")
@@ -138,6 +186,10 @@ public class Response
?? GetCustomAttribute("mail"); ?? GetCustomAttribute("mail");
} }
/// <summary>
/// Gets the First Name attribute from the XML response.
/// </summary>
/// <returns>The First Name attribute.</returns>
public virtual string GetFirstName() public virtual string GetFirstName()
{ {
return GetCustomAttribute("first_name") return GetCustomAttribute("first_name")
@@ -148,6 +200,10 @@ public class Response
?? GetCustomAttribute("givenName"); ?? GetCustomAttribute("givenName");
} }
/// <summary>
/// Gets the Last Name attribute from the XML response.
/// </summary>
/// <returns>The Last Name attribute.</returns>
public virtual string GetLastName() public virtual string GetLastName()
{ {
return GetCustomAttribute("last_name") return GetCustomAttribute("last_name")
@@ -158,18 +214,30 @@ public class Response
?? GetCustomAttribute("sn"); ?? GetCustomAttribute("sn");
} }
/// <summary>
/// Gets the department attribute from the XML response.
/// </summary>
/// <returns>The department attribute.</returns>
public virtual string GetDepartment() public virtual string GetDepartment()
{ {
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department") return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department")
?? GetCustomAttribute("department"); ?? GetCustomAttribute("department");
} }
/// <summary>
/// Gets the phone attribute from the XML response.
/// </summary>
/// <returns>The phone attribute.</returns>
public virtual string GetPhone() public virtual string GetPhone()
{ {
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/homephone") return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/homephone")
?? GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/telephonenumber"); ?? 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() public virtual string GetCompany()
{ {
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/companyname") return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/companyname")
@@ -177,18 +245,32 @@ public class Response
?? GetCustomAttribute("User.CompanyName"); ?? GetCustomAttribute("User.CompanyName");
} }
/// <summary>
/// Gets the location attribute from the XML response.
/// </summary>
/// <returns>The location attribute.</returns>
public virtual string GetLocation() public virtual string GetLocation()
{ {
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/location") return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/location")
?? GetCustomAttribute("physicalDeliveryOfficeName"); ?? 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) public string GetCustomAttribute(string attr)
{ {
var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager); var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager);
return node?.InnerText; 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) public List<string> GetCustomAttributes(string attr)
{ {
var node = _xmlDoc.SelectNodes("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager); 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); output.Add(item?.InnerText);
} }
return output; return output;
} }
@@ -213,6 +296,9 @@ public class Response
} }
} }
/// <summary>
/// Represents a SAML request.
/// </summary>
public class AuthRequest public class AuthRequest
{ {
private readonly string _id; private readonly string _id;
@@ -221,6 +307,11 @@ public class AuthRequest
private readonly string _issuer; private readonly string _issuer;
private readonly string _assertionConsumerServiceUrl; 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) public AuthRequest(string issuer, string assertionConsumerServiceUrl)
{ {
_id = "_" + Guid.NewGuid().ToString(); _id = "_" + Guid.NewGuid().ToString();
@@ -230,6 +321,9 @@ public class AuthRequest
_assertionConsumerServiceUrl = assertionConsumerServiceUrl; _assertionConsumerServiceUrl = assertionConsumerServiceUrl;
} }
/// <summary>
/// The formatting of the AuthRequest.
/// </summary>
public enum AuthRequestFormat public enum AuthRequestFormat
{ {
/// <summary> /// <summary>
@@ -238,6 +332,11 @@ public class AuthRequest
Base64 = 1 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) public string GetRequest(AuthRequestFormat format)
{ {
using var sw = new StringWriter(); 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; namespace Jellyfin.Plugin.SSO_Auth;
/// <summary>
/// A helper class to return HTML for the client's auth flow.
/// </summary>
public static class WebResponse public static class WebResponse
{ {
/// <summary>
/// The shared HTML between all of the responses.
/// </summary>
public static readonly string Base = @"<!DOCTYPE html> 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() { function isTv() {
// This is going to be really difficult to get right // This is going to be really difficult to get right
const userAgent = navigator.userAgent.toLowerCase(); const userAgent = navigator.userAgent.toLowerCase();
@@ -389,22 +399,39 @@ function getDeviceName() {
return deviceName; 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 + @" return Base + @"
async function main() { async function main() {
var data = '" + data + @"'; 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 deviceId = localStorage.getItem(""_deviceId2"");
var appName = ""Jellyfin Web""; var appName = ""Jellyfin Web"";
var appVersion = ""10.8.0""; var appVersion = ""10.8.0"";
var deviceName = getDeviceName(); 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 => { let response = await new Promise(resolve => {
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
@@ -428,60 +455,14 @@ async function main() {
jfCreds['Servers'][0]['UserId'] = responseJson['User']['Id']; jfCreds['Servers'][0]['UserId'] = responseJson['User']['Id'];
localStorage.setItem('jellyfin_credentials', JSON.stringify(jfCreds)); localStorage.setItem('jellyfin_credentials', JSON.stringify(jfCreds));
localStorage.setItem('enableAutoLogin', 'true'); localStorage.setItem('enableAutoLogin', 'true');
window.location.replace('/'); window.location.replace('" + baseUrl + @"');
} }
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
main(); 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>";
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>";
} }
} }
+4 -1
View File
@@ -1,7 +1,7 @@
name: "SSO Authentication" name: "SSO Authentication"
guid: "505ce9d1-d916-42fa-86ca-673ef241d7df" guid: "505ce9d1-d916-42fa-86ca-673ef241d7df"
imageUrl: "https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/main/img/logo.png" 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" targetAbi: "10.8.0.0"
framework: "net6.0" framework: "net6.0"
owner: "9p4" owner: "9p4"
@@ -15,6 +15,9 @@ artifacts:
- "IdentityModel.OidcClient.dll" - "IdentityModel.OidcClient.dll"
- "IdentityModel.dll" - "IdentityModel.dll"
changelog: | 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 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.1.0: Fix improper artifact loading
2.0.0.0: Add RBAC and Google support 2.0.0.0: Add RBAC and Google support