Compare commits

...
97 Commits
Author SHA1 Message Date
Ersei Saggi f1e157bac2 I'm tired of this grandpa 2026-05-11 21:52:38 -07:00
Ersei Saggi 83deeb253c Validate SAML responses 2026-03-19 20:11:02 -07:00
Ersei Saggi ca187a7b5e Make prettier happy 2026-02-22 11:04:40 -08:00
9p4 05213dc1ee Merge pull request #349 from mandos21/main
Bugfix: Prevent KeyNotFoundExceptions in OidPost
2026-02-11 20:11:01 -08:00
9p4 f7a0c94452 Merge pull request #318 from ForsakenRei/ForsakenRei-patch-1
Update README dead link, add warning for permission overwritten
2026-02-11 07:51:22 -08:00
mandos21 475048a32e Fixed KeyNotFoundExceptions in PostOid flow 2026-02-02 21:20:49 -05:00
Matt DeGenaro 781e07b2ce Update role mapping information in configPage.html
Clarified default role mapping for Keycloak and Authelia.
2026-02-02 20:27:15 -05:00
9p4 fed7e764a8 Merge pull request #347 from Joker9944/kanidm-config-example
Add kanidm config steps
2026-02-01 10:29:53 -08:00
Joker9944 f0def6fd8d Add kanidm config steps 2026-02-01 10:12:00 +01:00
9p4 8e128932c4 Merge pull request #343 from jon4hz/fix-invalidate-session-immediately
fix: invalidate state immediately after auth
2026-01-14 11:39:57 -08:00
jon4hz 5b3d70d328 fix: invalidate state immediately after auth 2026-01-14 20:36:04 +01:00
Ersei Saggi be26670e1f Make prettier happy 2026-01-12 17:48:12 -05:00
Ersei Saggi 547eabf55f Invalidate tokens after authentication
In certain cases, users who had their access revoked could log back in.
2026-01-12 17:46:02 -05:00
Ersei Saggi 48d75325b5 Use Jellyfin's HttpClientFactory
This should respect proxy settings and set a proper user-agent
2026-01-12 17:35:29 -05:00
Ersei Saggi cdce0e583d Add loggerFactory to XML comment
Makes CI happy
2025-12-21 02:41:32 -05:00
Ersei Saggi 0e897f922f Allow not loading profile information from user info endpoint 2025-12-18 15:14:34 -05:00
Ersei Saggi afbab1073e Add logging for OIDC library 2025-12-18 15:14:32 -05:00
しぐれ 87425aae36 doc: Add warning for permission overwritten 2025-11-13 21:36:10 -05:00
しぐれ 9be9a1fed8 doc: fix dead OpenID link in README 2025-11-13 21:25:29 -05:00
9p4 3b47851131 Merge pull request #312 from mhlas7/main
Add Pocket ID config steps
2025-11-13 09:37:31 -05:00
mhlas7 337ea0ba04 Add Pocket ID to the readme 2025-10-26 14:24:54 -07:00
mhlas7 c37e3e3a71 Add Pocket ID config steps 2025-10-26 12:45:21 -07:00
Ersei Saggi 8f86ce5101 Prepare for 4.0.0.3 2025-10-21 10:37:39 -04:00
Ersei Saggi d2c77db404 Ensure that disablePushedAuthorization is set to true for authelia 2025-10-21 00:48:35 -04:00
Ersei Saggi fc3976dcc8 Update download artifact too 2025-10-20 22:48:00 -04:00
Ersei Saggi fc58b8e80f Update upload action 2025-10-20 22:43:33 -04:00
Ersei Saggi da21ebffa3 Specify that pushed authorization may need to be disabled for Authelia 2025-10-20 12:40:02 -04:00
Ersei Saggi 0d18ebae03 Update config UI links
https://github.com/9p4/jellyfin-plugin-sso/issues/285
2025-10-20 12:30:55 -04:00
Ersei Saggi 77e2a229f2 Don't use rc libs for Jellyfin 10.11 2025-10-20 12:30:15 -04:00
Ersei Saggi 71bb9a2f14 Jellyfin 10.11.0-rc5 2025-08-14 12:43:04 -04:00
Ersei Saggi 8baa922eea Manifest should be for 4.0.0.0 / JF10.11 2025-08-12 14:57:22 -04:00
Ersei Saggi 0738ad85f7 Fix warnings 2025-08-12 14:22:31 -04:00
Ersei Saggi 8d049705c4 Update workflows to use dotnet9 2025-08-12 13:51:13 -04:00
Ersei Saggi 3459baffdf More Jellyfin 10.11 fixes 2025-08-12 13:49:09 -04:00
9p4 de6cd5d5c2 Merge pull request #265 from babbitt/punycode
Use punycode URL for redirect page - OIDC
2025-08-11 14:13:48 -04:00
9p4 299d3436ec Merge pull request #269 from kernelb00t/patch-1
Match the styling of the redirection page with Jellyfin colors and font
2025-08-11 14:12:38 -04:00
9p4 5808f34064 Merge pull request #280 from hendrik1120/patch-1
remove deprecated redirect uri from readme
2025-08-11 14:12:22 -04:00
Ersei Saggi 5ed4c8bbcc Update to net9.0 for JF 10.11 2025-08-11 14:11:52 -04:00
Hendrik Sievers f78a0f3108 remove deprecated redirect uri 2025-07-18 11:04:29 +02:00
Leo THIVILLON aa0e361265 Match the styling of the redirection page with Jellyfin colors and font
- Added a style tag
- Set color to Jellyfin's dark background and light text
- Set font to Noto Sans
2025-05-09 23:26:51 +02:00
Ersei Saggi efc997c39e Add security policy
Closes #266
2025-05-06 13:46:32 -04:00
Joe Babbitt caab66b653 use punycode URL for redirect page
Redirect page content gets encoded as LATIN1, non LATIN characters in URLs get malformed.
2025-04-29 17:22:38 -04:00
9p4 79ac628c4c Merge pull request #251 from FeikoJoosten/main
Prevent force updating username
2025-04-07 02:30:43 -04:00
Ersei Saggi fe3ca6337d We support >=10.8 2025-04-07 02:16:48 -04:00
Feiko Joosten f761dea356 Removed trailing whitespace 2025-04-05 18:59:36 +02:00
Feiko Joosten 55f58ecedd Prevent force updating username
Fixed an issue where you cannot modify the username as the code tried to find users by name instead of its id.
2025-04-04 01:58:01 +02:00
Ersei Saggi 556feba864 Update credits in README to point to Duende library 2025-03-26 13:55:40 -04:00
Ersei Saggi 1f08ccd213 Prepare for 3.5.3.0 2025-03-26 13:51:26 -04:00
Ersei Saggi f1f57723c3 Make prettier happy again 2025-03-26 13:14:36 -04:00
Borja Domínguez 216908acd5 Fix artifact names 2025-03-26 16:40:18 +01:00
9p4 9365ca2a33 Merge pull request #246 from bdovaz/allow-port-override
Allow port override
2025-03-26 11:34:08 -04:00
Borja Domínguez ae0ee62f21 Merge branch 'main' into allow-port-override 2025-03-26 16:32:12 +01:00
Ersei Saggi 728a994728 Make prettier happy 2025-03-26 11:20:29 -04:00
9p4 9c0597a5bc Merge pull request #202 from Estyms/main
Avatar via OIDC Provider
2025-03-26 11:18:24 -04:00
9p4 fb9335d908 Merge pull request #247 from bdovaz/fix-nightly-build
Update oddstr13/jellyfin-plugin-repository-manager
2025-03-26 11:15:43 -04:00
9p4 cbbafc976b Merge pull request #244 from bdovaz/replace-oidc-dependency
Allow to disable pushed authorization
2025-03-26 11:11:31 -04:00
Borja Domínguez Vázquez f7bde6f1a0 Change to commit hash 2025-03-24 21:46:49 +01:00
Borja Domínguez Vázquez d7351ef596 Change EOL 2025-03-24 21:44:34 +01:00
Borja Domínguez Vázquez 07315a124c Change EOL 2025-03-24 21:43:44 +01:00
Borja Domínguez Vázquez 9e96bd5218 Update oddstr13/jellyfin-plugin-repository-manager 2025-03-24 20:26:18 +01:00
Borja Domínguez Vázquez 6f77735248 Allow to disable pushed authorization 2025-03-24 20:12:48 +01:00
Borja Domínguez Vázquez 73b6c17518 Allow port override 2025-03-24 20:06:28 +01:00
Borja Domínguez Vázquez 86dcd4c579 Better error handling 2025-03-24 20:04:44 +01:00
Borja Domínguez Vázquez 553c88873e Replace deprecated oidc dependency 2025-03-23 15:00:26 +01:00
9p4 cc86549c2e Merge pull request #239 from andreblanke/main
Support DisableHttps/DoNotValidateIssuerName in SSOController.OidChallenge
2025-03-16 23:10:35 -04:00
Andre Blanke a2064c1f17 Support DisableHttps/DoNotValidateIssuerName in OidChallenge 2025-03-03 16:54:28 +01:00
Evann Regnault 2cf4230bd6 Merge branch 'main' into main 2025-02-28 04:28:39 +01:00
Ersei Saggi 91c57b18f5 Allow for Fsharp code to be included 2024-11-05 15:17:02 -05:00
Evann Regnault feb56ebdd6 [AvatarUrlFormat] Better Handling
- Mimetype is now inferred by the Response header.
- Extension is now derived from the Mimetype
2024-08-04 13:41:37 +02:00
Evann Regnault 2d6e2fc938 Text and JSON fields can now be emptied in configuration 2024-08-04 03:12:50 +02:00
Evann Regnault ef085e9fa3 Updated README.md
- Avatar Url Format Explanation
2024-08-04 03:12:15 +02:00
Evann Regnault 95d2c36e2c Added AvatarFormatUrl to programatically set avatar on SSO Connect with OIDC 2024-08-04 03:00:06 +02:00
Ersei Saggi b8e56cefab Update infra files 2024-06-04 10:26:15 -04:00
Ersei Saggi e65f358c79 Update webresponse to use updated JS code 2024-06-04 10:25:58 -04:00
Ersei Saggi cd35ef45ae Formatting 2024-06-04 09:56:19 -04:00
9p4 b5068f53a6 Merge pull request #193 from CFenner/patch-1
fix: consistently use OpenID instead of OID on the config page
2024-06-04 09:54:04 -04:00
Christopher Fenner 6f584597f6 consistently use OpenID instead of OID on the config page 2024-06-04 12:03:11 +02:00
Ersei Saggi 42fba8656a Make CI happy 2024-05-13 09:32:52 -04:00
Ersei Saggi 618bcdbd8a Update GH workflows to use net8.0 2024-05-13 09:31:05 -04:00
Ersei Saggi 2e8c19d631 Update for Jellyfin 10.9 2024-05-13 09:17:42 -04:00
9p4 19a6b49afa Merge pull request #190 from tbelway/main
Update providers.md to include android app keycloak OIDC redirect URI
2024-05-13 08:31:54 -04:00
tbelway 5b26808f0e Update providers.md to include android app keycloak redirect URI 2024-05-12 20:24:20 -04:00
9p4 2724d64de8 Merge pull request #170 from hendrik1120/main
update authelia example to authelia v4.38
2024-03-14 16:40:11 -04:00
Hendrik Sievers 63ec46db4a keep authelia example configuration prior to v4.38 2024-03-14 21:29:49 +01:00
Hendrik Sievers 4c891d8ef0 update authelia example to authelia v4.38
fix warnings in authelia
fix authelia using client_secret_basic instead of client_secret_post
2024-03-14 20:50:39 +01:00
Jade Lovelace 29189a9af0 Update version in SSO-Auth.csproj 2024-02-22 20:23:49 -05:00
Ersei Saggi 3894048168 Prepare for v3.5.2.3 2024-02-21 17:09:51 -05:00
Ersei Saggi d51e5069d6 feat: allow for better endpoint validation 2024-02-20 09:11:48 -05:00
Sergii Bogomolov 5149cef625 Remove jellyfin_credentials before loading iframe
Due to Safari's aggressive caching updated jellyfin_credentials were not
used. They were overwritten with the old credentials instead. This fix
removes jellyfin_credentials first, then let's iframe load and create
new jellyfin_credentials with no access token. After this we update it
and login succeeds.
2024-02-10 13:09:05 -05:00
Ersei Saggi 9be3e8e9c4 Update flake.lock 2024-02-07 10:26:00 -05:00
Ersei Saggi db203808f2 Prettier README linting 2024-02-04 17:40:43 -05:00
Ersei Saggi ec82bf7fbc Update README to use form-based login button 2024-02-04 17:38:11 -05:00
Ersei Saggi 87edc5f90a Prepare for 3.5.2.2 2024-01-22 16:38:06 -05:00
Ersei Saggi 304170e214 Update flake.lock 2024-01-11 09:57:16 -05:00
Ersei Saggi 9321c25f20 feat: allow linking to work with new paths 2024-01-11 09:56:45 -05:00
Ersei Saggi ebf7eef83e docs: mention quick connect in README 2023-12-09 16:15:43 -05:00
Ersei Saggi 065ea8b5cd docs: mention to have both paths in redirects 2023-11-24 17:59:46 -05:00
26 changed files with 803 additions and 279 deletions
+5 -5
View File
@@ -3,12 +3,12 @@ on:
inputs:
dotnet-version:
required: false
default: "6.0.x"
default: "9.0.x"
description: "The .NET version to setup for the build"
type: string
dotnet-target:
required: false
default: "net6.0"
default: "net9.0"
description: "The .NET target to set for JPRM"
type: string
@@ -25,15 +25,15 @@ jobs:
dotnet-version: "${{ inputs.dotnet-version }}"
- name: Build Jellyfin Plugin
uses: oddstr13/jellyfin-plugin-repository-manager@b9e92867a6aa279d611a5ea80cf61f6358838c39
uses: oddstr13/jellyfin-plugin-repository-manager@9497a0a499416cc572ed2e07a391d9f943a37b4d # v1.1.1
id: jprm
with:
dotnet-target: "${{ inputs.dotnet-target }}"
- name: Upload Artifact
uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # tag=v3
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # tag=v4.6.2
with:
name: build-artifact
retention-days: 30
if-no-files-found: error
path: ${{ steps.jprm.outputs.artifact }}
path: ${{ steps.jprm.outputs.artifact }}
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v1
with:
dotnet-version: 6.0.x
dotnet-version: 9.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
+3 -3
View File
@@ -15,7 +15,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v1
with:
dotnet-version: 6.0.x
dotnet-version: 9.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build Dotnet
@@ -30,12 +30,12 @@ jobs:
updateFile: true
- name: "JPRM: Build"
id: jrpm
uses: oddstr13/jellyfin-plugin-repository-manager@b9e92867a6aa279d611a5ea80cf61f6358838c39
uses: oddstr13/jellyfin-plugin-repository-manager@9497a0a499416cc572ed2e07a391d9f943a37b4d # v1.1.1
with:
version: "0.0.0.9000"
verbosity: debug
path: .
dotnet-target: "net6.0"
dotnet-target: "net9.0"
output: _dist
- name: Prepare GitHub Release assets
run: |-
+3 -3
View File
@@ -10,15 +10,15 @@ jobs:
build:
uses: ./.github/workflows/build.yml
with:
dotnet-version: "6.0.*"
dotnet-target: "net6.0"
dotnet-version: "9.0.*"
dotnet-target: "net9.0"
upload:
runs-on: ubuntu-latest
needs:
- build
steps:
- name: Download Artifact
uses: actions/download-artifact@v2.1.0
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: build-artifact
- name: Prepare GitHub Release assets
+13 -8
View File
@@ -22,6 +22,8 @@
</a>
</p>
Project archived because I'm tired of working on this after all the years.
This plugin allows users to sign in through an SSO provider (such as Google, Microsoft, or your own provider). This enables one-click signin.
https://user-images.githubusercontent.com/17993169/149681516-f93b43f5-fa5c-4c1f-a909-e5414878a864.mp4
@@ -34,7 +36,7 @@ This is 100% alpha software! PRs are welcome to improve the code.
~~There is NO admin configuration! You must use the API to configure the program!~~ Added by [strazto](https://github.com/strazto) in PR [#18](https://github.com/9p4/jellyfin-plugin-sso/pull/18) and [#27](https://github.com/9p4/jellyfin-plugin-sso/pull/27).
**[This is for Jellyfin 10.8](https://github.com/9p4/jellyfin-plugin-sso/issues/3) and only on the Web UI!**
**[This is for Jellyfin >=10.8](https://github.com/9p4/jellyfin-plugin-sso/issues/3) and only on the Web UI or clients supporting [Quick Connect](https://jellyfin.org/docs/general/server/quick-connect)**
**This README reflects the branch it is currently on! Switch tags to view version-specific documentation!**
@@ -46,11 +48,13 @@ This is 100% alpha software! PRs are welcome to improve the code.
- authentik
- Keycloak
- OIDC & SAML
- Pocket ID
- Kanidm
- Google OpenID: Works, but usernames are all numeric
## Supported Protocols
- [OpenID](https://openid.net/what-is-openid/)
- [OpenID](https://openid.net/developers/how-connect-works/)
- [SAML](https://www.cloudflare.com/learning/access-management/what-is-saml/)
## Security
@@ -96,11 +100,11 @@ The nightly build may have new features unavailable in other builds, but **be wa
In the Jellyfin administration UI, under "General", there is a "Branding" section. In that section, add the following code in the "Login disclaimer" block (replacing `PROVIDER_NAME` and the domain):
```html
<a
href="https://jellyfin.example.com/sso/OID/start/PROVIDER_NAME"
class="raised cancel block emby-button"
>Sign in with SSO</a
>
<form action="https://jellyfin.example.com/sso/OID/start/PROVIDER_NAME">
<button class="raised block emby-button button-submit">
Sign in with SSO
</button>
</form>
```
Then, add the following code in the "Custom CSS code" section:
@@ -237,6 +241,7 @@ These all require authorization. Append an API key to the end of the request: `c
- 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`.
- `avatarUrlFormat`: string. The URL format for the users avatars. OIDC claims can be used by using the `@{claim_type}` syntax. If not set, the avatars won't change.
- `disableHttps`: boolean. Determines whether the OpenID discovery endpoint requires HTTPS.
- `doNotValidateEndpoints`: boolean. Determines whether the OpenID discovery process will validate endpoints. This may be required for Google.
- `doNotValidateIssuerName`: boolean. Determines whether the OpenID discovery process will validate the OpenID issuer name.
@@ -308,7 +313,7 @@ Much thanks to the [Jellyfin LDAP plugin](https://github.com/jellyfin/jellyfin-p
I use the [AspNet SAML](https://github.com/jitbit/AspNetSaml/) library for the SAML side of things (patched to work with Base64 on non-Windows machines).
I use the [IdentityModel OIDC Client](https://github.com/IdentityModel/IdentityModel.OidcClient/) library for the OpenID side of things.
I use the [Duende IdentityModel OIDC Client](https://github.com/DuendeSoftware/foss) library for the OpenID side of things.
Thanks to these projects, without which I would have been pulling my hair out implementing these protocols from scratch.
+3
View File
@@ -0,0 +1,3 @@
Please email all security vulnerabilities and issues found to the email "contact at ersei dot net". If using LLMs/AI to find the issues, first verify the issue exists manually. Please do not publicly disclose security vulnerabilities until after a stable release for the fix has been released for 30 days.
The latest released version is the only supported version.
+2 -2
View File
@@ -1,7 +1,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.30114.105
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO-Auth", "SSO-Auth\SSO-Auth.csproj", "{C30A5CFB-B27E-4E83-9E96-1E0362B36748}"
EndProject
+2 -1
View File
@@ -9,7 +9,8 @@
using System;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller.Net;
using Microsoft.AspNetCore.Http;
+305 -55
View File
@@ -1,27 +1,34 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Mime;
using System.Reflection;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using IdentityModel.OidcClient;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using Duende.IdentityModel.OidcClient;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.SSO_Auth.Config;
using Jellyfin.Plugin.SSO_Auth.Helpers;
using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Providers;
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;
using SSO_Auth.Lib;
namespace Jellyfin.Plugin.SSO_Auth.Api;
@@ -36,24 +43,45 @@ public class SSOController : ControllerBase
private readonly ISessionManager _sessionManager;
private readonly IAuthorizationContext _authContext;
private readonly ILogger<SSOController> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ICryptoProvider _cryptoProvider;
private readonly IProviderManager _providerManager;
private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly IHttpClientFactory _httpClientFactory;
private static readonly IDictionary<string, TimedAuthorizeState> StateManager = new Dictionary<string, TimedAuthorizeState>();
/// <summary>
/// Initializes a new instance of the <see cref="SSOController"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{SSOController}"/> interface.</param>
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> 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>
/// <param name="cryptoProvider">Instance of the <see cref="ICryptoProvider"/> interface.</param>
public SSOController(ILogger<SSOController> logger, ISessionManager sessionManager, IUserManager userManager, IAuthorizationContext authContext, ICryptoProvider cryptoProvider)
/// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
public SSOController(
ILogger<SSOController> logger,
ILoggerFactory loggerFactory,
ISessionManager sessionManager,
IUserManager userManager,
IAuthorizationContext authContext,
ICryptoProvider cryptoProvider,
IProviderManager providerManager,
IHttpClientFactory httpClientFactory,
IServerConfigurationManager serverConfigurationManager)
{
_sessionManager = sessionManager;
_userManager = userManager;
_authContext = authContext;
_cryptoProvider = cryptoProvider;
_logger = logger;
_loggerFactory = loggerFactory;
_providerManager = providerManager;
_serverConfigurationManager = serverConfigurationManager;
_httpClientFactory = httpClientFactory;
_logger.LogInformation("SSO Controller initialized");
}
@@ -82,46 +110,78 @@ public class SSOController : ControllerBase
if (config.Enabled)
{
if (string.IsNullOrEmpty(state))
{
return BadRequest("Missing state");
}
if (!StateManager.TryGetValue(state, out var timedState))
{
return BadRequest("Invalid or expired state");
}
var scopes = config.OidScopes == null ? new string[2] : config.OidScopes;
var options = new OidcClientOptions
{
Authority = config.OidEndpoint?.Trim(),
ClientId = config.OidClientId?.Trim(),
ClientSecret = config.OidSecret?.Trim(),
RedirectUri = GetRequestBase(config.SchemeOverride) + $"/sso/OID/{(Request.Path.Value.Contains("/start/", StringComparison.InvariantCultureIgnoreCase) ? "redirect" : "r")}/" + provider,
RedirectUri = GetRequestBase(config.SchemeOverride, config.PortOverride) + $"/sso/OID/{(Request.Path.Value.Contains("/start/", StringComparison.InvariantCultureIgnoreCase) ? "redirect" : "r")}/" + provider,
Scope = string.Join(" ", scopes.Prepend("openid profile")),
DisablePushedAuthorization = config.DisablePushedAuthorization,
LoggerFactory = _loggerFactory,
LoadProfile = !config.DoNotLoadProfile,
HttpClientFactory = o =>
{
var client = _httpClientFactory.CreateClient();
System.Reflection.Assembly assembly = System.Reflection.Assembly.GetExecutingAssembly();
System.Diagnostics.FileVersionInfo fvi = System.Diagnostics.FileVersionInfo.GetVersionInfo(assembly.Location);
string version = fvi.FileVersion;
client.DefaultRequestHeaders.UserAgent.ParseAdd($"Jellyfin-Plugin-SSO-Auth +{version} (https://github.com/9p4/jellyfin-plugin-sso)");
return client;
}
};
var oidEndpointUri = new Uri(config.OidEndpoint?.Trim());
options.Policy.Discovery.AdditionalEndpointBaseAddresses.Add(oidEndpointUri.GetLeftPart(UriPartial.Authority));
options.Policy.Discovery.ValidateEndpoints = !config.DoNotValidateEndpoints; // For Google and other providers with different endpoints
options.Policy.Discovery.RequireHttps = !config.DisableHttps;
options.Policy.Discovery.ValidateIssuerName = !config.DoNotValidateIssuerName;
var oidcClient = new OidcClient(options);
var currentState = StateManager[state].State;
var currentState = timedState.State;
var result = await oidcClient.ProcessResponseAsync(Request.QueryString.Value, currentState).ConfigureAwait(false);
if (result.IsError)
{
return ReturnError(StatusCodes.Status400BadRequest, result.Error + " Try logging in again.");
return ReturnError(StatusCodes.Status400BadRequest, $"Error logging in: {result.Error} - {result.ErrorDescription}");
}
if (!config.EnableFolderRoles && config.EnabledFolders != null)
{
StateManager[state].Folders = new List<string>(config.EnabledFolders);
timedState.Folders = new List<string>(config.EnabledFolders);
}
else
{
StateManager[state].Folders = new List<string>();
timedState.Folders = new List<string>();
}
StateManager[state].EnableLiveTv = config.EnableLiveTv;
StateManager[state].EnableLiveTvManagement = config.EnableLiveTvManagement;
timedState.EnableLiveTv = config.EnableLiveTv;
timedState.EnableLiveTvManagement = config.EnableLiveTvManagement;
if (config.AvatarUrlFormat is not null)
{
timedState.AvatarURL = result.User.Claims.Aggregate(
config.AvatarUrlFormat,
(s, claim) => s.Contains($"@{{{claim.Type}}}") ? s.Replace($"@{{{claim.Type}}}", claim.Value) : s);
}
foreach (var claim in result.User.Claims)
{
if (claim.Type == (config.DefaultUsernameClaim?.Trim() ?? "preferred_username"))
{
StateManager[state].Username = claim.Value;
timedState.Username = claim.Value;
if (config.Roles == null || config.Roles.Length == 0)
{
StateManager[state].Valid = true;
timedState.Valid = true;
}
}
@@ -147,14 +207,40 @@ public class SSOController : ControllerBase
{
// We recursively traverse through the JSON data for the roles and parse it
var json = JsonConvert.DeserializeObject<IDictionary<string, object>>(claim.Value);
for (int i = 1; i < segments.Length - 1; i++)
if (json is null)
{
var segment = segments[i];
json = (json[segment] as JObject).ToObject<IDictionary<string, object>>();
roles = new List<string>();
}
else
{
bool missingSegment = false;
for (int i = 1; i < segments.Length - 1; i++)
{
var segment = segments[i];
if (!json.TryGetValue(segment, out var nextToken) || nextToken is not JObject nextObject)
{
missingSegment = true;
break;
}
// The final step is to take the JSON and turn it from a dictionary into a string
roles = (json[segments[^1]] as JArray).ToObject<List<string>>();
json = nextObject.ToObject<IDictionary<string, object>>();
if (json is null)
{
missingSegment = true;
break;
}
}
if (missingSegment || !json.TryGetValue(segments[^1], out var rolesToken) || rolesToken is not JArray rolesArray)
{
roles = new List<string>();
}
else
{
// The final step is to take the JSON and turn it from a dictionary into a string
roles = rolesArray.ToObject<List<string>>();
}
}
}
foreach (string role in roles)
@@ -166,7 +252,7 @@ public class SSOController : ControllerBase
{
if (role.Equals(validRoles))
{
StateManager[state].Valid = true;
timedState.Valid = true;
}
}
}
@@ -178,7 +264,7 @@ public class SSOController : ControllerBase
{
if (role.Equals(validAdminRoles))
{
StateManager[state].Admin = true;
timedState.Admin = true;
}
}
}
@@ -190,7 +276,7 @@ public class SSOController : ControllerBase
{
if (role.Equals(folderRoleMap.Role?.Trim()))
{
StateManager[state].Folders.AddRange(folderRoleMap.Folders);
timedState.Folders.AddRange(folderRoleMap.Folders);
}
}
}
@@ -204,7 +290,7 @@ public class SSOController : ControllerBase
{
if (role.Equals(validLiveTvRoles))
{
StateManager[state].EnableLiveTv = true;
timedState.EnableLiveTv = true;
}
}
}
@@ -216,7 +302,7 @@ public class SSOController : ControllerBase
{
if (role.Equals(validLiveTvManagementRoles))
{
StateManager[state].EnableLiveTvManagement = true;
timedState.EnableLiveTvManagement = true;
}
}
}
@@ -227,33 +313,33 @@ public class SSOController : ControllerBase
}
// If the provider doesn't support the preferred username claim, then use the sub claim
if (!StateManager[state].Valid)
if (!timedState.Valid)
{
foreach (var claim in result.User.Claims)
{
if (claim.Type == "sub")
{
StateManager[state].Username = claim.Value;
timedState.Username = claim.Value;
if (config.Roles.Length == 0)
{
StateManager[state].Valid = true;
timedState.Valid = true;
}
}
}
}
bool isLinking = StateManager[state].IsLinking;
bool isLinking = timedState.IsLinking;
if (StateManager[state].Valid)
if (timedState.Valid)
{
_logger.LogInformation($"Is request linking: {isLinking}");
return Content(WebResponse.Generator(data: state, provider: provider, baseUrl: GetRequestBase(config.SchemeOverride), mode: "OID", isLinking: isLinking), MediaTypeNames.Text.Html);
return Content(WebResponse.Generator(data: state, provider: provider, baseUrl: GetRequestBase(config.SchemeOverride, config.PortOverride), mode: "OID", isLinking: isLinking), MediaTypeNames.Text.Html);
}
else
{
_logger.LogWarning(
"OpenID user {Username} has one or more incorrect role claims: {@Claims}. Expected any one of: {@ExpectedClaims}",
StateManager[state].Username,
timedState.Username,
result.User.Claims.Select(o => new { o.Type, o.Value }),
config.Roles);
@@ -288,17 +374,49 @@ public class SSOController : ControllerBase
if (config.Enabled)
{
bool newPath = config.NewPath;
if (!isLinking)
{
newPath = Request.Path.Value.Contains("/start/", StringComparison.InvariantCultureIgnoreCase);
config.NewPath = newPath;
}
string redirectUri = GetRequestBase(config.SchemeOverride, config.PortOverride) + $"/sso/OID/{(newPath ? "redirect" : "r")}/" + provider;
var options = new OidcClientOptions
{
Authority = config.OidEndpoint?.Trim(),
ClientId = config.OidClientId?.Trim(),
ClientSecret = config.OidSecret?.Trim(),
RedirectUri = GetRequestBase(config.SchemeOverride) + $"/sso/OID/{(Request.Path.Value.Contains("/start/", StringComparison.InvariantCultureIgnoreCase) ? "redirect" : "r")}/" + provider,
RedirectUri = redirectUri,
Scope = string.Join(" ", config.OidScopes.Prepend("openid profile")),
DisablePushedAuthorization = config.DisablePushedAuthorization,
LoggerFactory = _loggerFactory,
LoadProfile = !config.DoNotLoadProfile,
HttpClientFactory = o =>
{
var client = _httpClientFactory.CreateClient();
System.Reflection.Assembly assembly = System.Reflection.Assembly.GetExecutingAssembly();
System.Diagnostics.FileVersionInfo fvi = System.Diagnostics.FileVersionInfo.GetVersionInfo(assembly.Location);
string version = fvi.FileVersion;
client.DefaultRequestHeaders.UserAgent.ParseAdd($"Jellyfin-Plugin-SSO-Auth +{version} (https://github.com/9p4/jellyfin-plugin-sso)");
return client;
}
};
options.Policy.Discovery.ValidateEndpoints = false; // For Google and other providers with different endpoints
var oidEndpointUri = new Uri(config.OidEndpoint?.Trim());
options.Policy.Discovery.AdditionalEndpointBaseAddresses.Add(oidEndpointUri.GetLeftPart(UriPartial.Authority));
options.Policy.Discovery.ValidateEndpoints = !config.DoNotValidateEndpoints; // For Google and other providers with different endpoints
options.Policy.Discovery.RequireHttps = !config.DisableHttps;
options.Policy.Discovery.ValidateIssuerName = !config.DoNotValidateIssuerName;
var oidcClient = new OidcClient(options);
var state = await oidcClient.PrepareLoginAsync().ConfigureAwait(false);
if (state.IsError)
{
return ReturnError(StatusCodes.Status400BadRequest, $"Error preparing login: {state.Error} - {state.ErrorDescription}");
}
StateManager.Add(state.State, new TimedAuthorizeState(state, DateTime.Now));
// Track whether this is a linking request or not.
@@ -314,7 +432,7 @@ public class SSOController : ControllerBase
/// </summary>
/// <param name="provider">The name of the provider to add.</param>
/// <param name="config">The OID configuration (deserialized from a JSON post).</param>
[Authorize(Policy = "RequiresElevation")]
[Authorize(Policy = Policies.RequiresElevation)]
[HttpPost("OID/Add/{provider}")]
public void OidAdd(string provider, [FromBody] OidConfig config)
{
@@ -327,7 +445,7 @@ public class SSOController : ControllerBase
/// Deletes an OpenID provider.
/// </summary>
/// <param name="provider">Name of provider to delete.</param>
[Authorize(Policy = "RequiresElevation")]
[Authorize(Policy = Policies.RequiresElevation)]
[HttpGet("OID/Del/{provider}")]
public void OidDel(string provider)
{
@@ -340,7 +458,7 @@ public class SSOController : ControllerBase
/// Lists the OpenID providers configured. Requires administrator privileges.
/// </summary>
/// <returns>The list of OpenID configurations.</returns>
[Authorize(Policy = "RequiresElevation")]
[Authorize(Policy = Policies.RequiresElevation)]
[HttpGet("OID/Get")]
public ActionResult OidProviders()
{
@@ -371,7 +489,7 @@ public class SSOController : ControllerBase
/// This is a debug endpoint to list all running OpenID flows. Requires administrator privileges.
/// </summary>
/// <returns>The list of OpenID flows in progress.</returns>
[Authorize(Policy = "RequiresElevation")]
[Authorize(Policy = Policies.RequiresElevation)]
[HttpGet("OID/States")]
public ActionResult OidStates()
{
@@ -407,8 +525,19 @@ public class SSOController : ControllerBase
{
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(), kvp.Value.EnableLiveTv, kvp.Value.EnableLiveTvManagement, response, config.DefaultProvider?.Trim())
var authenticationResult = await Authenticate(
userId,
kvp.Value.Admin,
config.EnableAuthorization,
config.EnableAllFolders,
kvp.Value.Folders.ToArray(),
kvp.Value.EnableLiveTv,
kvp.Value.EnableLiveTvManagement,
response,
config.DefaultProvider?.Trim(),
kvp.Value.AvatarURL)
.ConfigureAwait(false);
StateManager.Remove(kvp.Key);
return Ok(authenticationResult);
}
}
@@ -449,6 +578,11 @@ public class SSOController : ControllerBase
{
var samlResponse = new Response(config.SamlCertificate, Request.Form["SAMLResponse"]);
if (!samlResponse.IsValid())
{
return Problem("Invalid SAML signature");
}
bool valid = false;
// If no roles are configured, don't use RBAC
@@ -475,7 +609,7 @@ public class SSOController : ControllerBase
WebResponse.Generator(
data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)),
provider: provider,
baseUrl: GetRequestBase(config.SchemeOverride),
baseUrl: GetRequestBase(config.SchemeOverride, config.PortOverride),
mode: "SAML",
isLinking: isLinking),
MediaTypeNames.Text.Html);
@@ -514,6 +648,14 @@ public class SSOController : ControllerBase
if (config.Enabled)
{
bool newPath = config.NewPath;
if (!isLinking)
{
newPath = Request.Path.Value.Contains("/start/", StringComparison.InvariantCultureIgnoreCase);
config.NewPath = newPath;
}
string redirectUri = GetRequestBase(config.SchemeOverride, config.PortOverride) + $"/sso/SAML/{(newPath ? "post" : "p")}/" + provider;
string relayState = null;
if (isLinking)
{
@@ -522,7 +664,7 @@ public class SSOController : ControllerBase
var request = new AuthRequest(
config.SamlClientId.Trim(),
GetRequestBase(config.SchemeOverride) + $"/sso/SAML/{(Request.Path.Value.Contains("/start/", StringComparison.InvariantCultureIgnoreCase) ? "post" : "p")}/" + provider);
redirectUri);
return Redirect(request.GetRedirectUrl(config.SamlEndpoint.Trim(), relayState));
}
@@ -536,7 +678,7 @@ public class SSOController : ControllerBase
/// <param name="provider">The provider name to add.</param>
/// <param name="newConfig">The SAML configuration object (deserialized) from JSON.</param>
/// <returns>The success result.</returns>
[Authorize(Policy = "RequiresElevation")]
[Authorize(Policy = Policies.RequiresElevation)]
[HttpPost("SAML/Add/{provider}")]
public OkResult SamlAdd(string provider, [FromBody] SamlConfig newConfig)
{
@@ -551,7 +693,7 @@ public class SSOController : ControllerBase
/// </summary>
/// <param name="provider">The ID of the provider to delete.</param>
/// <returns>The success result.</returns>
[Authorize(Policy = "RequiresElevation")]
[Authorize(Policy = Policies.RequiresElevation)]
[HttpGet("SAML/Del/{provider}")]
public OkResult SamlDel(string provider)
{
@@ -565,7 +707,7 @@ public class SSOController : ControllerBase
/// Returns a list of all SAML providers configured. Requires administrator privileges.
/// </summary>
/// <returns>A list of all of the Saml providers available.</returns>
[Authorize(Policy = "RequiresElevation")]
[Authorize(Policy = Policies.RequiresElevation)]
[HttpGet("SAML/Get")]
public ActionResult SamlProviders()
{
@@ -599,6 +741,12 @@ public class SSOController : ControllerBase
bool liveTv = config.EnableLiveTv;
bool liveTvManagement = config.EnableLiveTvManagement;
var samlResponse = new Response(config.SamlCertificate, response.Data);
if (!samlResponse.IsValid())
{
return Problem("Invalid SAML signature");
}
List<string> folders;
if (!config.EnableFolderRoles && config.EnabledFolders != null)
{
@@ -664,7 +812,17 @@ public class SSOController : ControllerBase
Guid userId = await CreateCanonicalLinkAndUserIfNotExist("saml", provider, samlResponse.GetNameID());
var authenticationResult = await Authenticate(userId, isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), liveTv, liveTvManagement, response, config.DefaultProvider?.Trim())
var authenticationResult = await Authenticate(
userId,
isAdmin,
config.EnableAuthorization,
config.EnableAllFolders,
folders.ToArray(),
liveTv,
liveTvManagement,
response,
config.DefaultProvider?.Trim(),
null)
.ConfigureAwait(false);
return Ok(authenticationResult);
}
@@ -678,7 +836,7 @@ public class SSOController : ControllerBase
/// <param name="username">The username to switch to the new provider.</param>
/// <param name="provider">The new provider to switch to.</param>
/// <returns>Whether this API endpoint succeeded.</returns>
[Authorize(Policy = "RequiresElevation")]
[Authorize(Policy = Policies.RequiresElevation)]
[HttpPost("Unregister/{username}")]
public ActionResult Unregister(string username, [FromBody] string provider)
{
@@ -715,7 +873,28 @@ public class SSOController : ControllerBase
private async Task<Guid> CreateCanonicalLinkAndUserIfNotExist(string mode, string provider, string canonicalName)
{
User user = null;
user = _userManager.GetUserByName(canonicalName);
// First try to get the user by its id in case it was already registered before
Guid userId = Guid.Empty;
try
{
userId = GetCanonicalLink(mode, provider, canonicalName);
}
catch (KeyNotFoundException)
{
userId = Guid.Empty;
}
// No userId found? Let's try and find the user by name instead
if (userId == Guid.Empty)
{
user = _userManager.GetUserByName(canonicalName);
}
else
{
user = _userManager.GetUserById(userId);
}
if (user == null)
{
_logger.LogInformation($"SSO user {canonicalName} doesn't exist, creating...");
@@ -730,7 +909,7 @@ public class SSOController : ControllerBase
UpdateCanonicalLinkConfig(links, mode, provider);
}
Guid userId = Guid.Empty;
userId = Guid.Empty;
try
{
userId = GetCanonicalLink(mode, provider, canonicalName);
@@ -770,7 +949,7 @@ public class SSOController : ControllerBase
/// <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")]
[Authorize]
[HttpPost("{mode}/Link/{provider}/{jellyfinUserId}")]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
@@ -800,7 +979,7 @@ public class SSOController : ControllerBase
/// <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")]
[Authorize]
[HttpDelete("{mode}/Link/{provider}/{jellyfinUserId}/{canonicalName}")]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
@@ -830,7 +1009,7 @@ public class SSOController : ControllerBase
/// </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")]
[Authorize]
[HttpGet("saml/links/{jellyfinUserId}")]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult<SerializableDictionary<string, IEnumerable<string>>>> GetSamlLinksByUser(Guid jellyfinUserId)
@@ -858,7 +1037,7 @@ public class SSOController : ControllerBase
/// </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")]
[Authorize]
[HttpGet("oid/links/{jellyfinUserId}")]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult<SerializableDictionary<string, IEnumerable<string>>>> GetOidLinksByUser(Guid jellyfinUserId)
@@ -906,7 +1085,11 @@ public class SSOController : ControllerBase
}
var samlResponse = new Response(config.SamlCertificate, response.Data);
// TODO: Does saml response require further validation?
if (!samlResponse.IsValid())
{
return Problem("Invalid SAML signature");
}
string providerUserId = samlResponse.GetNameID();
@@ -998,7 +1181,8 @@ public class SSOController : ControllerBase
/// <param name="enableLiveTvAdmin">Determines whether live TV can be managed by this user.</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(Guid userId, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, bool enableLiveTv, bool enableLiveTvAdmin, AuthResponse authResponse, string defaultProvider)
/// <param name="avatarUrl">The new avatar url for the user.</param>
private async Task<AuthenticationResult> Authenticate(Guid userId, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, bool enableLiveTv, bool enableLiveTvAdmin, AuthResponse authResponse, string defaultProvider, string avatarUrl)
{
User user = _userManager.GetUserById(userId);
if (enableAuthorization)
@@ -1011,6 +1195,56 @@ public class SSOController : ControllerBase
}
}
if (avatarUrl is not null)
{
try
{
using var client = _httpClientFactory.CreateClient();
System.Reflection.Assembly assembly = System.Reflection.Assembly.GetExecutingAssembly();
System.Diagnostics.FileVersionInfo fvi = System.Diagnostics.FileVersionInfo.GetVersionInfo(assembly.Location);
string version = fvi.FileVersion;
client.DefaultRequestHeaders.UserAgent.ParseAdd($"Jellyfin-Plugin-SSO-Auth +{version} (https://github.com/9p4/jellyfin-plugin-sso)");
var avatarResponse = await client.GetAsync(avatarUrl);
if (!avatarResponse.Content.Headers.TryGetValues("content-type", out var contentTypeList))
{
throw new Exception("Cannot get Content-Type of image : " + avatarUrl);
}
var contentType = contentTypeList.First();
if (!contentType.StartsWith("image"))
{
throw new Exception("Content type of avatar URL is not an image, got : " + contentType);
}
var extension = contentType.Split("/").Last();
var stream = await avatarResponse.Content.ReadAsStreamAsync();
if (user != null)
{
var userDataPath =
Path.Combine(
_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath,
user.Username);
if (user.ProfileImage is not null)
{
await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
}
user.ProfileImage = new ImageInfo(Path.Combine(userDataPath, "profile" + extension));
await _providerManager.SaveImage(stream, contentType, user.ProfileImage.Path)
.ConfigureAwait(false);
}
}
catch (Exception e)
{
_logger.LogError(e.Message);
}
}
user.SetPermission(PermissionKind.EnableLiveTvAccess, enableLiveTv);
user.SetPermission(PermissionKind.EnableLiveTvManagement, enableLiveTvAdmin);
@@ -1046,9 +1280,19 @@ public class SSOController : ControllerBase
}
}
private string GetRequestBase(string schemeOverride = null)
private string GetRequestBase(string schemeOverride = null, int? portOverride = null)
{
int requestPort = Request.Host.Port ?? -1;
int requestPort;
if (portOverride != null)
{
requestPort = portOverride.Value;
}
else
{
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;
@@ -1128,6 +1372,7 @@ public class TimedAuthorizeState
IsLinking = false;
EnableLiveTv = false;
EnableLiveTvManagement = false;
AvatarURL = null;
}
/// <summary>
@@ -1175,4 +1420,9 @@ public class TimedAuthorizeState
/// Gets or sets a value indicating whether the user is allowed to manage live TV.
/// </summary>
public bool EnableLiveTvManagement { get; set; }
/// <summary>
/// Gets or sets the user avatar url.
/// </summary>
public string AvatarURL { get; set; }
}
+36 -1
View File
@@ -131,6 +131,16 @@ public class SamlConfig
/// </summary>
public string SchemeOverride { get; set; }
/// <summary>
/// Gets or sets the redirect port override.
/// </summary>
public int? PortOverride { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the new, more descriptive paths are to be used.
/// </summary>
public bool NewPath { get; set; }
/// <summary>
/// Gets or sets a mapping of canonical names from the provider to jellyfin user ids.
/// </summary>
@@ -260,6 +270,16 @@ public class OidConfig
/// </summary>
public string SchemeOverride { get; set; }
/// <summary>
/// Gets or sets the redirect port override.
/// </summary>
public int? PortOverride { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the new, more descriptive paths are to be used.
/// </summary>
public bool NewPath { get; set; }
/// <summary>
/// Gets or sets a mapping of canonical names from the provider to jellyfin user ids.
/// </summary>
@@ -283,10 +303,20 @@ public class OidConfig
/// </summary>
public string DefaultUsernameClaim { get; set; }
/// <summary>
/// Gets or sets the URL format of the new user avatar.
/// </summary>
public string AvatarUrlFormat { get; set; }
/// <summary>
/// Gets or sets a value indicating whether HTTPS in the discovery endpoint is required.
/// </summary>
public bool DisableHttps { get; set; }
public bool DisableHttps { get; set; }
/// <summary>
/// Gets or sets a value indicating whether pushed authorization is required.
/// </summary>
public bool DisablePushedAuthorization { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the OpenID endpoints are validated.
@@ -297,6 +327,11 @@ public class OidConfig
/// Gets or sets a value indicating whether the OpenID issuer name is validated.
/// </summary>
public bool DoNotValidateIssuerName { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the UserInfo endpoint is used to get profile data.
/// </summary>
public bool DoNotLoadProfile { get; set; }
}
/// <summary>
+10 -2
View File
@@ -301,12 +301,20 @@ const ssoConfigurationPage = {
form_elements.text_fields.forEach((id) => {
const value = page.querySelector("#" + id).value;
if (value) current_config[id] = page.querySelector("#" + id).value;
if (value) {
current_config[id] = page.querySelector("#" + id).value;
} else {
current_config[id] = null;
}
});
form_elements.json_fields.forEach((id) => {
const value = page.querySelector("#" + id).value;
if (value) current_config[id] = JSON.parse(value);
if (value) {
current_config[id] = JSON.parse(value);
} else {
current_config[id] = null;
}
});
form_elements.check_fields.forEach((id) => {
+80 -10
View File
@@ -73,7 +73,7 @@
<div class="collapseContent">
<div class="selectContainer">
<label class="selectLabel" for="selectProvider"
>Name of OID Provider:
>Name of OpenID Provider:
</label>
<select
is="emby-select"
@@ -123,7 +123,7 @@
<label
class="inputLabel inputLabelUnfocused"
for="OidProviderName"
>Name of OID Provider:</label
>Name of OpenID Provider:</label
>
<input
is="emby-input"
@@ -133,20 +133,20 @@
class="sso-text"
/>
<div class="fieldDescription">
The name used by Jellyfin to identify the OID provider.
The name used by Jellyfin to identify the OpenID provider.
<br />
If an OID provider with a matching name does not exist, a
If an OpenID provider with a matching name does not exist, a
new provider with this name will be created.
<br />
If an OID provider with a matching name already exists, the
settings for that provider will be updated.
If an OpenID provider with a matching name already exists,
the settings for that provider will be updated.
</div>
</div>
<div class="inputContainer">
<label
class="inputLabel inputLabelUnfocused"
for="OidEndpoint"
>OID Endpoint:</label
>OpenID Endpoint:</label
>
<input
is="emby-input"
@@ -181,7 +181,7 @@
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="OidSecret"
>OID Secret:</label
>OpenID client secret:</label
>
<input
is="emby-input"
@@ -191,7 +191,7 @@
class="sso-text"
/>
<div class="fieldDescription">
The OpenID secret. Randomly generated & shared.
The OpenID client secret. Randomly generated & shared.
</div>
</div>
@@ -480,7 +480,9 @@
list of strings from the OIDC server.
<br />
For Keycloak, it is <code>realm_access.roles</code> by
default.
default for realm roles. For client roles, it is
<code>resource_access.&gt;clientId&lt;.roles</code>
(e.g. resource_access.jellyfin.roles)
<br />
For Authelia, it is <code>groups</code>
</div>
@@ -559,6 +561,24 @@
</div>
</div>
<div class="inputContainer">
<label
class="inputLabel inputLabelUnfocused"
for="AvatarUrlFormat"
>Set avatar url format</label
>
<input
is="emby-input"
id="AvatarUrlFormat"
type="text"
class="sso-text"
/>
<div class="fieldDescription">
The url of the avatar with sso variable format: example :
<code>https://example.com/@{user_id}.png</code>
</div>
</div>
<div class="checkboxContainer">
<label>
<input
@@ -573,6 +593,23 @@
<div class="fieldDescription checkboxFieldDescription"></div>
</div>
<div class="checkboxContainer">
<label>
<input
is="emby-checkbox"
id="DisablePushedAuthorization"
name="DisablePushedAuthorization"
type="checkbox"
class="sso-toggle"
/>
<span
>Disable Pushed Authorization (Insecure). May be needed
for Authelia.</span
>
</label>
<div class="fieldDescription checkboxFieldDescription"></div>
</div>
<div
class="checkboxContainer checkboxContainer-withDescription"
>
@@ -602,6 +639,23 @@
<span>Do Not Validate OpenID Issuer Name (Insecure)</span>
</label>
</div>
<div
class="checkboxContainer checkboxContainer-withDescription"
>
<label>
<input
is="emby-checkbox"
id="DoNotLoadProfile"
name="DoNotLoadProfile"
type="checkbox"
class="sso-toggle"
/>
<span>Do Not Load Profile Information</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
May be required for Cloudflare OpenID
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RoleClaim"
@@ -619,6 +673,22 @@
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RoleClaim"
>Port Override</label
>
<input
is="emby-input"
id="PortOverride"
type="text"
class="sso-text"
/>
<div class="fieldDescription">
If the plugin is redirecting to an incorrect port, set this
to the appropiate port
</div>
</div>
<button
id="SaveProvider"
is="emby-button"
+3 -3
View File
@@ -61,14 +61,14 @@
is="emby-linkbutton"
href="https://github.com/9p4/jellyfin-plugin-sso"
class="button-link"
>help page</a
>homepage</a
>
and
<a
is="emby-linkbutton"
href="https://github.com/9p4/jellyfin-plugin-sso/projects/1"
href="https://github.com/9p4/jellyfin-plugin-sso/issues"
class="button-link"
>roadmap
>issue tracker
</a>
for more information.
</p>
+13
View File
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>SSO_Auth</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="Library.fs" />
</ItemGroup>
</Project>
+5
View File
@@ -0,0 +1,5 @@
namespace SSO_Auth.Lib
module Say =
let hello name =
printfn "Hello %s" name
+13 -9
View File
@@ -1,10 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.SSO_Auth</RootNamespace>
<AssemblyVersion>3.3.0.0</AssemblyVersion>
<FileVersion>3.3.0.0</FileVersion>
<AssemblyVersion>4.0.0.4</AssemblyVersion>
<FileVersion>4.0.0.4</FileVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
@@ -29,19 +29,23 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="IdentityModel.OidcClient" Version="5.0.0" />
<PackageReference Include="Jellyfin.Controller" Version="10.*-*" />
<PackageReference Include="Jellyfin.Model" Version="10.*-*" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="6.0.0" />
<PackageReference Include="Duende.IdentityModel.OidcClient" Version="6.0.1" />
<PackageReference Include="Jellyfin.Controller" Version="10.11.0" />
<PackageReference Include="Jellyfin.Model" Version="10.11.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="6.0.1" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="all" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="Lib\Lib.fsproj" />
</ItemGroup>
<PropertyGroup>
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
+1 -1
View File
@@ -61,7 +61,7 @@ public class Response
/// <param name="certificateBytes">The certificate formatted as an array of bytes.</param>
public Response(byte[] certificateBytes)
{
_certificate = new X509Certificate2(certificateBytes);
_certificate = X509CertificateLoader.LoadCertificate(certificateBytes);
}
/// <summary>
@@ -83,4 +83,4 @@ public class SSOViewsController : ControllerBase
{
return ServeView(viewName);
}
}
}
+18 -12
View File
@@ -240,36 +240,42 @@ Emby Button
/* fonts.scss */
html {
font-family: "Noto Sans", "Noto Sans HK", "Noto Sans JP", "Noto Sans KR",
"Noto Sans SC", "Noto Sans TC", sans-serif;
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;
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;
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;
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;
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;
font-family:
"Noto Sans", "Noto Sans HK", "Noto Sans JP", "Noto Sans KR", "Noto Sans SC",
"Noto Sans TC", sans-serif;
}
.layout-tv {
+101 -91
View File
@@ -1,3 +1,5 @@
using System.Globalization;
namespace Jellyfin.Plugin.SSO_Auth;
/// <summary>
@@ -9,7 +11,16 @@ public static class WebResponse
/// The shared HTML between all of the responses.
/// </summary>
public static readonly string Base = @"<!DOCTYPE html>
<html><head></head><body>
<html><head>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<style>
body {
background: #101010;
color: #d1cfce;
font-family: Noto Sans, Noto Sans HK, Noto Sans JP, Noto Sans KR, Noto Sans SC, Noto Sans TC, sans-serif;
}
</style>
</head><body>
<p>Logging in...</p>
<noscript>Please enable Javascript to complete the login</noscript>
<script>
@@ -35,25 +46,14 @@ function isTv() {
return true;
}
if (isWeb0s()) {
return true;
}
return false;
return isWeb0s();
}
function isWeb0s() {
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.indexOf('netcast') !== -1) {
return true;
}
if (userAgent.indexOf('web0s') !== -1) {
return true;
}
return false;
return userAgent.indexOf('netcast') !== -1
|| userAgent.indexOf('web0s') !== -1;
}
function isMobile(userAgent) {
@@ -100,11 +100,7 @@ function hasKeyboard(browser) {
return true;
}
if (browser.tv) {
return true;
}
return false;
return !!browser.tv;
}
function iOSversion() {
@@ -147,8 +143,12 @@ function web0sVersion(browser) {
// The next is only valid for the app
if (browser.versionMajor >= 79) {
return 6;
if (browser.versionMajor >= 94) {
return 23;
} else if (browser.versionMajor >= 87) {
return 22;
} else if (browser.versionMajor >= 79) {
return 6;
} else if (browser.versionMajor >= 68) {
return 5;
} else if (browser.versionMajor >= 53) {
@@ -179,14 +179,11 @@ let _supportsCssAnimation;
let _supportsCssAnimationWithPrefix;
function supportsCssAnimation(allowPrefix) {
// TODO: Assess if this is still needed, as all of our targets should natively support CSS animations.
if (allowPrefix) {
if (_supportsCssAnimationWithPrefix === true || _supportsCssAnimationWithPrefix === false) {
return _supportsCssAnimationWithPrefix;
}
} else {
if (_supportsCssAnimation === true || _supportsCssAnimation === false) {
return _supportsCssAnimation;
}
if (allowPrefix && (_supportsCssAnimationWithPrefix === true || _supportsCssAnimationWithPrefix === false)) {
return _supportsCssAnimationWithPrefix;
}
if (_supportsCssAnimation === true || _supportsCssAnimation === false) {
return _supportsCssAnimation;
}
let animation = false;
@@ -198,8 +195,8 @@ function supportsCssAnimation(allowPrefix) {
}
if (animation === false && allowPrefix) {
for (let i = 0; i < domPrefixes.length; i++) {
if (elm.style[domPrefixes[i] + 'AnimationName'] !== undefined) {
for (const domPrefix of domPrefixes) {
if (elm.style[domPrefix + 'AnimationName'] !== undefined) {
animation = true;
break;
}
@@ -218,25 +215,25 @@ function supportsCssAnimation(allowPrefix) {
const uaMatch = function (ua) {
ua = ua.toLowerCase();
const match = /(edg)[ /]([\w.]+)/.exec(ua) ||
/(edga)[ /]([\w.]+)/.exec(ua) ||
/(edgios)[ /]([\w.]+)/.exec(ua) ||
/(edge)[ /]([\w.]+)/.exec(ua) ||
/(opera)[ /]([\w.]+)/.exec(ua) ||
/(opr)[ /]([\w.]+)/.exec(ua) ||
/(chrome)[ /]([\w.]+)/.exec(ua) ||
/(safari)[ /]([\w.]+)/.exec(ua) ||
/(firefox)[ /]([\w.]+)/.exec(ua) ||
ua.indexOf('compatible') < 0 && /(mozilla)(?:.*? rv:([\w.]+)|)/.exec(ua) ||
[];
const match = /(chrome)[ /]([\w.]+)/.exec(ua)
|| /(edg)[ /]([\w.]+)/.exec(ua)
|| /(edga)[ /]([\w.]+)/.exec(ua)
|| /(edgios)[ /]([\w.]+)/.exec(ua)
|| /(edge)[ /]([\w.]+)/.exec(ua)
|| /(opera)[ /]([\w.]+)/.exec(ua)
|| /(opr)[ /]([\w.]+)/.exec(ua)
|| /(safari)[ /]([\w.]+)/.exec(ua)
|| /(firefox)[ /]([\w.]+)/.exec(ua)
|| ua.indexOf('compatible') < 0 && /(mozilla)(?:.*? rv:([\w.]+)|)/.exec(ua)
|| [];
const versionMatch = /(version)[ /]([\w.]+)/.exec(ua);
let platform_match = /(ipad)/.exec(ua) ||
/(iphone)/.exec(ua) ||
/(windows)/.exec(ua) ||
/(android)/.exec(ua) ||
[];
let platform_match = /(ipad)/.exec(ua)
|| /(iphone)/.exec(ua)
|| /(windows)/.exec(ua)
|| /(android)/.exec(ua)
|| [];
let browser = match[1] || '';
@@ -255,7 +252,7 @@ const uaMatch = function (ua) {
version = version || match[2] || '0';
let versionMajor = parseInt(version.split('.')[0]);
let versionMajor = parseInt(version.split('.')[0], 10);
if (isNaN(versionMajor)) {
versionMajor = 0;
@@ -313,7 +310,9 @@ if (userAgent.toLowerCase().indexOf('xbox') !== -1) {
browser.tv = true;
}
browser.animate = typeof document !== 'undefined' && document.documentElement.animate != null;
browser.hisense = userAgent.toLowerCase().includes('hisense');
browser.tizen = userAgent.toLowerCase().indexOf('tizen') !== -1 || window.tizen != null;
browser.vidaa = userAgent.toLowerCase().includes('vidaa');
browser.web0s = isWeb0s();
browser.edgeUwp = browser.edge && (userAgent.toLowerCase().indexOf('msapphost') !== -1 || userAgent.toLowerCase().indexOf('webview') !== -1);
@@ -324,7 +323,7 @@ if (browser.web0s) {
delete browser.safari;
const v = (navigator.appVersion).match(/Tizen (\d+).(\d+)/);
browser.tizenVersion = parseInt(v[1]);
browser.tizenVersion = parseInt(v[1], 10);
} else {
browser.orsay = userAgent.toLowerCase().indexOf('smarthub') !== -1;
}
@@ -340,11 +339,9 @@ if (browser.mobile || browser.tv) {
browser.slow = true;
}
if (typeof document !== 'undefined') {
/* eslint-disable-next-line compat/compat */
if (('ontouchstart' in window) || (navigator.maxTouchPoints > 0)) {
browser.touch = true;
}
/* eslint-disable-next-line compat/compat */
if (typeof document !== 'undefined' && ('ontouchstart' in window) || (navigator.maxTouchPoints > 0)) {
browser.touch = true;
}
browser.keyboard = hasKeyboard(browser);
@@ -361,39 +358,41 @@ if (browser.iOS) {
}
function getDeviceName() {
var deviceName = '';
if (browser.tizen) {
deviceName = 'Samsung Smart TV';
} else if (browser.web0s) {
deviceName = 'LG Smart TV';
} else if (browser.operaTv) {
deviceName = 'Opera TV';
} else if (browser.xboxOne) {
deviceName = 'Xbox One';
} else if (browser.ps4) {
deviceName = 'Sony PS4';
} else if (browser.chrome) {
deviceName = 'Chrome';
} else if (browser.edgeChromium) {
deviceName = 'Edge Chromium';
} else if (browser.edge) {
deviceName = 'Edge';
} else if (browser.firefox) {
deviceName = 'Firefox';
} else if (browser.opera) {
deviceName = 'Opera';
} else if (browser.safari) {
deviceName = 'Safari';
} else {
deviceName = 'Web Browser';
}
var deviceName = '';
if (!deviceName) {
if (browser.tizen) {
deviceName = 'Samsung Smart TV';
} else if (browser.web0s) {
deviceName = 'LG Smart TV';
} else if (browser.operaTv) {
deviceName = 'Opera TV';
} else if (browser.xboxOne) {
deviceName = 'Xbox One';
} else if (browser.ps4) {
deviceName = 'Sony PS4';
} else if (browser.chrome) {
deviceName = 'Chrome';
} else if (browser.edgeChromium) {
deviceName = 'Edge Chromium';
} else if (browser.edge) {
deviceName = 'Edge';
} else if (browser.firefox) {
deviceName = 'Firefox';
} else if (browser.opera) {
deviceName = 'Opera';
} else if (browser.safari) {
deviceName = 'Safari';
} else {
deviceName = 'Web Browser';
}
if (browser.ipad) {
deviceName += ' iPad';
} else if (browser.iphone) {
deviceName += ' iPhone';
} else if (browser.android) {
deviceName += ' Android';
if (browser.ipad) {
deviceName += ' iPad';
} else if (browser.iphone) {
deviceName += ' iPhone';
} else if (browser.android) {
deviceName += ' Android';
}
}
return deviceName;
@@ -416,6 +415,14 @@ const sleep = (milliseconds) => {
/// <returns>A string with the HTML to serve to the client.</returns>
public static string Generator(string data, string provider, string baseUrl, string mode, bool isLinking = false)
{
// Strip out the protocol (http:// or https://) and convert the domain to Punycode
var idnMapping = new IdnMapping();
var protocolSeparatorIndex = baseUrl.IndexOf("//");
var protocol = baseUrl.Substring(0, protocolSeparatorIndex + 2);
var domain = baseUrl.Substring(protocolSeparatorIndex + 2);
var punycodeDomain = idnMapping.GetAscii(domain);
var punycodeBaseUrl = protocol + punycodeDomain;
return Base + @"
async function link(request) {
const jfCredentialsString = localStorage.getItem(""jellyfin_credentials"");
@@ -429,7 +436,7 @@ async function link(request) {
if (jfUser == null) return;
if (jfToken == null) return;
const url = '" + $"{baseUrl}/sso/{mode}/Link/{provider}/" + @"' + jfUser;
const url = '" + $"{punycodeBaseUrl}/sso/{mode}/Link/{provider}/" + @"' + jfUser;
return new Promise(resolve => {
var xhr = new XMLHttpRequest();
@@ -453,6 +460,9 @@ async function link(request) {
}
async function main() {
localStorage.removeItem('jellyfin_credentials');
document.getElementById('iframe-main').src = '" + punycodeBaseUrl + @"/web/index.html';
var data = '" + data + @"';
while (localStorage.getItem(""_deviceId2"") == null ||
localStorage.getItem(""jellyfin_credentials"") == null ||
@@ -469,7 +479,7 @@ async function main() {
if (" + $"{isLinking}".ToLower() + @") await link(request);
var url = '" + baseUrl + "/sso/" + mode + "/Auth/" + provider + @"';
var url = '" + punycodeBaseUrl + "/sso/" + mode + "/Auth/" + provider + @"';
let response = await new Promise(resolve => {
var xhr = new XMLHttpRequest();
@@ -493,7 +503,7 @@ async function main() {
jfCreds['Servers'][0]['UserId'] = responseJson['User']['Id'];
localStorage.setItem('jellyfin_credentials', JSON.stringify(jfCreds));
localStorage.setItem('enableAutoLogin', 'true');
window.location.replace('" + baseUrl + @"/web/index.html');
window.location.replace('" + punycodeBaseUrl + @"/web/index.html');
}
document.addEventListener('DOMContentLoaded', function () {
@@ -501,6 +511,6 @@ document.addEventListener('DOMContentLoaded', function () {
});
// https://stackoverflow.com/a/25435165
</script><iframe class='docs-texteventtarget-iframe' sandbox='allow-same-origin allow-forms allow-scripts' src='" + baseUrl + "/web/index.html' style='position: absolute;width:0;height:0;border:0;'></iframe></body></html>";
</script><iframe id='iframe-main' class='docs-texteventtarget-iframe' sandbox='allow-same-origin allow-forms allow-scripts' src='' style='position: absolute;width:0;height:0;border:0;'></iframe></body></html>";
}
}
+11 -5
View File
@@ -1,9 +1,9 @@
name: "SSO Authentication"
guid: "505ce9d1-d916-42fa-86ca-673ef241d7df"
imageUrl: "https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/main/img/logo.png"
version: "3.5.2.1"
targetAbi: "10.8.0.0"
framework: "net6.0"
version: "4.0.0.4"
targetAbi: "10.11.0.0"
framework: "net9.0"
owner: "9p4"
overview: "Authenticate users against an SSO provider."
description: |
@@ -12,9 +12,15 @@ description: |
category: "Authentication"
artifacts:
- "SSO-Auth.dll"
- "IdentityModel.OidcClient.dll"
- "IdentityModel.dll"
- "Duende.IdentityModel.OidcClient.dll"
- "Duende.IdentityModel.dll"
changelog: |
4.0.0.4: Fix security issue in SAML
4.0.0.0: Jellyfin 10.11
3.5.3.0: Allow for OID-provided avatars, various bugfixes and workarounds
3.5.2.4: Updates for Jellyfin 10.9
3.5.2.3: Improve OpenID discovery policy security rules, fix iOS login bugs related to cache
3.5.2.2: Fix linking page when using new paths
3.5.2.1: Hotfix for SAML null checks
3.5.2.0: Allow overriding the scheme used for generating URLs.
3.5.1.1: Change iframe URL to point to the web UI instead of the root
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1691218994,
"narHash": "sha256-46GJ5vLf9H+Oh7Jii2gJI9GATJHGbx2iQpon5nUSFPI=",
"lastModified": 1760934318,
"narHash": "sha256-/oUYsC0lUCBory65VK+UHqCCsCspbL1Vgfcf1KUYqVw=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "0d2fb29f5071a12d7983319c2c2576be6a130582",
"rev": "87848bf0cc4f87717fc813a4575f07330c3e743c",
"type": "github"
},
"original": {
+1 -1
View File
@@ -5,6 +5,6 @@
let pkgs = nixpkgs.legacyPackages.x86_64-linux;
in {
devShell.x86_64-linux =
pkgs.mkShell { buildInputs = [ pkgs.nodePackages.prettier pkgs.dotnet-sdk ]; };
pkgs.mkShell { buildInputs = [ pkgs.nodePackages.prettier pkgs.dotnet-sdk_9 ]; };
};
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 107 KiB

+68 -61
View File
@@ -1,68 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<RuleSet Name="Rules for Jellyfin.Server" Description="Code analysis rules for Jellyfin.Server.csproj" ToolsVersion="14.0">
<Rules AnalyzerId="StyleCop.Analyzers" RuleNamespace="StyleCop.Analyzers">
<!-- disable warning SA1202: 'public' members must come before 'private' members -->
<Rule Id="SA1202" Action="Info" />
<!-- disable warning SA1204: Static members must appear before non-static members -->
<Rule Id="SA1204" Action="Info" />
<!-- disable warning SA1404: Code analysis suppression should have justification -->
<Rule Id="SA1404" Action="Info" />
<RuleSet Name="Rules for Jellyfin.Server"
Description="Code analysis rules for Jellyfin.Server.csproj" ToolsVersion="14.0">
<Rules AnalyzerId="StyleCop.Analyzers" RuleNamespace="StyleCop.Analyzers">
<!-- disable warning SA1202: 'public' members must come before 'private' members -->
<Rule Id="SA1202" Action="Info" />
<!-- disable warning SA1204: Static members must appear before non-static members -->
<Rule Id="SA1204" Action="Info" />
<!-- disable warning SA1404: Code analysis suppression should have justification -->
<Rule Id="SA1404" Action="Info" />
<!-- disable warning SA1009: Closing parenthesis should be followed by a space. -->
<Rule Id="SA1009" Action="None" />
<!-- disable warning SA1101: Prefix local calls with 'this.' -->
<Rule Id="SA1101" Action="None" />
<!-- disable warning SA1108: Block statements should not contain embedded comments -->
<Rule Id="SA1108" Action="None" />
<!-- disable warning SA1128:: Put constructor initializers on their own line -->
<Rule Id="SA1128" Action="None" />
<!-- disable warning SA1130: Use lambda syntax -->
<Rule Id="SA1130" Action="None" />
<!-- disable warning SA1200: 'using' directive must appear within a namespace declaration -->
<Rule Id="SA1200" Action="None" />
<!-- disable warning SA1309: Fields must not begin with an underscore -->
<Rule Id="SA1309" Action="None" />
<!-- disable warning SA1413: Use trailing comma in multi-line initializers -->
<Rule Id="SA1413" Action="None" />
<!-- disable warning SA1512: Single-line comments must not be followed by blank line -->
<Rule Id="SA1512" Action="None" />
<!-- disable warning SA1515: Single-line comment should be preceded by blank line -->
<Rule Id="SA1515" Action="None" />
<!-- disable warning SA1600: Elements should be documented -->
<Rule Id="SA1600" Action="None" />
<!-- disable warning SA1633: The file header is missing or not located at the top of the file -->
<Rule Id="SA1633" Action="None" />
</Rules>
<!-- disable warning SA1009: Closing parenthesis should be followed by a space. -->
<Rule Id="SA1009" Action="None" />
<!-- disable warning SA1101: Prefix local calls with 'this.' -->
<Rule Id="SA1101" Action="None" />
<!-- disable warning SA1108: Block statements should not contain embedded comments -->
<Rule Id="SA1108" Action="None" />
<!-- disable warning SA1128:: Put constructor initializers on their own line -->
<Rule Id="SA1128" Action="None" />
<!-- disable warning SA1130: Use lambda syntax -->
<Rule Id="SA1130" Action="None" />
<!-- disable warning SA1200: 'using' directive must appear within a namespace declaration -->
<Rule Id="SA1200" Action="None" />
<!-- disable warning SA1309: Fields must not begin with an underscore -->
<Rule Id="SA1309" Action="None" />
<!-- disable warning SA1402: File may only contain a single type -->
<Rule Id="SA1402" Action="None" />
<!-- disable warning SA1413: Use trailing comma in multi-line initializers -->
<Rule Id="SA1413" Action="None" />
<!-- disable warning SA1512: Single-line comments must not be followed by blank line -->
<Rule Id="SA1512" Action="None" />
<!-- disable warning SA1515: Single-line comment should be preceded by blank line -->
<Rule Id="SA1515" Action="None" />
<!-- disable warning SA1600: Elements should be documented -->
<Rule Id="SA1600" Action="None" />
<!-- disable warning SA1633: The file header is missing or not located at the top of the
file -->
<Rule Id="SA1633" Action="None" />
<!-- disable warning SA1649: File name should match first type name -->
<Rule Id="SA1649" Action="None" />
</Rules>
<Rules AnalyzerId="Microsoft.CodeAnalysis.FxCopAnalyzers" RuleNamespace="Microsoft.Design">
<!-- disable warning CA1031: Do not catch general exception types -->
<Rule Id="CA1031" Action="Info" />
<!-- disable warning CA1032: Implement standard exception constructors -->
<Rule Id="CA1032" Action="Info" />
<!-- disable warning CA1062: Validate arguments of public methods -->
<Rule Id="CA1062" Action="Info" />
<!-- disable warning CA1716: Identifiers should not match keywords -->
<Rule Id="CA1716" Action="Info" />
<!-- disable warning CA1720: Identifiers should not contain type names -->
<Rule Id="CA1720" Action="Info" />
<!-- disable warning CA1812: internal class that is apparently never instantiated.
<Rules AnalyzerId="Microsoft.CodeAnalysis.FxCopAnalyzers" RuleNamespace="Microsoft.Design">
<!-- disable warning CA1031: Do not catch general exception types -->
<Rule Id="CA1031" Action="Info" />
<!-- disable warning CA1032: Implement standard exception constructors -->
<Rule Id="CA1032" Action="Info" />
<!-- disable warning CA1062: Validate arguments of public methods -->
<Rule Id="CA1062" Action="Info" />
<!-- disable warning CA1716: Identifiers should not match keywords -->
<Rule Id="CA1716" Action="Info" />
<!-- disable warning CA1720: Identifiers should not contain type names -->
<Rule Id="CA1720" Action="Info" />
<!-- disable warning CA1812: internal class that is apparently never instantiated.
If so, remove the code from the assembly.
If this class is intended to contain only static members, make it static -->
<Rule Id="CA1812" Action="Info" />
<!-- disable warning CA1822: Member does not access instance data and can be marked as static -->
<Rule Id="CA1822" Action="Info" />
<!-- disable warning CA2000: Dispose objects before losing scope -->
<Rule Id="CA2000" Action="Info" />
<Rule Id="CA1812" Action="Info" />
<!-- disable warning CA1822: Member does not access instance data and can be marked as
static -->
<Rule Id="CA1822" Action="Info" />
<!-- disable warning CA2000: Dispose objects before losing scope -->
<Rule Id="CA2000" Action="Info" />
<!-- disable warning CA1054: Change the type of parameter url from string to System.Uri -->
<Rule Id="CA1054" Action="None" />
<!-- disable warning CA1055: URI return values should not be strings -->
<Rule Id="CA1055" Action="None" />
<!-- disable warning CA1056: URI properties should not be strings -->
<Rule Id="CA1056" Action="None" />
<!-- disable warning CA1303: Do not pass literals as localized parameters -->
<Rule Id="CA1303" Action="None" />
<!-- disable warning CA1308: Normalize strings to uppercase -->
<Rule Id="CA1308" Action="None" />
</Rules>
<!-- disable warning CA1054: Change the type of parameter url from string to System.Uri -->
<Rule Id="CA1054" Action="None" />
<!-- disable warning CA1055: URI return values should not be strings -->
<Rule Id="CA1055" Action="None" />
<!-- disable warning CA1056: URI properties should not be strings -->
<Rule Id="CA1056" Action="None" />
<!-- disable warning CA1303: Do not pass literals as localized parameters -->
<Rule Id="CA1303" Action="None" />
<!-- disable warning CA1308: Normalize strings to uppercase -->
<Rule Id="CA1308" Action="None" />
</Rules>
</RuleSet>
+102 -1
View File
@@ -2,6 +2,8 @@
This plugin has been tested to work against various providers, though not all providers provide support for all of this plugins' features.
❗ Before you proceed, make sure you have another admin account if you are going to link SSO provider to the only admin account on the server, permission might get overwritten (see [#212](https://github.com/9p4/jellyfin-plugin-sso/issues/212)).
## TOC / Tested Providers:
This section is broken into providers that support Role-Based Access Control (RBAC), and those that do not
@@ -10,8 +12,9 @@ This section is broken into providers that support Role-Based Access Control (RB
- ✅ [Authelia](#authelia)
- ✅ [authentik](#authentik)
- [✅ Keycloak](#keycloak-oidc)
- ✅ [Keycloak](#keycloak-oidc)
- Both [OIDC](#keycloak-oidc) & [SAML](#keycloak-saml)
- ✅ [Pocket ID](#pocket-id)
### No RBAC Support
@@ -42,6 +45,25 @@ Authelia is simple to configure, and RBAC is straightforward.
Below is the `identity_providers` section of an Authelia config:
### Authelia v4.38 and above
```yaml
identity_providers:
oidc:
# hmac secret and private key given by env variables
clients:
- client_id: jellyfin
client_name: My media server
# Client secret should be randomly generated
client_secret: <redacted>
token_endpoint_auth_method: client_secret_post
authorization_policy: one_factor
redirect_uris:
- https://jellyfin.example.com/sso/OID/redirect/authelia
```
### Authelia v4.37 and below
```yaml
identity_providers:
oidc:
@@ -69,6 +91,7 @@ authelia:
OidSecret: <redacted>
RoleClaim: groups
OidScopes: ["groups"]
DisablePushedAuthorization: true
```
## authentik
@@ -149,6 +172,7 @@ Ensure that the following configuration options are set:
- Access Type: Confidential
- Standard Flow Enabled
- Redirect URI: https://myjellyfin.example.com/sso/OID/redirect/PROVIDER_NAME
- Redirect URI (for Android app): org.jellyfin.mobile://login-callback
- 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.
@@ -202,3 +226,80 @@ keycloak:
SamlClientId: <same-as-in-keycloak>
SamlCertificate: <copied-from-xml-file>
```
## Pocket ID
A simple and easy-to-use OIDC provider that allows users to authenticate with their passkeys to your services.
### Pocket ID Config
1. Login to you Pocket ID admin account
1. Go to `Administration -> OCID Clients`
1. Click `Add OCID Client`
1. Give the client a name e.g. `Jellyfin`
1. Set the `Clent Launch URL` to your Jellyfin endpoint
1. Set the callbak url to `https://jellyfin.example.com/sso/OID/redirect/pocketid`. The `pocketid` part must match the `Name of OpenID Provider` in the Jellyfin SSO provider
1. (optional) Enable PKCE if Jellyfin is an https endpoint
1. (optional) Set a logo
1. (optional) Set `Allowed User Groups`
### Jellyfin's Config
```yaml
pocketid:
OidEndpoint: https://pocketid.example.com/.well-known/openid-configuration
OidClientId: <pocket-id-client-id>
OidSecret: <pocket-id-secret>
EnableAuthorization: true # (optional) If you want Jellyfin to read group permissions from pocket id
RoleClaim: groups # (optional) If you want Jellyfin to be able to read group assignments from pocket id
AdminRoles: admin # (optional) The pocket id group which will give a user Jellyfin admin privilges
Roles: users # (optional) The pocket id group which will give a user Jellyfin access
AvatarUrlFormat: @{picture} # (optional) This will pull each users pocket id photo into Jellyfin
```
## Kanidm
Kanidm is a modern and simple identity management platform written in rust.
### Kanidm Config
```shell
kanidm system oauth2 create jellyfin "Jellyfin" https://jellyfin.example.com/
# Set this to drop the trailing @idm.example.com in usernames
kanidm system oauth2 prefer-short-username jellyfin
kanidm system oauth2 add-redirect-url jellyfin https://jellyfin.example.com/sso/OID/redirect/kanidm
kanidm system oauth2 add-redirect-url jellyfin https://jellyfin.example.com/sso/OID/r/kanidm
# Optionally setup groups for Jellyfin
kanidm group create jellyfin_admins
kanidm group create jellyfin_users
kanidm system oauth2 update-scope-map jellyfin jellyfin_admins openid profile groups
kanidm system oauth2 update-scope-map jellyfin jellyfin_users openid profile groups
```
Get the secret used in the Jellyfin config with `kanidm system oauth2 show-basic-secret jellyfin`.
### Jellyfin's Config
```yaml
kanidm:
OidEndpoint: https://idm.example.com/oauth2/openid/jellyfin/
OidClientId: jellyfin
OidSecret: <kanidm-secret>
# (optional) If you want Jellyfin to read group permissions from kanidm
EnableAuthorization: true
OidScopes:
- groups
RoleClaim: groups
AdminsRoles:
- jellyfin_admins@idm.example.com
Roles:
- jellyfin_users@idm.example.com
# If in your setup admin accounts aren't members of the users group you need to add the admins group to roles as well
- jellyfin_admins@idm.example.com
# (optional) If you want the name attribute instead of the spn attribute as username
DefaultUsernameClaim: preferred_username
```