mirror of
https://github.com/9p4/jellyfin-plugin-sso.git
synced 2026-09-19 13:12:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7ce40fe5e | ||
|
|
8666818dfa | ||
|
|
c5e6bf96f9 | ||
|
|
fd59b83a0e | ||
|
|
ab36aea359 | ||
|
|
fb268ed290 | ||
|
|
f00bf70597 | ||
|
|
f0d16c33b8 | ||
|
|
5c0e4ecefe | ||
|
|
ac1affa7b4 | ||
|
|
510624f142 | ||
|
|
840da75feb | ||
|
|
56b36e0fd4 | ||
|
|
a4ea12b7f4 | ||
|
|
843873d8d5 | ||
|
|
fc888de045 | ||
|
|
d72e47e7a7 | ||
|
|
a7fb4d4ef0 | ||
|
|
ebfcadc862 | ||
|
|
d8d7d616bd | ||
|
|
bd60d7e32c | ||
|
|
7e5738c65f | ||
|
|
f810ea7259 | ||
|
|
cc05b12a23 | ||
|
|
e7d70b8326 | ||
|
|
c5e1644d17 | ||
|
|
7ede38d0ab | ||
|
|
fad5a62e07 | ||
|
|
511e5e4f12 | ||
|
|
ad6328c1c5 | ||
|
|
7bc7e44768 | ||
|
|
d94199cb8f | ||
|
|
a0bfa6a3ee | ||
|
|
19bae4de9d |
@@ -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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -28,11 +28,16 @@ jobs:
|
||||
sha256sum ${file#./} >> ${file%.*}.sha256
|
||||
done
|
||||
ls -l
|
||||
- name: Upload GitHub Release assets
|
||||
uses: shogo82148/[email protected]
|
||||
- name: Publish output artifacts
|
||||
id: publish-assets
|
||||
uses: softprops/action-gh-release@50195ba7f6f93d1ac97ba8332a178e008ad176aa
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: ./*
|
||||
prerelease: false
|
||||
fail_on_unmatched_files: true
|
||||
tag_name: ${{ github.event.release.tag_name }}
|
||||
files: ./*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
generate:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
@@ -41,7 +46,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
@@ -448,7 +448,4 @@ $RECYCLE.BIN/
|
||||
## Visual Studio Code
|
||||
##
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.vscode
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*.min.js
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user