Compare commits

...
32 Commits
Author SHA1 Message Date
Matthew Strasiotto c5e6bf96f9 Merge pull request #68 from matthewstrasiotto/main
ci: 👷 keep our own build workflow and reference that in publish.yml
2022-08-18 18:42:39 +10:00
Matthew Strasiotto fd59b83a0e ci: 👷 keep our own build workflow and reference that in publish.yml
Squash of
- ci: 👷 keep our own build workflow and reference that in publish.yml
- ci: 📌 pin actions/checkout,setup-dotnet odstr13/jellyfin-plugin-repo-manager to approved version

This will still fail because actions/upload-artifact isnt approved.

Getting kind of tilted trying to contribute to ci when the upstream CI settings fight me at every turn
2022-08-18 18:41:16 +10:00
Matthew Strasiotto ab36aea359 Merge pull request #65 from matthewstrasiotto/release-3.4.0
build: 🔖 bump version to 3.4 in build.yaml
2022-08-18 17:44:24 +10:00
Matthew Strasiotto fb268ed290 build: 🔖 bump version to 3.4 in build.yaml 2022-08-18 17:43:53 +10:00
9p4 f00bf70597 Merge pull request #34 from matthewstrasiotto/self_service
Implement Canonical Linking + Self Service
2022-08-15 18:45:51 -04:00
Matthew Strasiotto f0d16c33b8 docs: explain CI releases for contributors 2022-08-14 14:31:09 +10:00
Matthew Strasiotto 5c0e4ecefe docs: add contributing section, restructure installing section, document vscode bootstrap 2022-08-14 14:31:09 +10:00
Matthew Strasiotto ac1affa7b4 build: ignore .vscode entirely in gitignore 2022-08-14 14:06:18 +10:00
Matthew Strasitoto 510624f142 fix(SSOViews): Resolve server url when served under custom base URL 2022-08-14 13:13:07 +10:00
Matthew Strasitoto 840da75feb remove cdn for material icons, embed svg in stylesheet 2022-08-14 13:13:07 +10:00
Matthew Strasitoto 56b36e0fd4 document self service in readme 2022-08-14 13:13:07 +10:00
Matthew Strasitoto a4ea12b7f4 document self service in admin config page 2022-08-14 13:13:07 +10:00
Matthew Strasitoto 843873d8d5 fix: improve 403 failure message 2022-08-14 13:13:07 +10:00
Matthew Strasitoto fc888de045 Finish implementing linking view page 2022-08-14 13:13:07 +10:00
Matthew Strasiotto d72e47e7a7 Use a local apiClient module 2022-08-14 13:13:07 +10:00
Matthew Strasiotto a7fb4d4ef0 Code cleanup 2022-08-14 13:13:07 +10:00
Matthew Strasiotto ebfcadc862 Reduce duplication in adding new views 2022-08-14 13:13:06 +10:00
Matthew Strasiotto d8d7d616bd Add nav to leave linking page 2022-08-14 13:13:06 +10:00
Matthew Strasiotto bd60d7e32c Remove scss directives from restyled css 2022-08-14 13:13:06 +10:00
Matthew Strasiotto 7e5738c65f Add css to replace missing css in custom view 2022-08-14 13:13:06 +10:00
Matthew Strasiotto f810ea7259 add module for api client
Add js apiclient init module
2022-08-14 13:13:06 +10:00
Matthew Strasiotto cc05b12a23 Add controller for serving views 2022-08-14 13:13:06 +10:00
Matthew Strasiotto e7d70b8326 lazy provider linking frontend
Use ApiClient.getUrl to get base urls for links

Per
https://github.com/9p4/jellyfin-plugin-sso/pull/34#discussion_r885060442
2022-08-14 13:13:06 +10:00
Matthew Strasiotto c5e1644d17 implement api for listing and deleting links 2022-08-14 13:13:06 +10:00
Matthew Strasiotto 7ede38d0ab Implement canonical linking 2022-08-14 13:13:06 +10:00
Matthew Strasitoto fad5a62e07 docs: 📝 document new plugin install mechanism
Resolves #51
2022-07-17 17:23:49 +10:00
Matthew Strasiotto 511e5e4f12 Merge pull request #54 from matthewstrasiotto/ci-fix-nightly-checksums
ci: only run checksum agaisnt zip files
2022-07-01 14:04:32 +10:00
Matthew Strasitoto ad6328c1c5 ci: only run checksum agaisnt zip files 2022-07-01 13:43:18 +10:00
Matthew Strasiotto 7bc7e44768 Merge pull request #53 from matthewstrasiotto/ci-add-nightly-checksum
ci: add checksum to nightly builds
2022-07-01 13:29:28 +10:00
Matthew Strasitoto d94199cb8f ci: add checksum to nightly builds 2022-07-01 13:29:08 +10:00
Matthew Strasiotto a0bfa6a3ee Merge pull request #52 from matthewstrasiotto/ci-ignore-prereleases
ci: ignore prereleases when publishing manifest
2022-07-01 12:03:45 +10:00
Matthew Strasitoto 19bae4de9d ci: ignore prereleases when publishing manifest 2022-07-01 12:01:22 +10:00
21 changed files with 1695 additions and 39 deletions
+39
View File
@@ -0,0 +1,39 @@
on:
workflow_call:
inputs:
dotnet-version:
required: false
default: "6.0.x"
description: "The .NET version to setup for the build"
type: string
dotnet-target:
required: false
default: "net6.0"
description: "The .NET target to set for JPRM"
type: string
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v2
- name: Setup .NET
uses: actions/setup-dotnet@v1
with:
dotnet-version: "${{ inputs.dotnet-version }}"
- name: Build Jellyfin Plugin
uses: oddstr13/jellyfin-plugin-repository-manager@b9e92867a6aa279d611a5ea80cf61f6358838c39
id: jprm
with:
dotnet-target: "${{ inputs.dotnet-target }}"
- name: Upload Artifact
uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # tag=v3
with:
name: build-artifact
retention-days: 30
if-no-files-found: error
path: ${{ steps.jprm.outputs.artifact }}
+10 -1
View File
@@ -37,6 +37,15 @@ jobs:
path: .
dotnet-target: "net6.0"
output: _dist
- name: Prepare GitHub Release assets
run: |-
pushd _dist
for file in ./*.zip; do
md5sum ${file#./} >> ${file%.*}.md5
sha256sum ${file#./} >> ${file%.*}.sha256
done
ls -l
popd
- name: Publish output artifacts
id: publish-assets
uses: softprops/action-gh-release@50195ba7f6f93d1ac97ba8332a178e008ad176aa
@@ -54,7 +63,7 @@ jobs:
- name: Publish Plugin Manifest
uses: Kevinjil/jellyfin-plugin-repo-action@a7832ecc44c6b1a45d531970f6647b8682b005b8
with:
ignorePrereleases: false
ignorePrereleases: true
githubToken: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
pagesBranch: manifest-release
+2 -2
View File
@@ -8,7 +8,7 @@ on:
jobs:
build:
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/build.yaml@30fd723cc3bafc4457a4a85be3cdbee2f3e2013b
uses: ./.github/workflows/build.yml
with:
dotnet-version: "6.0.*"
dotnet-target: "net6.0"
@@ -41,7 +41,7 @@ jobs:
- name: Publish Plugin Manifest
uses: Kevinjil/jellyfin-plugin-repo-action@a7832ecc44c6b1a45d531970f6647b8682b005b8
with:
ignorePrereleases: false
ignorePrereleases: true
githubToken: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
pagesBranch: manifest-release
+1 -4
View File
@@ -448,7 +448,4 @@ $RECYCLE.BIN/
## Visual Studio Code
##
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.vscode
+1
View File
@@ -0,0 +1 @@
*.min.js
+54 -7
View File
@@ -26,6 +26,8 @@ This plugin allows users to sign in through an SSO provider (such as Google, Mic
https://user-images.githubusercontent.com/17993169/149681516-f93b43f5-fa5c-4c1f-a909-e5414878a864.mp4
Existing users may link new SSO accounts, or remove existing links using self-service at `/SSOViews/linking`.
## Current State:
This is 100% alpha software! PRs are welcome to improve the code.
@@ -57,24 +59,34 @@ This is my first time writing C# so please take all of the code written here wit
## Installing
Add the package repo [https://repo.ersei.net/jellyfin/manifest.json](https://repo.ersei.net/jellyfin/manifest.json) to your Jellyfin configuration. Then, install the package!
Add the package repo [https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/manifest-release/manifest.json](https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/manifest-release/manifest.json) to your Jellyfin plugin repositories.
## Building
Then, install the plugin from the plugin catalog!
This is built with .NET 6.0. Build with `dotnet publish .` for the debug release in the `SSO-Auth` directory. Copy over the `IdentityModel.OidcClient.dll`, the `IdentityModel.dll` and the `SSO-Auth.dll` files in the `/bin/Debug/net6.0/publish` directory to a new folder in your Jellyfin configuration: `config/plugins/sso`.
See [Contributing](#contributing) for instructions on how to build from source.
## Releasing
### (Fallback) Legacy package repo (Versions <= 3.3.0)
This plugin uses [JPRM](https://github.com/oddstr13/jellyfin-plugin-repository-manager) to build the plugin. Refer to the documentation there to install JPRM.
We have transitioned to a release system that automates distribution, packaging & hosting.
This system is new, and if something goes wrong, you can try using the old package repository as a fallback.
Build the zipped plugin with `jprm --verbosity=debug plugin build .`.
Instead add the **old** package repository: [https://repo.ersei.net/jellyfin/manifest.json](https://repo.ersei.net/jellyfin/manifest.json) to your jellyfin plugin repositories.
### Installing cutting edge/nightly builds
If you're impatient/brave/feel like helping us test things out, you can install the nightly build of the plugin, which is automatically built against the main branch.
The nightly build can be installed from the [main plugin repo](https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/manifest-release/manifest.json), and will always have a version number of `0.0.0.9000`.
The nightly build may have new features unavailable in other builds, but **be warned**, things may change frequently in nightly builds, and things may break, and you could lose data.
## Roadmap
- [x] Admin page
- [ ] Automated tests
- [x] Add role/claims support
- [ ] Use canonical usernames instead of preferred usernames
- [x] Use canonical usernames instead of preferred usernames
- [x] Add user self-service
- [ ] Finalize RBAC access for all user properties
## Examples
@@ -204,6 +216,41 @@ There is also no logout callback. Logging out of Jellyfin will log you out of Je
**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).
# Contributing
## Building
This is built with .NET 6.0. Build with `dotnet publish .` for the debug release in the `SSO-Auth` directory. Copy over the `IdentityModel.OidcClient.dll`, the `IdentityModel.dll` and the `SSO-Auth.dll` files in the `/bin/Debug/net6.0/publish` directory to a new folder in your Jellyfin configuration: `config/plugins/sso`.
### VSCode Workflow
An example `.vscode` configuration may be found at [matthewstrasiotto/jellyfin-plugin-sso-vscode](https://github.com/matthewstrasiotto/jellyfin-plugin-sso-vscode).
From the root of this repo, you may clone that to `.vscode`
```bash
# From repo root
git clone https://github.com/matthewstrasiotto/jellyfin-plugin-sso-vscode .vscode
```
## Releasing
This plugin uses [JPRM](https://github.com/oddstr13/jellyfin-plugin-repository-manager) to build the plugin. Refer to the documentation there to install JPRM.
Build the zipped plugin with `jprm --verbosity=debug plugin build .`.
### CI Releases
Anything merged to the main branch will be built and published by our CI system.
Anything tagged/released as a formal Github release will also be built and published by our CI system.
If you wish to use releases from your own fork, refer to
[Installing](#installing), however, you will need to change the url to the
manifest file, https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/manifest-release/manifest.json
so that it refers to your fork.
## Credits and Thanks
Much thanks to the [Jellyfin LDAP plugin](https://github.com/jellyfin/jellyfin-plugin-ldapauth) for offering a base for me to start on my plugin.
+46
View File
@@ -0,0 +1,46 @@
// The following code is a derivative work of the code from the Jellyfin project,
// which is licensed GPLv2. This code therefore is also licensed under the terms
// of the GNU Public License, verison 2.
// https://github.com/jellyfin/jellyfin/blob/a60cb280a3d31ba19ffb3a94cf83ef300a7473b7/Jellyfin.Api/Helpers/RequestHelpers.cs#L63-L77
// Use of this relatively small snippet complies with fair use
// See https://www.gnu.org/licenses/gpl-faq.en.html#SourceCodeInDocumentation
// These helpers were not published within a Nuget package, so it was neccessary to re-implement.
using System;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Net;
using Microsoft.AspNetCore.Http;
namespace Jellyfin.Plugin.SSO_Auth.Helpers;
/// <summary>
/// Request Extensions.
/// </summary>
public static class RequestHelpers
{
/// <summary>
/// Checks if the user can update an entry.
/// </summary>
/// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
/// <param name="requestContext">The <see cref="HttpRequest"/>.</param>
/// <param name="userId">The user id.</param>
/// <param name="restrictUserPreferences">Whether to restrict the user preferences.</param>
/// <returns>A <see cref="bool"/> whether the user can update the entry.</returns>
internal static async Task<bool> AssertCanUpdateUser(IAuthorizationContext authContext, HttpRequest requestContext, Guid userId, bool restrictUserPreferences)
{
var auth = await authContext.GetAuthorizationInfo(requestContext).ConfigureAwait(false);
var authenticatedUser = auth.User;
// If they're going to update the record of another user, they must be an administrator
if ((!userId.Equals(auth.UserId) && !authenticatedUser.HasPermission(PermissionKind.IsAdministrator))
|| (restrictUserPreferences && !authenticatedUser.EnableUserPreferenceAccess))
{
return false;
}
return true;
}
}
+367 -22
View File
@@ -8,12 +8,15 @@ using IdentityModel.OidcClient;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.SSO_Auth.Config;
using Jellyfin.Plugin.SSO_Auth.Helpers;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -29,6 +32,7 @@ public class SSOController : ControllerBase
{
private readonly IUserManager _userManager;
private readonly ISessionManager _sessionManager;
private readonly IAuthorizationContext _authContext;
private readonly ILogger<SSOController> _logger;
private static readonly IDictionary<string, TimedAuthorizeState> StateManager = new Dictionary<string, TimedAuthorizeState>();
@@ -37,11 +41,13 @@ public class SSOController : ControllerBase
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{SSOController}"/> interface.</param>
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
/// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
public SSOController(ILogger<SSOController> logger, ISessionManager sessionManager, IUserManager userManager)
public SSOController(ILogger<SSOController> logger, ISessionManager sessionManager, IUserManager userManager, IAuthorizationContext authContext)
{
_sessionManager = sessionManager;
_userManager = userManager;
_authContext = authContext;
_logger = logger;
_logger.LogInformation("SSO Controller initialized");
}
@@ -196,9 +202,12 @@ public class SSOController : ControllerBase
}
}
bool isLinking = StateManager[state].IsLinking;
if (StateManager[state].Valid)
{
return Content(WebResponse.Generator(data: state, provider: provider, baseUrl: GetRequestBase(), mode: "OID"), MediaTypeNames.Text.Html);
_logger.LogInformation($"Is request linking: {isLinking}");
return Content(WebResponse.Generator(data: state, provider: provider, baseUrl: GetRequestBase(), mode: "OID", isLinking: isLinking), MediaTypeNames.Text.Html);
}
else
{
@@ -220,9 +229,10 @@ public class SSOController : ControllerBase
/// Initiates the login flow for OpenID. This redirects the user to the auth provider.
/// </summary>
/// <param name="provider">The name of the provider.</param>
/// <param name="isLinking">Whether or not this request is to link accounts (Rather than authenticate).</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, [FromQuery] bool isLinking = false)
{
Invalidate();
OidConfig config;
@@ -249,6 +259,9 @@ public class SSOController : ControllerBase
var oidcClient = new OidcClient(options);
var state = await oidcClient.PrepareLoginAsync().ConfigureAwait(false);
StateManager.Add(state.State, new TimedAuthorizeState(state, DateTime.Now));
// Track whether this is a linking request or not.
StateManager[state.State].IsLinking = isLinking;
return Redirect(state.StartUrl);
}
@@ -293,6 +306,26 @@ public class SSOController : ControllerBase
return Ok(SSOPlugin.Instance.Configuration.OidConfigs);
}
/// <summary>
/// Lists the OpenID providers names only.
/// </summary>
/// <returns>The list of OpenID configurations.</returns>
[HttpGet("OID/GetNames")]
public ActionResult OidProviderNames()
{
return Ok(SSOPlugin.Instance.Configuration.OidConfigs.Keys);
}
/// <summary>
/// Lists the SAML providers names only.
/// </summary>
/// <returns>The list of OpenID configurations.</returns>
[HttpGet("SAML/GetNames")]
public ActionResult SamlProviderNames()
{
return Ok(SSOPlugin.Instance.Configuration.SamlConfigs.Keys);
}
/// <summary>
/// This is a debug endpoint to list all running OpenID flows. Requires administrator privileges.
/// </summary>
@@ -331,7 +364,9 @@ public class SSOController : ControllerBase
{
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, config.DefaultProvider)
Guid userId = await CreateCanonicalLinkAndUserIfNotExist("oid", provider, kvp.Value.Username);
var authenticationResult = await Authenticate(userId, kvp.Value.Admin, config.EnableAuthorization, config.EnableAllFolders, kvp.Value.Folders.ToArray(), response, config.DefaultProvider)
.ConfigureAwait(false);
return Ok(authenticationResult);
}
@@ -345,9 +380,13 @@ public class SSOController : ControllerBase
/// 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>
/// <param name="relayState">
/// RelayState given in the original saml request. If it is equal to "linking",
/// We consider this to be a linking request.
/// </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, [FromQuery] string relayState = null)
{
SamlConfig config;
try
@@ -359,13 +398,19 @@ public class SSOController : ControllerBase
return BadRequest("No matching provider found");
}
bool isLinking = relayState == "linking";
_logger.LogInformation(
$"SAML request has relayState of {relayState}");
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);
return Content(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML", isLinking: isLinking), MediaTypeNames.Text.Html);
}
// Check if user is allowed to log in based on roles
@@ -375,7 +420,7 @@ public class SSOController : ControllerBase
{
if (allowedRole.Equals(role))
{
return Content(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML"), MediaTypeNames.Text.Html);
return Content(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML", isLinking: isLinking), MediaTypeNames.Text.Html);
}
}
}
@@ -395,9 +440,10 @@ public class SSOController : ControllerBase
/// 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>
/// <param name="isLinking">Whether this flow intends to link an account, or initiate auth.</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, [FromQuery] bool isLinking = false)
{
SamlConfig config;
try
@@ -411,11 +457,17 @@ public class SSOController : ControllerBase
if (config.Enabled)
{
string relayState = null;
if (isLinking)
{
relayState = "linking";
}
var request = new AuthRequest(
config.SamlClientId,
GetRequestBase() + "/sso/SAML/p/" + provider);
return Redirect(request.GetRedirectUrl(config.SamlEndpoint));
return Redirect(request.GetRedirectUrl(config.SamlEndpoint, relayState));
}
throw new ArgumentException("Provider does not exist");
@@ -520,7 +572,9 @@ public class SSOController : ControllerBase
}
}
var authenticationResult = await Authenticate(samlResponse.GetNameID(), isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), response, config.DefaultProvider)
Guid userId = await CreateCanonicalLinkAndUserIfNotExist("saml", provider, samlResponse.GetNameID());
var authenticationResult = await Authenticate(userId, isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), response, config.DefaultProvider)
.ConfigureAwait(false);
return Ok(authenticationResult);
}
@@ -544,28 +598,312 @@ public class SSOController : ControllerBase
return Ok();
}
private SerializableDictionary<string, Guid> GetCanonicalLinks(string mode, string provider)
{
SerializableDictionary<string, Guid> links = null;
switch (mode.ToLower())
{
case "saml":
links = SSOPlugin.Instance.Configuration.SamlConfigs[provider].CanonicalLinks;
break;
case "oid":
links = SSOPlugin.Instance.Configuration.OidConfigs[provider].CanonicalLinks;
break;
default:
throw new ArgumentException($"{mode} is not a valid choice between 'saml' and 'oid'");
}
if (links == null)
{
links = new SerializableDictionary<string, Guid>();
}
return links;
}
private async Task<Guid> CreateCanonicalLinkAndUserIfNotExist(string mode, string provider, string canonicalName)
{
Guid userId = Guid.Empty;
try
{
userId = GetCanonicalLink(mode, provider, canonicalName);
}
catch (KeyNotFoundException)
{
userId = Guid.Empty;
}
if (userId == Guid.Empty)
{
_logger.LogInformation("SSO user link doesn't exist, creating...");
User user = null;
user = _userManager.GetUserByName(canonicalName);
if (user == null)
{
_logger.LogInformation($"SSO user {canonicalName} doesn't exist, creating...");
user = await _userManager.CreateUserAsync(canonicalName).ConfigureAwait(false);
user.AuthenticationProviderId = GetType().FullName;
}
userId = user.Id;
CreateCanonicalLink(mode, provider, userId, canonicalName);
}
return userId;
}
private Guid GetCanonicalLink(string mode, string provider, string canonicalName)
{
SerializableDictionary<string, Guid> links = null;
Guid userId = Guid.Empty;
links = GetCanonicalLinks(mode, provider);
userId = links[canonicalName];
return userId;
}
/// <summary>
/// Create a canonical link for a given user. Must be performed by the user being changed, or admin.
/// </summary>
/// <param name="mode">The mode of the function; SAML or OID.</param>
/// <param name="provider">The name of the provider to link to a jellyfin account.</param>
/// <param name="jellyfinUserId">The user ID within jellyfin to link to the provider.</param>
/// <param name="authResponse">The client information to authenticate the user with.</param>
/// <returns>Whether this API endpoint succeeded.</returns>
[Authorize(Policy = "DefaultAuthorization")]
[HttpPost("{mode}/Link/{provider}/{jellyfinUserId}")]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult> AddCanonicalLink([FromRoute] string mode, [FromRoute] string provider, [FromRoute] Guid jellyfinUserId, [FromBody] AuthResponse authResponse)
{
if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, jellyfinUserId, true).ConfigureAwait(false))
{
return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to link SSO providers.");
}
switch (mode.ToLower())
{
case "saml":
return SamlLink(provider, jellyfinUserId, authResponse);
case "oid":
return OidLink(provider, jellyfinUserId, authResponse);
default:
throw new ArgumentException($"{mode} is not a valid choice between 'saml' and 'oid'");
}
}
/// <summary>
/// Unregisters a given mapping from id within provider to user.
/// </summary>
/// <param name="mode">The mode of the function; SAML or OID.</param>
/// <param name="provider">The name of the provider from which the link should be removed.</param>
/// <param name="jellyfinUserId">The user ID within jellyfin to unlink from the provider.</param>
/// <param name="canonicalName">The user ID within jellyfin to unlink.</param>
/// <returns>Whether this API endpoint succeeded.</returns>
[Authorize(Policy = "DefaultAuthorization")]
[HttpDelete("{mode}/Link/{provider}/{jellyfinUserId}/{canonicalName}")]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult> DeleteCanonicalLink([FromRoute] string mode, [FromRoute] string provider, [FromRoute] Guid jellyfinUserId, [FromRoute] string canonicalName)
{
if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, jellyfinUserId, true).ConfigureAwait(false))
{
return StatusCode(StatusCodes.Status403Forbidden, "Current user is not allowed to unlink SSO providers for user ID.");
}
Guid linkedId = GetCanonicalLink(mode, provider, canonicalName);
if (linkedId != jellyfinUserId)
{
return StatusCode(StatusCodes.Status409Conflict, "jellyfin UID does not match id registered to that canonical name.");
}
var links = GetCanonicalLinks(mode, provider);
links.Remove(canonicalName);
return UpdateCanonicalLinkConfig(links, mode, provider);
}
/// <summary>
/// Gets all the saml links for a user.
/// </summary>
/// <param name="jellyfinUserId">The user ID within jellyfin for which to return the links.</param>
/// <returns>A dictionary of provider : link mappings.</returns>
[Authorize(Policy = "DefaultAuthorization")]
[HttpGet("saml/links/{jellyfinUserId}")]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult<SerializableDictionary<string, IEnumerable<string>>>> GetSamlLinksByUser(Guid jellyfinUserId)
{
if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, jellyfinUserId, true).ConfigureAwait(false))
{
return StatusCode(StatusCodes.Status403Forbidden, "Non-admin is not allowed to query other user's mappings.");
}
var mappings = new SerializableDictionary<string, IEnumerable<string>>();
var providerList = SSOPlugin.Instance.Configuration.SamlConfigs;
foreach (var providerName in providerList.Keys)
{
var canonLinks = providerList[providerName].CanonicalLinks;
var canonKeys = from link in canonLinks where link.Value == jellyfinUserId select link.Key;
mappings[providerName] = canonKeys;
}
return mappings;
}
/// <summary>
/// Gets all the oid links for a user.
/// </summary>
/// <param name="jellyfinUserId">The user ID within jellyfin for which to return the links.</param>
/// <returns>A dictionary of provider : link mappings.</returns>
[Authorize(Policy = "DefaultAuthorization")]
[HttpGet("oid/links/{jellyfinUserId}")]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult<SerializableDictionary<string, IEnumerable<string>>>> GetOidLinksByUser(Guid jellyfinUserId)
{
if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, jellyfinUserId, true).ConfigureAwait(false))
{
return StatusCode(StatusCodes.Status403Forbidden, "Non-admin is not allowed to query other user's mappings.");
}
var mappings = new SerializableDictionary<string, IEnumerable<string>>();
var providerList = SSOPlugin.Instance.Configuration.OidConfigs;
foreach (var providerName in providerList.Keys)
{
var canonLinks = providerList[providerName].CanonicalLinks;
var canonKeys = from link in canonLinks where link.Value == jellyfinUserId select link.Key;
mappings[providerName] = canonKeys;
}
return mappings;
}
/// <summary>
/// Validate a saml link request and create the link if it is valid.
/// </summary>
/// <param name="provider">The provider to authenticate against.</param>
/// <param name="jellyfinUserId">
/// The ID of the account to be linked to the provider.
/// Must be performed by this user, or an admin.
/// </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>
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
private ActionResult SamlLink(string provider, Guid jellyfinUserId, AuthResponse response)
{
SamlConfig config;
try
{
config = SSOPlugin.Instance.Configuration.SamlConfigs[provider];
}
catch (KeyNotFoundException)
{
return BadRequest("No matching provider found");
}
var samlResponse = new Response(config.SamlCertificate, response.Data);
// TODO: Does saml response require further validation?
string providerUserId = samlResponse.GetNameID();
return CreateCanonicalLink("saml", provider, jellyfinUserId, providerUserId);
}
/// <summary>
/// Validate an OIDC link request and create the link if it is valid.
/// </summary>
/// <param name="provider">The provider to authenticate against.</param>
/// <param name="jellyfinUserId">
/// The ID of the account to be linked to the provider.
/// Must be performed by this user, or an admin.
/// </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>
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
private ActionResult OidLink(string provider, Guid jellyfinUserId, AuthResponse response)
{
OidConfig config;
try
{
config = SSOPlugin.Instance.Configuration.OidConfigs[provider];
}
catch (KeyNotFoundException)
{
return BadRequest("No matching provider found");
}
foreach (var kvp in StateManager)
{
if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid)
{
string providerUserId = kvp.Value.Username;
return CreateCanonicalLink("oid", provider, jellyfinUserId, providerUserId);
}
}
return Problem("Something went wrong!");
}
private ActionResult CreateCanonicalLink(string mode, string provider, [FromRoute] Guid jellyfinUserId, string providerUserId)
{
SerializableDictionary<string, Guid> links = null;
try
{
links = GetCanonicalLinks(mode, provider);
}
catch (KeyNotFoundException)
{
return BadRequest("No matching provider found");
}
links[providerUserId] = jellyfinUserId;
UpdateCanonicalLinkConfig(links, mode, provider);
return NoContent();
}
private OkResult UpdateCanonicalLinkConfig(SerializableDictionary<string, Guid> links, string mode, string provider)
{
var configuration = SSOPlugin.Instance.Configuration;
switch (mode.ToLower())
{
case "saml":
configuration.SamlConfigs[provider].CanonicalLinks = links;
break;
case "oid":
configuration.OidConfigs[provider].CanonicalLinks = links;
break;
default:
throw new ArgumentException($"{mode} is not a valid choice between 'saml' and 'oid'");
}
SSOPlugin.Instance.UpdateConfiguration(configuration);
return Ok();
}
/// <summary>
/// Authenticates the user with the given information.
/// </summary>
/// <param name="username">The username of the user to authenticate.</param>
/// <param name="userId">The user id 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>
/// <param name="defaultProvider">The default provider of the user to be set after logging in.</param>
private async Task<AuthenticationResult> Authenticate(string username, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse, string defaultProvider)
private async Task<AuthenticationResult> Authenticate(Guid userId, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse, string defaultProvider)
{
User user = null;
user = _userManager.GetUserByName(username);
if (user == null)
{
_logger.LogInformation("SSO user doesn't exist, creating...");
user = await _userManager.CreateUserAsync(username).ConfigureAwait(false);
user.AuthenticationProviderId = GetType().FullName;
}
User user = _userManager.GetUserById(userId);
if (enableAuthorization)
{
user.SetPermission(PermissionKind.IsAdministrator, isAdmin);
@@ -670,6 +1008,7 @@ public class TimedAuthorizeState
Created = created;
Valid = false;
Admin = false;
IsLinking = false;
}
/// <summary>
@@ -697,6 +1036,12 @@ public class TimedAuthorizeState
/// </summary>
public bool Admin { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the state is
/// tied to a linking flow (instead of a login flow).
/// </summary>
public bool IsLinking { get; set; }
/// <summary>
/// Gets or sets the folders the user is allowed access to.
/// </summary>
+86
View File
@@ -0,0 +1,86 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model;
using MediaBrowser.Model.Plugins;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SSO_Auth.Views;
/// <summary>
/// The sso views controller.
/// </summary>
[ApiController]
[Route("[controller]")]
public class SSOViewsController : ControllerBase
{
private readonly IUserManager _userManager;
private readonly ISessionManager _sessionManager;
private readonly IAuthorizationContext _authContext;
private readonly ILogger<SSOViewsController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SSOViewsController"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{SSOViewsController}"/> interface.</param>
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
/// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
public SSOViewsController(ILogger<SSOViewsController> logger, ISessionManager sessionManager, IUserManager userManager, IAuthorizationContext authContext)
{
_sessionManager = sessionManager;
_userManager = userManager;
_authContext = authContext;
_logger = logger;
_logger.LogInformation("SSO Views Controller initialized");
}
private ActionResult ServeView(string viewName)
{
IEnumerable<PluginPageInfo> pages = null;
if (SSOPlugin.Instance == null)
{
return BadRequest("No plugin instance found");
}
pages = SSOPlugin.Instance.GetViews();
if (pages == null)
{
return NotFound("Pages is null or empty");
}
var view = pages.FirstOrDefault(pageInfo => pageInfo.Name == viewName, null);
if (view == null)
{
return NotFound("No matching view found");
}
#nullable enable
Stream? stream = SSOPlugin.Instance.GetType().Assembly.GetManifestResourceStream(view.EmbeddedResourcePath);
if (stream == null)
{
_logger.LogError("Failed to get resource {Resource}", view.EmbeddedResourcePath);
return NotFound();
}
#nullable disable
return File(stream, MimeTypes.GetMimeType(view.EmbeddedResourcePath));
}
/// <summary>
/// Gets a html view.
/// </summary>
/// <param name="viewName">The name of the view / asset to fetch.</param>
/// <returns>The html view with the specified name.</returns>
[HttpGet("{viewName}")]
public ActionResult GetView([FromRoute] string viewName)
{
return ServeView(viewName);
}
}
+41
View File
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Xml.Serialization;
@@ -36,6 +37,8 @@ public class PluginConfiguration : MediaBrowser.Model.Plugins.BasePluginConfigur
[XmlRoot("PluginConfiguration")]
public class SamlConfig
{
private SerializableDictionary<string, Guid> _canonicalLinks;
/// <summary>
/// Gets or sets the SAML information endpoint.
/// </summary>
@@ -97,6 +100,24 @@ public class SamlConfig
/// Gets or sets the default provider the user after logging in with SSO.
/// </summary>
public string DefaultProvider { get; set; }
/// <summary>
/// Gets or sets a mapping of canonical names from the provider to jellyfin user ids.
/// </summary>
[XmlElement("CanonicalLinks")]
public SerializableDictionary<string, Guid> CanonicalLinks
{
get
{
if (_canonicalLinks == null)
{
return new SerializableDictionary<string, Guid>();
}
return _canonicalLinks;
}
set => _canonicalLinks = value;
}
}
/// <summary>
@@ -105,6 +126,8 @@ public class SamlConfig
[XmlRoot("PluginConfiguration")]
public class OidConfig
{
private SerializableDictionary<string, Guid> _canonicalLinks;
/// <summary>
/// Gets or sets the OpenID well-known information endpoint.
/// </summary>
@@ -176,6 +199,24 @@ public class OidConfig
/// Gets or sets the default provider the user after logging in with SSO.
/// </summary>
public string DefaultProvider { get; set; }
/// <summary>
/// Gets or sets a mapping of canonical names from the provider to jellyfin user ids.
/// </summary>
[XmlElement("CanonicalLinks")]
public SerializableDictionary<string, Guid> CanonicalLinks
{
get
{
if (_canonicalLinks == null)
{
return new SerializableDictionary<string, Guid>();
}
return _canonicalLinks;
}
set => _canonicalLinks = value;
}
}
/// <summary>
+3
View File
@@ -397,4 +397,7 @@ export default function (view) {
console.log(current_mappings);
ssoConfigurationPage.populateRoleMappings(current_mappings, container);
});
view.querySelector("#sso-self-service-link").href =
ApiClient.getUrl("/SSOViews/linking");
}
+17
View File
@@ -45,6 +45,23 @@
>roadmap
</a>
for more information.
<br />
To allow users to manage their own SSO accounts, including linking
SSO providers, and removing existing links, they need to visit
<a
is="emby-linkbutton"
id="sso-self-service-link"
class="button-link"
>the self service page </a
>. <br />
You can use
<a
is="emby-linkbutton"
href="https://jellyfin.org/docs/general/clients/web-config.html#custom-menu-links"
class="button-link"
>custom menu links
</a>
to accomplish this.
</p>
<form id="sso-load-config" class="esqConfigurationForm">
+99
View File
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Polyfill styles that are missing when serving without dashboard -->
<link rel="stylesheet" href="emby-restyle.css" />
<link id="theme-style" rel="stylesheet" />
<script defer>
import("./ApiClient.js").then((clientModule) => {
import("./linking.js").then((renderer) => {
const view = document.querySelector("#sso-config-page");
document.querySelector("#theme-style").href = ApiClient.getUrl(
"/web/themes/dark/theme.css"
);
const homeButton = document.querySelector("a.emby-button.home");
homeButton.href = ApiClient.serverAddress();
homeButton.style.display = "";
renderer.default(view);
});
});
</script>
<title>SSO Linking</title>
</head>
<body class="force-scroll dashboardDocument mouseIdle">
<div
id="sso-config-page"
data-role="page"
class="page type-interior pluginConfigurationPage esqConfigurationPage"
data-controller="__plugin/SSO-Auth-linking.js"
>
<div data-role="content">
<div class="content-primary">
<div class="sectionTitleContainer flex align-items-center">
<a class="raised emby-button home" style="display: none">
<span class="material-icons home" aria-hidden="true"></span
><span>Home</span>
</a>
<h2 class="sectionTitle">SSO Linking:</h2>
<a
is="emby-button"
class="raised button-alt headerHelpButton"
target="_blank"
href="https://github.com/9p4/jellyfin-plugin-sso"
>${Help}</a
>
</div>
<p>
Use the
<span class="fab" aria-hidden="true">
<span class="material-icons add" aria-hidden="true"></span>
</span>
button to create a new link for the given provider.
<br />
You may remove existing links by selecting them and pressing the
"Delete" button.
</p>
<p>
See the
<a
is="emby-linkbutton"
href="https://github.com/9p4/jellyfin-plugin-sso"
class="button-link"
>help page</a
>
and
<a
is="emby-linkbutton"
href="https://github.com/9p4/jellyfin-plugin-sso/projects/1"
class="button-link"
>roadmap
</a>
for more information.
</p>
<label class="checkbox-wrapper">
<input is="emby-checkbox" id="enable-delete" type="checkbox" />
<span class="checkbox-label">Enable "Delete" button.</span>
</label>
<h1 class="sectionTitle">Link a provider</h1>
<div class="verticalSection" title="Link a provider">
<h2 class="sectionTitle">SAML</h2>
<div id="sso-provider-list-saml" data-id="saml"></div>
<h2 class="sectionTitle">OID</h2>
<div id="sso-provider-list-oid" data-id="oid"></div>
</div>
<button
id="btn-delete-selected-links"
type="button"
class="button-delete raised emby-button"
disabled="true"
>
<span class="material-icons delete" aria-hidden="true"></span>
<span>Delete Selected</span>
</button>
</div>
</div>
</div>
</body>
</html>
+186
View File
@@ -0,0 +1,186 @@
const ssoConfigLinking = {
pluginUniqueId: "505ce9d1-d916-42fa-86ca-673ef241d7df",
loadProviders: (view) => {
const provider_list_id = "sso-provider-list";
const provider_list_saml_id = `${provider_list_id}-saml`;
const provider_list_oid_id = `${provider_list_id}-oid`;
const provider_list_saml = view.querySelector(`#${provider_list_saml_id}`);
const provider_list_oid = view.querySelector(`#${provider_list_oid_id}`);
provider_list_saml.innerHTML = "";
provider_list_oid.innerHTML = "";
fetch(new Request(ApiClient.getUrl("sso/OID/GetNames"))).then((resp) => {
resp.json().then((config_names) => {
ssoConfigLinking.loadProviderList(
provider_list_oid,
config_names,
"oid"
);
});
});
fetch(new Request(ApiClient.getUrl("sso/SAML/GetNames"))).then((resp) => {
resp.json().then((config_names) => {
ssoConfigLinking.loadProviderList(
provider_list_saml,
config_names,
"saml"
);
});
});
},
loadProviderList: (container, providers, provider_mode) => {
providers.forEach((provider_name) => {
var provider_config = document.createElement("div");
provider_config.classList.add("sso-provider-links-container");
provider_config.setAttribute("data-id", provider_name);
provider_config.innerHTML = `
<label
class="inputLabel inputLabelUnfocused sso-provider-link-title"
>${provider_name}
</label>
<a
class="fab emby-button sso-provider-add-link"
>
<span class="material-icons add" aria-hidden="true"></span>
</a>
<div
class="sso-provider-existing-links-container"
data-provider="${provider_name}"
></div>
`;
var add_provider = provider_config.querySelector(
".sso-provider-add-link"
);
//const provider_name_css = ssoConfigLinking.safeCSSId(provider_name);
//provider_link.id = "sso-provider-" + provider_name_css;
//provider_link.classList.add("sso-provider-" + provider_name_css);
add_provider.classList.add("sso-provider");
add_provider.href = ApiClient.getUrl(
`/SSO/${provider_mode}/p/${provider_name}?isLinking=true`
);
container.appendChild(provider_config);
});
const currentUserId = ApiClient.getCurrentUserId();
if (currentUserId) {
ApiClient.fetch(
{
type: "GET",
url: ApiClient.getUrl(`sso/${provider_mode}/links/${currentUserId}`),
},
true
).then((resp) => {
resp.json().then((provider_map) => {
console.log({ provider_map, currentUserId });
Object.keys(provider_map).forEach((provider_name) => {
const provider_container = container.querySelector(
`.sso-provider-existing-links-container[data-provider="${provider_name}"]`
);
ssoConfigLinking.populateExistingLinks(
provider_container,
provider_mode,
provider_name,
provider_map[provider_name]
);
});
});
});
}
},
populateExistingLinks: (
container,
provider_mode,
provider_name,
canonical_names
) => {
container
.querySelectorAll(".sso-provider-link-checkbox-wrapper")
.forEach((e) => e.remove());
const checkboxes = canonical_names.map((canonical_name) => {
var out = document.createElement("label");
out.classList.add("sso-provider-link-checkbox-wrapper");
out.classList.add("checkbox-wrapper");
out.innerHTML = `
<input
is="emby-checkbox"
class="sso-link-checkbox"
data-id="${canonical_name}"
data-mode="${provider_mode}"
data-provider="${provider_name}"
type="checkbox"
/>
<span class="checkbox-label">${canonical_name}</span>
`;
return out;
});
checkboxes.forEach((e) => {
container.appendChild(e);
});
},
handleDeleteButtonPressed: (evt, view) => {
if (evt.target.disabled) return;
const currentUserId = ApiClient.getCurrentUserId();
if (!currentUserId) return;
const delete_requests = [...view.querySelectorAll(".sso-link-checkbox")]
.filter((checkbox_link) => {
const canonical_name = checkbox_link.getAttribute("data-id");
const provider_name = checkbox_link.getAttribute("data-provider");
const provider_mode = checkbox_link.getAttribute("data-mode");
if (![canonical_name, provider_name, provider_mode].every((e) => e)) {
return false;
}
if (!checkbox_link.checked) {
return false;
}
return true;
})
.map((checked_link) => {
const canonical_name = checked_link.getAttribute("data-id");
const provider_name = checked_link.getAttribute("data-provider");
const provider_mode = checked_link.getAttribute("data-mode");
return ApiClient.fetch({
type: "DELETE",
url: ApiClient.getUrl(
`sso/${provider_mode}/link/${provider_name}/${currentUserId}/${canonical_name}`
),
});
});
Promise.all(delete_requests).then((values) => {
console.log({ message: "Delete requests handled", values });
window.location.reload();
});
},
};
export default function (view) {
ssoConfigLinking.loadProviders(view);
view.querySelector("#enable-delete").addEventListener("change", (e) => {
view.querySelector("#btn-delete-selected-links").disabled =
!e.target.checked;
});
view
.querySelector("#btn-delete-selected-links")
.addEventListener("click", (e) =>
ssoConfigLinking.handleDeleteButtonPressed(e, view)
);
}
+9
View File
@@ -13,9 +13,18 @@
<None Remove="Config\configPage.html" />
<None Remove="Config\config.js" />
<None Remove="Config\style.css" />
<None Remove="Config\linking.html" />
<None Remove="Views\apiClient.js" />
<None Remove="Views\jellyfin-apiClient.esm.min.js" />
<None Remove="Views\emby-restyle.css" />
<EmbeddedResource Include="Config\configPage.html" />
<EmbeddedResource Include="Config\config.js" />
<EmbeddedResource Include="Config\style.css" />
<EmbeddedResource Include="Config\linking.html" />
<EmbeddedResource Include="Config\linking.js" />
<EmbeddedResource Include="Views\apiClient.js" />
<EmbeddedResource Include="Views\jellyfin-apiClient.esm.min.js" />
<EmbeddedResource Include="Views\emby-restyle.css" />
</ItemGroup>
<ItemGroup>
+52 -1
View File
@@ -11,7 +11,7 @@ namespace Jellyfin.Plugin.SSO_Auth;
/// <summary>
/// The SSO plugin class.
/// </summary>
public class SSOPlugin : BasePlugin<PluginConfiguration>, IHasWebPages
public class SSOPlugin : BasePlugin<PluginConfiguration>, IPlugin, IHasWebPages
{
/// <summary>
/// Initializes a new instance of the <see cref="SSOPlugin"/> class.
@@ -62,6 +62,57 @@ public class SSOPlugin : BasePlugin<PluginConfiguration>, IHasWebPages
Name = Name + ".css",
EmbeddedResourcePath = $"{GetType().Namespace}.Config.style.css"
},
new PluginPageInfo
{
Name = Name + "-linking",
EmbeddedResourcePath = $"{GetType().Namespace}.Config.linking.html"
},
new PluginPageInfo
{
Name = Name + "-linking.js",
EmbeddedResourcePath = $"{GetType().Namespace}.Config.linking.js"
},
};
}
/// <summary>
/// Returns the available user views for this plugin.
/// </summary>
/// <returns>A list of user views for this plugin.</returns>
public IEnumerable<PluginPageInfo> GetViews()
{
return new[]
{
new PluginPageInfo
{
Name = "style.css",
EmbeddedResourcePath = $"{GetType().Namespace}.Config.style.css"
},
new PluginPageInfo
{
Name = "linking",
EmbeddedResourcePath = $"{GetType().Namespace}.Config.linking.html"
},
new PluginPageInfo
{
Name = "linking.js",
EmbeddedResourcePath = $"{GetType().Namespace}.Config.linking.js"
},
new PluginPageInfo
{
Name = "ApiClient.js",
EmbeddedResourcePath = $"{GetType().Namespace}.Views.apiClient.js"
},
new PluginPageInfo
{
Name = "emby-restyle.css",
EmbeddedResourcePath = $"{GetType().Namespace}.Views.emby-restyle.css"
},
new PluginPageInfo
{
Name = "jellyfin-apiClient.esm.min.js",
EmbeddedResourcePath = $"{GetType().Namespace}.Views.jellyfin-apiClient.esm.min.js"
},
};
}
}
+153
View File
@@ -0,0 +1,153 @@
import jellyfinApiclient from "./jellyfin-apiClient.esm.min.js";
window.jellyfinApiclient = jellyfinApiclient;
console.log(jellyfinApiclient);
// https://github.com/jellyfin/jellyfin-web/blob/9067b0e397cc8b38635d661ce86ddd83194f3202/src/scripts/clientUtils.js#L19-L76
export async function serverAddress({ basePath = "/web" }) {
const apiClient = window.ApiClient;
if (apiClient) {
return Promise.resolve(apiClient.serverAddress());
}
const urls = [];
const getViewUrl = (basePath) => {
let url;
const index = window.location.href
.toLowerCase()
.lastIndexOf(basePath.toLowerCase());
if (index != -1) {
url = window.location.href.substring(0, index);
} else {
// Return nothing, let another method handle it
url = undefined;
}
return url;
};
if (urls.length === 0) {
// Otherwise use computed base URL
let url;
url = getViewUrl(basePath) ?? getViewUrl("/web") ?? window.location.origin;
// Don't use bundled app URL (file:) as server URL
if (url.startsWith("file:")) {
return Promise.resolve();
}
urls.push(url);
}
console.debug("URL candidates:", urls);
const promises = urls.map((url) => {
return fetch(`${url}/System/Info/Public`)
.then((resp) => {
return {
url: url,
response: resp,
};
})
.catch(() => {
return Promise.resolve();
});
});
return Promise.all(promises)
.then((responses) => {
responses = responses.filter((obj) => obj && obj.response.ok);
return Promise.all(
responses.map((obj) => {
return {
url: obj.url,
config: obj.response.json(),
};
})
);
})
.then((configs) => {
const selection =
configs.find((obj) => !obj.config.StartupWizardCompleted) || configs[0];
return Promise.resolve(selection?.url);
})
.catch((error) => {
console.log(error);
return Promise.resolve();
});
}
// TODO: Refactor duplicated code
// ! Duplicated at
// https://github.com/9p4/jellyfin-plugin-sso/blob/38558d762a13422862240af4060bdd1bb1618d57/SSO-Auth/WebResponse.cs#L363-L401
function getDeviceName() {
return "DUMMY";
}
function getDeviceId() {
return localStorage.getItem("_deviceId2");
}
const sleep = (milliseconds) => {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
};
async function awaitLocalStorage() {
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);
}
}
await awaitLocalStorage();
// Fetch credentials
var credentials = new jellyfinApiclient.Credentials();
var server = await serverAddress({ basePath: "/SSOViews" });
console.log({ server: server });
var deviceId = getDeviceId();
var appName = "SSO-Auth";
var appVersion = "0.0.0.9000";
var capabilities = {};
const current_server = credentials
.credentials()
.Servers.find((e) => e.LocalAddress == server || e.ManualAddress == server);
var localApiClient = new jellyfinApiclient.ApiClient(
server,
appName,
appVersion,
getDeviceName(),
deviceId
);
localApiClient.setAuthenticationInfo(
current_server.AccessToken,
current_server.UserId
);
var connections = new jellyfinApiclient.ConnectionManager(
credentials,
appName,
appVersion,
getDeviceName(),
deviceId,
capabilities
);
connections.addApiClient(localApiClient);
window.ApiClient = localApiClient;
export default localApiClient;
+480
View File
@@ -0,0 +1,480 @@
/* Material icons polyfills */
.material-icons {
height: 1em;
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: inherit;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "liga";
}
.material-icons.home {
content: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjRweCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik0xMCAyMHYtNmg0djZoNXYtOGgzTDEyIDMgMiAxMmgzdjh6Ii8+PC9zdmc+");
}
.material-icons.add {
content: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjRweCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik0xOSAxM2gtNnY2aC0ydi02SDV2LTJoNlY1aDJ2Nmg2djJ6Ii8+PC9zdmc+");
}
.material-icons.delete {
content: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjRweCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik02IDE5YzAgMS4xLjkgMiAyIDJoOGMxLjEgMCAyLS45IDItMlY3SDZ2MTJ6TTE5IDRoLTMuNWwtMS0xaC01bC0xIDFINXYyaDE0VjR6Ii8+PC9zdmc+");
}
/*
theme.css
*/
.button-delete {
background: rgb(247, 0, 0);
color: rgba(255, 255, 255, 0.87);
}
.button-delete:disabled {
background: rgb(105, 0, 0);
color: rgba(127, 127, 127, 0.87);
cursor: not-allowed;
pointer-events: none;
}
/*
Emby Button
*/
.emby-button {
position: relative;
display: inline-flex;
align-items: center;
box-sizing: border-box;
margin: 0.3em;
text-align: center;
font-size: inherit;
font-family: inherit;
color: inherit;
/* These are getting an outline in opera tv browsers, which run chrome 30 */
outline: none !important;
outline-width: 0;
-moz-user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
user-select: none;
cursor: pointer;
z-index: 0;
padding: 0.9em 1em;
vertical-align: middle;
border: 0;
border-radius: 0.2em;
font-weight: 600;
/* Disable webkit tap highlighting */
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
text-decoration: none;
/* Not crazy about this but it normalizes heights between anchors and buttons */
line-height: 1.35;
transform-origin: center;
transition: 0.2s;
}
.emby-button.show-focus:focus {
transform: scale(1.2);
z-index: 1;
}
.emby-button::-moz-focus-inner {
border: 0;
}
.button-flat {
background: transparent;
}
.button-link {
background: transparent;
cursor: pointer;
margin: 0;
padding: 0;
vertical-align: initial;
}
.button-link:hover {
text-decoration: underline;
}
.emby-button > .material-icons {
/* For non-fab buttons that have icons */
font-size: 1.36em;
}
.button-link > .material-icons {
font-size: 1em;
}
.fab {
display: inline-flex;
border-radius: 50%;
padding: 0.6em;
box-sizing: border-box;
align-items: center;
justify-content: center;
text-align: center;
}
.emby-button.block {
display: block;
align-items: center;
justify-content: center;
margin: 0.25em 0;
width: 100%;
}
.paper-icon-button-light {
position: relative;
display: inline-flex;
align-items: center;
box-sizing: border-box;
margin: 0 0.29em;
background: transparent;
text-align: center;
font-size: inherit;
font-family: inherit;
color: inherit;
-moz-user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
user-select: none;
cursor: pointer;
z-index: 0;
min-width: initial;
min-height: initial;
width: auto;
height: auto;
padding: 0.556em;
vertical-align: middle;
border: 0;
/* These are getting an outline in opera tv browsers, which run chrome 30 */
outline: none !important;
overflow: hidden;
border-radius: 50%;
/* Disable webkit tap highlighting */
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
justify-content: center;
transform-origin: center;
transition: 0.2s;
}
.paper-icon-button-light.show-focus:focus {
transform: scale(1.3);
z-index: 1;
}
.paper-icon-button-light::-moz-focus-inner {
border: 0;
}
.paper-icon-button-light:disabled {
opacity: 0.3;
cursor: default;
}
.paper-icon-button-light > .material-icons {
font-size: 1.66956521739130434em;
/* Make sure its on top of the ripple */
position: relative;
z-index: 1;
vertical-align: middle;
}
.paper-icon-button-light > div {
max-height: 100%;
transform: scale(1.8);
position: relative;
z-index: 1;
vertical-align: middle;
display: inline;
margin: 0 auto;
}
.emby-button-foreground {
position: relative;
z-index: 1;
}
.btnFilterWithBubble {
position: relative;
}
.filterButtonBubble {
color: #fff;
position: absolute;
top: 0;
right: 0;
width: 1.6em;
height: 1.6em;
z-index: 100000000;
display: flex;
align-items: center;
justify-content: center;
font-size: 82%;
border-radius: 100em;
box-shadow: 0 4px 5px 0 rgba(0, 0, 0, 0.14), 0 1px 10px 0 rgba(0, 0, 0, 0.12),
0 2px 4px -1px rgba(0, 0, 0, 0.2);
background: #03a9f4;
font-weight: bold;
}
/* fonts.scss */
html {
font-family: "Noto Sans", "Noto Sans HK", "Noto Sans JP", "Noto Sans KR",
"Noto Sans SC", "Noto Sans TC", sans-serif;
text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
html[lang|="ja"] {
font-family: "Noto Sans", "Noto Sans JP", "Noto Sans HK", "Noto Sans KR",
"Noto Sans SC", "Noto Sans TC", sans-serif;
}
html[lang|="ko"] {
font-family: "Noto Sans", "Noto Sans KR", "Noto Sans HK", "Noto Sans JP",
"Noto Sans SC", "Noto Sans TC", sans-serif;
}
html[lang|="zh-CN"] {
font-family: "Noto Sans", "Noto Sans SC", "Noto Sans HK", "Noto Sans JP",
"Noto Sans KR", "Noto Sans TC", sans-serif;
}
html[lang|="zh-TW"] {
font-family: "Noto Sans", "Noto Sans TC", "Noto Sans HK", "Noto Sans JP",
"Noto Sans KR", "Noto Sans SC", sans-serif;
}
html[lang|="zh-HK"] {
font-family: "Noto Sans", "Noto Sans HK", "Noto Sans JP", "Noto Sans KR",
"Noto Sans SC", "Noto Sans TC", sans-serif;
}
.layout-tv {
/* Per WebOS and Tizen guidelines, fonts must be 20px minimum.
This takes the 16px baseline and multiplies it by 1.25 to get 20px. */
font-size: 125%;
}
.layout-mobile {
font-size: 90%;
}
/* site.scss */
html {
line-height: 1.35;
}
body {
overflow-x: hidden;
background-color: transparent !important;
-webkit-font-smoothing: antialiased;
}
.clipForScreenReader {
clip: rect(1px, 1px, 1px, 1px);
clip-path: inset(50%);
height: 1px;
width: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
}
.material-icons {
/* Fix font ligatures on older WebOS versions */
font-feature-settings: "liga";
}
.backgroundContainer {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
contain: strict;
}
.layout-mobile,
.layout-tv {
-webkit-touch-callout: none;
user-select: none;
}
.mainAnimatedPage {
contain: style size !important;
}
.pageContainer {
overflow-x: visible !important;
}
.bodyWithPopupOpen {
overflow-y: hidden !important;
}
div[data-role="page"] {
outline: 0;
}
.pageTitle {
margin-top: 0;
font-family: inherit;
}
.fieldDescription {
padding-left: 0.15em;
font-weight: 400;
white-space: normal !important;
}
.fieldDescription + .fieldDescription {
margin-top: 0.3em;
}
.content-primary,
.padded-bottom-page,
.page,
.pageWithAbsoluteTabs .pageTabContent {
/* provides room for the music controls */
padding-bottom: 5em !important;
}
.readOnlyContent {
@media all and (min-width: 50em) {
max-width: 54em;
}
}
form {
@media all and (min-width: 50em) {
max-width: 54em;
}
}
.headerHelpButton {
margin-left: 1.25em !important;
padding-bottom: 0.4em !important;
padding-top: 0.4em !important;
}
.mediaInfoContent {
margin-left: auto;
margin-right: auto;
width: 85%;
}
.headroom {
will-change: transform;
transition: transform 200ms linear;
}
.drawerContent {
/* make sure the bottom of the drawer is visible when music is playing */
padding-bottom: 4em;
}
.force-scroll {
overflow-y: scroll;
}
.hide-scroll {
overflow-y: hidden;
}
.w-100 {
width: 100%;
}
.margin-auto-x {
margin-left: auto;
margin-right: auto;
}
.margin-auto-y {
margin-top: auto;
margin-bottom: auto;
}
/* Fix checkboxes */
/* Customize the label (the container) */
*/ .checkbox-wrapper {
position: relative;
}
.checkbox-wrapper [type="checkbox"] {
/*display: none;*/
position: absolute;
top: 0px;
left: 0px;
height: 20px;
width: 20px;
-webkit-appearance: none;
}
.checkbox-label {
display: flex;
position: relative;
font-size: 20px;
font-weight: 400;
align-items: center;
justify-content: flex-start;
margin-bottom: 20px;
}
.checkbox-label:before,
.checkbox-label:after {
pointer-events: none;
}
.checkbox-label:before {
display: flex;
content: " ";
height: 20px;
width: 20px;
border: 0.14em solid white;
border-radius: 0.14em;
/* background: #fff; */
margin-right: 10px;
}
.checkbox-label:after {
position: absolute;
top: 0;
left: 0;
display: flex;
content: " ";
height: 20px;
width: 20px;
border: 0.14em solid white;
border-radius: 0.14em;
background: none;
}
.checkbox-wrapper input[type="checkbox"]:checked + .checkbox-label:after {
background-color: #2196f3;
content: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjRweCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik05IDE2LjJMNC44IDEybC0xLjQgMS40TDkgMTkgMjEgN2wtMS40LTEuNEw5IDE2LjJ6Ii8+PC9zdmc+");
}
File diff suppressed because one or more lines are too long
+39 -1
View File
@@ -412,10 +412,46 @@ const sleep = (milliseconds) => {
/// <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>
/// <param name="isLinking">Whether or not this request is to link accounts (Rather than authenticate).</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)
public static string Generator(string data, string provider, string baseUrl, string mode, bool isLinking = false)
{
return Base + @"
async function link(request) {
const jfCredentialsString = localStorage.getItem(""jellyfin_credentials"");
if (jfCredentialsString == null) return;
const jfCredentials = JSON.parse(jfCredentialsString);
const jfUser = jfCredentials['Servers'][0]['UserId'];
const jfToken = jfCredentials['Servers'][0]['AccessToken'];
if (jfUser == null) return;
if (jfToken == null) return;
const url = '" + $"{baseUrl}/sso/{mode}/Link/{provider}/" + @"' + jfUser;
return new Promise(resolve => {
var xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader(
'X-Emby-Authorization',
`MediaBrowser Client=""${request.appName}"",Device=""${request.deviceName}"",DeviceId=""${request.deviceId}"",Version=""${request.appVersion}"",Token=""${jfToken}""`)
xhr.onload = function(e) {
resolve(xhr.response);
};
xhr.onerror = function (e) {
console.log(e);
resolve(undefined);
};
xhr.send(JSON.stringify(request));
})
}
async function main() {
var data = '" + data + @"';
while (localStorage.getItem(""_deviceId2"") == null ||
@@ -431,6 +467,8 @@ async function main() {
var request = {deviceId, appName, appVersion, deviceName, data};
if (" + $"{isLinking}".ToLower() + @") await link(request);
var url = '" + baseUrl + "/sso/" + mode + "/Auth/" + provider + @"';
let response = await new Promise(resolve => {
+2 -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.3.0.0"
version: "3.4.0.0"
targetAbi: "10.8.0.0"
framework: "net6.0"
owner: "9p4"
@@ -15,6 +15,7 @@ artifacts:
- "IdentityModel.OidcClient.dll"
- "IdentityModel.dll"
changelog: |
3.4.0.0: Add user self-service for linking existing accounts + managing existing links. Allow IDP accounts to be linked to jellyfin accounts with a different display-name.
3.3.0.0: Add fallback authentication provider. Add OpenID admin page.
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)