mirror of
https://github.com/9p4/jellyfin-plugin-sso.git
synced 2026-09-19 13:12:19 +00:00
Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7331e2ffe3 | ||
|
|
cef4fe5784 | ||
|
|
0d2952423d | ||
|
|
98ddb9e352 | ||
|
|
46eb00bf43 | ||
|
|
5723cd718d | ||
|
|
96b14f7a5a | ||
|
|
320551bc18 | ||
|
|
9f8626bdf7 | ||
|
|
d5925fc1de | ||
|
|
facf45058f | ||
|
|
f3d0497801 | ||
|
|
672fce8189 | ||
|
|
c2f50e1e4c | ||
|
|
a90e0359e4 | ||
|
|
332f62d76e | ||
|
|
47db5cb504 | ||
|
|
fe1fdad0b0 | ||
|
|
d06f680407 | ||
|
|
976a816d8c | ||
|
|
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 | ||
|
|
7e232d7cf9 | ||
|
|
eaf7e87747 | ||
|
|
8df2bd94e8 | ||
|
|
815d6cbe7e | ||
|
|
6a0fcfe711 | ||
|
|
f9331665e7 | ||
|
|
299f3f7220 | ||
|
|
4eec8bb789 | ||
|
|
55d795b9d8 | ||
|
|
5a8d1da6de |
@@ -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 }}
|
||||
@@ -0,0 +1,11 @@
|
||||
name: Lint Commit Messages
|
||||
on: [pull_request, push]
|
||||
|
||||
jobs:
|
||||
commitlint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: wagoid/commitlint-github-action@4caf21aed4a778f940d0b17eb109942ef167bb27
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Publish Nightly
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v1
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
- name: Build Dotnet
|
||||
run: dotnet build --no-restore --warnaserror
|
||||
- name: "Flag as nightly in build.yaml"
|
||||
uses: fjogeleit/[email protected]
|
||||
with:
|
||||
valueFile: 'build.yaml'
|
||||
propertyPath: 'version'
|
||||
value: "0.0.0.9000"
|
||||
commitChange: false
|
||||
updateFile: true
|
||||
- name: "JPRM: Build"
|
||||
id: jrpm
|
||||
uses: oddstr13/jellyfin-plugin-repository-manager@b9e92867a6aa279d611a5ea80cf61f6358838c39
|
||||
with:
|
||||
version: "0.0.0.9000"
|
||||
verbosity: debug
|
||||
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
|
||||
with:
|
||||
prerelease: false
|
||||
fail_on_unmatched_files: true
|
||||
tag_name: nightly
|
||||
files: |
|
||||
_dist/*
|
||||
build.yaml
|
||||
body: |
|
||||
Nightly build
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Publish Plugin Manifest
|
||||
uses: Kevinjil/jellyfin-plugin-repo-action@a7832ecc44c6b1a45d531970f6647b8682b005b8
|
||||
with:
|
||||
ignorePrereleases: true
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: ${{ github.repository }}
|
||||
pagesBranch: manifest-release
|
||||
pagesFile: manifest.json
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Publish Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- released
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
dotnet-version: "6.0.*"
|
||||
dotnet-target: "net6.0"
|
||||
upload:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download Artifact
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
name: build-artifact
|
||||
- name: Prepare GitHub Release assets
|
||||
run: |-
|
||||
for file in ./*; do
|
||||
md5sum ${file#./} >> ${file%.*}.md5
|
||||
sha256sum ${file#./} >> ${file%.*}.sha256
|
||||
done
|
||||
ls -l
|
||||
- name: Publish output artifacts
|
||||
id: publish-assets
|
||||
uses: softprops/action-gh-release@50195ba7f6f93d1ac97ba8332a178e008ad176aa
|
||||
with:
|
||||
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:
|
||||
- upload
|
||||
steps:
|
||||
- name: Publish Plugin Manifest
|
||||
uses: Kevinjil/jellyfin-plugin-repo-action@a7832ecc44c6b1a45d531970f6647b8682b005b8
|
||||
with:
|
||||
ignorePrereleases: true
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: ${{ github.repository }}
|
||||
pagesBranch: manifest-release
|
||||
pagesFile: manifest.json
|
||||
+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.
|
||||
@@ -38,8 +40,13 @@ This is 100% alpha software! PRs are welcome to improve the code.
|
||||
|
||||
## Tested Providers
|
||||
|
||||
[Find provider specific documentation in providers.md](providers.md)
|
||||
|
||||
- Authelia
|
||||
- authentik
|
||||
- Keycloak
|
||||
- OIDC & SAML
|
||||
- Google OpenID: Works, but usernames are all numeric
|
||||
- Keycloak OpenID and SAML: Works
|
||||
|
||||
## Supported Protocols
|
||||
|
||||
@@ -52,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
|
||||
@@ -175,10 +192,12 @@ These all require authorization. Append an API key to the end of the request: `c
|
||||
- `enableFolderRoles`: boolean. Determines if role-based folder access should be used.
|
||||
- `folderRoleMapping`: object in the format "role": string and "folders": array of strings. The user with this role will have access to the following folders if `enableFolderRoles` is enabled. To get the IDs of the folders, GET the `/Library/MediaFolders` URL with an API key. Look for the `Id` attribute.
|
||||
- `roleClaim`: string. This is the value in the OpenID response to check for roles. For Keycloak, it is `realm_access.roles` by default. The first element is the claim type, the subsequent values are to parse the JSON of the claim value. Use a "\\." to denote a literal ".". This expects a list of strings from the OIDC server.
|
||||
- `oidScopes` : array of strings. each containing an additional scope name to include in the OIDC request.
|
||||
- `oidScopes` : array of strings. Each contains an additional scope name to include in the OIDC request.
|
||||
- For some OIDC providers (For example, [authelia](https://github.com/9p4/jellyfin-plugin-sso/issues/23#issuecomment-1112237616)), additional scopes may be required in order to validate group membership in role claim.
|
||||
- Leave empty to only request the default scopes.
|
||||
- `defaultProvider`: string. The set provider then gets assigned to the user after they have logged in. If it is not set, nothing is changed. With this, a user can login with SSO but is still able to log in via other providers later. See the `Unregister` endpoint.
|
||||
- `defaultUsernameClaim`: string. The provider will use the claim to create the users' usernames. If not set, it fallbacks to `preferred_username`.
|
||||
- `requireHttps`: boolean. Determines whether the OpenID discovery endpoint requires HTTP. It is advised NOT to set this to false. If not set, it fallbacks to `true`.
|
||||
- GET `OID/Del/PROVIDER_NAME`: This removes a configuration for OpenID for a given provider name.
|
||||
- GET `OID/Get`: Lists the configurations currently available.
|
||||
- GET `OID/States`: Lists currently active OpenID flows in progress.
|
||||
@@ -199,6 +218,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;
|
||||
}
|
||||
}
|
||||
+393
-25
@@ -2,18 +2,23 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Mime;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
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 MediaBrowser.Model.Cryptography;
|
||||
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,7 +34,9 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly IAuthorizationContext _authContext;
|
||||
private readonly ILogger<SSOController> _logger;
|
||||
private readonly ICryptoProvider _cryptoProvider;
|
||||
private static readonly IDictionary<string, TimedAuthorizeState> StateManager = new Dictionary<string, TimedAuthorizeState>();
|
||||
|
||||
/// <summary>
|
||||
@@ -37,11 +44,15 @@ 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)
|
||||
/// <param name="cryptoProvider">Instance of the <see cref="ICryptoProvider"/> interface.</param>
|
||||
public SSOController(ILogger<SSOController> logger, ISessionManager sessionManager, IUserManager userManager, IAuthorizationContext authContext, ICryptoProvider cryptoProvider)
|
||||
{
|
||||
_sessionManager = sessionManager;
|
||||
_userManager = userManager;
|
||||
_authContext = authContext;
|
||||
_cryptoProvider = cryptoProvider;
|
||||
_logger = logger;
|
||||
_logger.LogInformation("SSO Controller initialized");
|
||||
}
|
||||
@@ -79,6 +90,7 @@ public class SSOController : ControllerBase
|
||||
Scope = string.Join(" ", config.OidScopes.Prepend("openid profile")),
|
||||
};
|
||||
options.Policy.Discovery.ValidateEndpoints = false; // For Google and other providers with different endpoints
|
||||
options.Policy.Discovery.RequireHttps = config.RequireHttps || true;
|
||||
var oidcClient = new OidcClient(options);
|
||||
var currentState = StateManager[state].State;
|
||||
var result = oidcClient.ProcessResponseAsync(Request.QueryString.Value, currentState).Result;
|
||||
@@ -98,7 +110,7 @@ public class SSOController : ControllerBase
|
||||
|
||||
foreach (var claim in result.User.Claims)
|
||||
{
|
||||
if (claim.Type == "preferred_username")
|
||||
if (claim.Type == (config.DefaultUsernameClaim ?? "preferred_username"))
|
||||
{
|
||||
StateManager[state].Username = claim.Value;
|
||||
if (config.Roles.Length == 0)
|
||||
@@ -180,7 +192,7 @@ public class SSOController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
// If the provider doesn't support preferred_username, then use sub
|
||||
// If the provider doesn't support the preferred username claim, then use the sub claim
|
||||
if (!StateManager[state].Valid)
|
||||
{
|
||||
foreach (var claim in result.User.Claims)
|
||||
@@ -196,9 +208,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 +235,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 +265,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 +312,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 +370,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 +386,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 +404,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 +426,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 +446,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 +463,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 +578,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 +604,317 @@ 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)
|
||||
{
|
||||
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;
|
||||
// https://jonathancrozier.com/blog/how-to-generate-a-cryptographically-secure-random-string-in-dot-net-with-c-sharp
|
||||
user.Password = _cryptoProvider.CreatePasswordHash(Convert.ToBase64String(RandomNumberGenerator.GetBytes(64))).ToString();
|
||||
|
||||
// Make sure there aren't any trailing existing links
|
||||
var links = GetCanonicalLinks(mode, provider);
|
||||
links.Remove(canonicalName);
|
||||
UpdateCanonicalLinkConfig(links, mode, provider);
|
||||
}
|
||||
|
||||
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...");
|
||||
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);
|
||||
@@ -610,7 +959,19 @@ public class SSOController : ControllerBase
|
||||
|
||||
private string GetRequestBase()
|
||||
{
|
||||
return Request.Scheme + "://" + Request.Host + Request.PathBase;
|
||||
int requestPort = Request.Host.Port ?? -1;
|
||||
if ((requestPort == 80 && string.Equals(Request.Scheme, "http", StringComparison.OrdinalIgnoreCase)) || (requestPort == 443 && string.Equals(Request.Scheme, "https", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
requestPort = -1;
|
||||
}
|
||||
|
||||
return new UriBuilder
|
||||
{
|
||||
Scheme = Request.Scheme,
|
||||
Host = Request.Host.Host,
|
||||
Port = requestPort,
|
||||
Path = Request.PathBase
|
||||
}.ToString().TrimEnd('/');
|
||||
}
|
||||
|
||||
private ContentResult ReturnError(int code, string message)
|
||||
@@ -670,6 +1031,7 @@ public class TimedAuthorizeState
|
||||
Created = created;
|
||||
Valid = false;
|
||||
Admin = false;
|
||||
IsLinking = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -697,6 +1059,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,34 @@ 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>
|
||||
/// Gets or sets the default username claim when creating new accounts.
|
||||
/// </summary>
|
||||
public string DefaultUsernameClaim { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether HTTPS in the discovery endpoint is required.
|
||||
/// </summary>
|
||||
public bool RequireHttps { get; set; }
|
||||
}
|
||||
|
||||
/// <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">
|
||||
@@ -420,6 +437,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label
|
||||
class="inputLabel inputLabelUnfocused"
|
||||
for="DefaultUsernameClaim"
|
||||
>Set default username claim:</label
|
||||
>
|
||||
<input
|
||||
is="emby-input"
|
||||
id="DefaultUsernameClaim"
|
||||
type="text"
|
||||
class="sso-text"
|
||||
/>
|
||||
<div class="fieldDescription">
|
||||
The default username claim to use from OpenID by default. If
|
||||
it is not set, it defaults to
|
||||
<code>preferred_username</code>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="SaveProvider"
|
||||
is="emby-button"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
+200
@@ -0,0 +1,200 @@
|
||||
# Provider Specific Configuration
|
||||
|
||||
This plugin has been tested to work against various providers, though not all providers provide support for all of this plugins' features.
|
||||
|
||||
## TOC / Tested Providers:
|
||||
|
||||
This section is broken into providers that support Role-Based Access Control (RBAC), and those that do not
|
||||
|
||||
### Providers that support RBAC
|
||||
|
||||
- ✅ [Authelia](#authelia)
|
||||
- ✅ [authentik](#authentik)
|
||||
- [✅ Keycloak](#keycloak-oidc)
|
||||
- Both [OIDC](#keycloak-oidc) & [SAML](#keycloak-saml)
|
||||
|
||||
### No RBAC Support
|
||||
|
||||
- ✅ Google OIDC
|
||||
- ❗ Usernames are numeric
|
||||
|
||||
## General Options, when RBAC is supported
|
||||
|
||||
For any provider that supports RBAC, we can configure it as we see fit:
|
||||
|
||||
```yaml
|
||||
Enabled: true
|
||||
EnableAuthorization: true
|
||||
EnableAllFolders: true
|
||||
EnabledFolders: []
|
||||
Roles: ["jellyfin_user"]
|
||||
AdminRoles: ["jellyfin_admin"]
|
||||
EnableFolderRoles: false
|
||||
FolderRoleMapping: []
|
||||
```
|
||||
|
||||
## Authelia
|
||||
|
||||
Authelia is simple to configure, and RBAC is straightforward.
|
||||
|
||||
### Authelia's Config
|
||||
|
||||
Below is the `identity_providers` section of an Authelia config:
|
||||
|
||||
```yaml
|
||||
identity_providers:
|
||||
oidc:
|
||||
# hmac secret and private key given by env variables
|
||||
clients:
|
||||
- id: jellyfin
|
||||
description: My media server
|
||||
# Client secret should be randomly generated
|
||||
secret: <redacted>
|
||||
authorization_policy: one_factor
|
||||
redirect_uris:
|
||||
- https://jellyfin.example.com/sso/OID/r/authelia
|
||||
```
|
||||
|
||||
### Jellyfin's Config
|
||||
|
||||
On Jellyfin's end, we need to configure an Authelia provider as follows:
|
||||
|
||||
In order to test group membership, we need to request Authelia's `groups` OIDC scope, which we will use to check user roles.
|
||||
|
||||
```yaml
|
||||
authelia:
|
||||
OidEndpoint: https://authelia.example.com
|
||||
OidClientId: jellyfin
|
||||
OidSecret: <redacted>
|
||||
RoleClaim: groups
|
||||
OidScopes: ["groups"]
|
||||
```
|
||||
|
||||
## authentik
|
||||
|
||||
To begin with, we must set up an OIDC provider + application in authentik. Refer to the official documentation for detailed instruction.
|
||||
|
||||
### authentik's Config
|
||||
|
||||
authentik supports RBAC, but is slightly more complicated to configure than Authelia, as we need to configure a custom scope binding to include in the OIDC response.
|
||||
|
||||
To do this, we:
|
||||
|
||||
- create a **Custom Property Mapping**
|
||||
|
||||

|
||||
|
||||
- Create a **Scope Mapping**
|
||||
|
||||

|
||||
|
||||
- Assign the following attributes:
|
||||
|
||||

|
||||
|
||||
```yaml
|
||||
# A nice, human readable name
|
||||
name: Group Membership
|
||||
# The name of the scope a client must request to get access to a user's groups
|
||||
Scope Name: groups
|
||||
# A description of what is being requested to show to a user
|
||||
Description: See Which Groups you belong to
|
||||
```
|
||||
|
||||
- For the **Expression** field, use the following code:
|
||||
```python
|
||||
return [group.name for group in user.ak_groups.all()]
|
||||
```
|
||||
|
||||
Now we can add this property mapping to authentik's Jellyfin OAuth provider:
|
||||
|
||||
- Navigate to `Applications/providers`
|
||||
|
||||

|
||||
|
||||
- Edit / Update your Jellyfin OAuth provider
|
||||
- Under **"Advanced Protocol Settings"**, add the **Group Membership** Scope
|
||||
|
||||

|
||||
|
||||
### Jellyfin's Config
|
||||
|
||||
On Jellyfin's end, we need to configure an authentik provider as follows:
|
||||
|
||||
In order to test group membership, we need to request authentik's OIDC scope `groups`, which we will use to check user roles.
|
||||
|
||||
```yaml
|
||||
authentik:
|
||||
OidEndpoint: https://authentik.example.com/application/o/jellyfin
|
||||
OidClientId: <same-as-in-authentik>
|
||||
OidSecret: <redacted>
|
||||
RoleClaim: groups
|
||||
OidScopes: ["groups"]
|
||||
```
|
||||
|
||||
## Keycloak OIDC
|
||||
|
||||
Keycloak in general is a little more complicated than other providers. Ensure that you have a realm created and have some usable users.
|
||||
|
||||
### Keycloak's Config
|
||||
|
||||
Create a new Keycloak `openid-connect` application. Set the root URL to your Jellyfin URL (ie https://myjellyfin.example.com)
|
||||
|
||||
Ensure that the following configuration options are set:
|
||||
|
||||
- Access Type: Confidential
|
||||
- Standard Flow Enabled
|
||||
- Redirect URI: https://myjellyfin.example.com/sso/OID/r/PROVIDER_NAME
|
||||
- Base URL: https://myjellyfin.example.com
|
||||
|
||||
Press the "Save" button at the bottom of the page and open the "Credentials" tab. Note down the secret.
|
||||
|
||||
For adding groups and RBAC, go to the "mappers" tab, press "Add Builtin", and select either "Groups", "Realm Roles", or "Client Roles", depending on the role system you are planning on using. Once the mapper is added, edit the mapper and ensure that you note down the Token Claim Name as well as enable all four toggles: "Multivalued", "Add to ID token", "Add to access token", and "Add to userinfo" are enabled.
|
||||
|
||||
Note that if you are using the template for the "Client Roles" mapper, the default token claim name has `${client_id}` in it. When noting down this value, make sure you note down the actual Client ID (which should be written above).
|
||||
|
||||
### Jellyfin's Config
|
||||
|
||||
On Jellyfin's side, we need to configure a Keycloak provider as follows:
|
||||
|
||||
```yaml
|
||||
keycloak:
|
||||
OidEndpoint: https://keycloak.example.com/realms/<realm>
|
||||
OidClientId: <same-as-in-keycloak>
|
||||
OidSecret: <redacted>
|
||||
RoleClaim: <same-as-token-claim-name>
|
||||
```
|
||||
|
||||
## Keycloak SAML
|
||||
|
||||
Keycloak with SAML is very similar to OpenID. Again, Keycloak in general is a little more complicated than other providers. Ensure that you have a realm created and have some usable users.
|
||||
|
||||
### Keycloak's Config
|
||||
|
||||
Create a new Keycloak `saml` application. Set the root URL to your Jellyfin URL (ie https://myjellyfin.example.com)
|
||||
|
||||
Ensure that the following configuration options are set:
|
||||
|
||||
- Sign Documents on
|
||||
- Sign Assertions off
|
||||
- Client Signature Required off
|
||||
- Redirect URI: [https://myjellyfin.example.com/sso/SAML/p/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/p/PROVIDER_NAME)
|
||||
- Base URL: [https://myjellyfin.example.com](https://myjellyfin.example.com)
|
||||
- Master SAML processing URL: [https://myjellyfin.example.com/sso/SAML/p/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/p/PROVIDER_NAME)
|
||||
|
||||
Press the "Save" button at the bottom of the page.
|
||||
|
||||
For adding groups and RBAC, go to the "mappers" tab, press "Add Builtin", and select either "Groups", "Realm Roles", or "Client Roles", depending on the role system you are planning on using. Once the mapper is added, edit the mapper and ensure that you note down the Token Claim Name as well as enable all four toggles: "Multivalued", "Add to ID token", "Add to access token", and "Add to userinfo" are enabled.
|
||||
|
||||
Note that if you are using the template for the "Client Roles" mapper, the default token claim name has `${client_id}` in it. When noting down this value, make sure you note down the actual Client ID (which should be written above).
|
||||
|
||||
Finally, download the certificate. Open the "Installation" tab, select "Mod Auth Mellon files", and download the zip. Extract the zip file, and open the `idp-metadata.xml` file. Note down the contents of the `X509Certificate` value.
|
||||
|
||||
### Jellyfin's Config
|
||||
|
||||
```yaml
|
||||
keycloak:
|
||||
SamlEndpoint: https://keycloak.example.com/realms/<realm>/protocol/saml
|
||||
SamlClientId: <same-as-in-keycloak>
|
||||
SamlCertificate: <copied-from-xml-file>
|
||||
```
|
||||
Reference in New Issue
Block a user