Compare commits

..

3 Commits

47 changed files with 447 additions and 3086 deletions
+13
View File
@@ -0,0 +1,13 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: a055
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
-4
View File
@@ -1,4 +0,0 @@
contact_links:
- name: Question
url: https://github.com/9p4/jellyfin-plugin-sso/discussions
about: Please ask and answer questions here.
+5 -5
View File
@@ -3,12 +3,12 @@ on:
inputs:
dotnet-version:
required: false
default: "9.0.x"
default: "6.0.x"
description: "The .NET version to setup for the build"
type: string
dotnet-target:
required: false
default: "net9.0"
default: "net6.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@9497a0a499416cc572ed2e07a391d9f943a37b4d # v1.1.1
uses: oddstr13/jellyfin-plugin-repository-manager@b9e92867a6aa279d611a5ea80cf61f6358838c39
id: jprm
with:
dotnet-target: "${{ inputs.dotnet-target }}"
- name: Upload Artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # tag=v4.6.2
uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # tag=v3
with:
name: build-artifact
retention-days: 30
if-no-files-found: error
path: ${{ steps.jprm.outputs.artifact }}
path: ${{ steps.jprm.outputs.artifact }}
+11
View File
@@ -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
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v1
with:
dotnet-version: 9.0.x
dotnet-version: 6.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
+2 -5
View File
@@ -12,12 +12,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
# Make sure the actual branch is checked out when running on pull requests
ref: ${{ github.head_ref }}
uses: actions/checkout@v2
- name: Prettify code
uses: creyD/prettier_action@v4.3
uses: creyD/prettier_action@v4.2
with:
dry: True
prettier_options: '--check **/*.{js,html,md,css,scss}'
+3 -3
View File
@@ -15,7 +15,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v1
with:
dotnet-version: 9.0.x
dotnet-version: 6.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@9497a0a499416cc572ed2e07a391d9f943a37b4d # v1.1.1
uses: oddstr13/jellyfin-plugin-repository-manager@b9e92867a6aa279d611a5ea80cf61f6358838c39
with:
version: "0.0.0.9000"
verbosity: debug
path: .
dotnet-target: "net9.0"
dotnet-target: "net6.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: "9.0.*"
dotnet-target: "net9.0"
dotnet-version: "6.0.*"
dotnet-target: "net6.0"
upload:
runs-on: ubuntu-latest
needs:
- build
steps:
- name: Download Artifact
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@v2.1.0
with:
name: build-artifact
- name: Prepare GitHub Release assets
-26
View File
@@ -1,26 +0,0 @@
when:
- event: pull_request
steps:
# Restore, build and test in one step so the NuGet cache stays on the step's
# own ephemeral storage instead of the workspace volume.
- name: build-test
image: mcr.microsoft.com/dotnet/sdk:10.0
environment:
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_NOLOGO: "1"
commands:
- dotnet --info
- dotnet restore SSO-Auth.sln
- dotnet build SSO-Auth.sln -c Release --no-restore --warnaserror
- dotnet test SSO-Auth.sln -c Release --no-build --verbosity minimal
backend_options:
kubernetes:
serviceAccountName: jellyfin-plugin-sso
resources:
requests:
memory: 1Gi
cpu: 1
limits:
memory: 4Gi
cpu: 2
-74
View File
@@ -1,74 +0,0 @@
when:
- event: tag
ref: refs/tags/v*
steps:
# Everything runs on almalinux9-base: JPRM installs from the artifactapi pypi
# remote and the publish below PUTs to artifactapi, both over https against
# the internal CA. The Microsoft SDK image does not trust that CA (curl 60),
# so the dotnet build has to come to the image that does rather than the
# other way round.
- name: package
image: git.unkin.net/unkin/almalinux9-base:20260606
environment:
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_NOLOGO: "1"
commands:
- |
set -eu
VERSION=$$(sed -n 's/^version: *"\([0-9.]*\)".*/\1/p' build.yaml | head -1)
if [ "v$$VERSION" != "$$CI_COMMIT_TAG" ]; then
echo "tag $$CI_COMMIT_TAG does not match build.yaml version $$VERSION; use make patch/minor/major/hotfix"
exit 1
fi
dnf -y -q install dotnet-sdk-10.0 python3-pip zip
pip3 install --quiet --index-url https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/pypi/simple "jprm==1.1.0"
mkdir -p dist
jprm plugin build . --output ./dist --version "$$VERSION" --dotnet-configuration Release --dotnet-framework net10.0
ls -l dist
backend_options:
kubernetes:
serviceAccountName: jellyfin-plugin-sso
resources:
requests:
memory: 2Gi
cpu: 1
limits:
memory: 4Gi
cpu: 2
# Uploads are immutable (409 on overwrite) and artifactapi has no HEAD route,
# so probe with GET and treat an existing object as a no-op re-run.
- name: publish
image: git.unkin.net/unkin/almalinux9-base:20260606
commands:
- |
set -eu
HOST="https://artifactapi.k8s.syd1.au.unkin.net"
REPO="jellyfin-plugins"
ZIP=$$(ls dist/sso-authentication_*.zip | head -1)
VERSION=$$(echo "$$CI_COMMIT_TAG" | sed 's/^v//')
PATH_IN_REPO="unkin/jellyfin-plugin-sso/$$VERSION/$$(basename "$$ZIP")"
code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/$$PATH_IN_REPO" || true)
if [ "$$code" = "200" ]; then
echo "$$PATH_IN_REPO already published (HTTP $$code); skipping upload"
exit 0
fi
echo "Uploading $$ZIP to $$REPO/$$PATH_IN_REPO (existence probe returned $$code)"
curl -f -X PUT \
"$$HOST/api/v2/remotes/$$REPO/files/$$PATH_IN_REPO" \
-H "Content-Type: application/zip" \
--data-binary @"$$ZIP"
echo
sha256sum "$$ZIP"
depends_on: [package]
backend_options:
kubernetes:
serviceAccountName: jellyfin-plugin-sso
resources:
requests:
memory: 256Mi
cpu: 250m
limits:
memory: 1Gi
cpu: 1
-76
View File
@@ -1,76 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to make participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery and unwelcome sexual attention or
advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic
address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies within all project spaces, and it also applies when
an individual is representing the project or its community in public spaces.
Examples of representing a project or community include using an official
project e-mail address, posting via an official social media account, or acting
as an appointed representative at an online or offline event. Representation of
a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at [contact@ersei.net](mailto:contact@ersei.net). All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq
-181
View File
@@ -1,181 +0,0 @@
<!-- omit in toc -->
# Contributing to Jellyfin SSO Plugin
First off, thanks for taking the time to contribute! ❤️
All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
>
> - Star the project
> - Tweet about it
> - Refer this project in your project's readme
> - Mention the project at local meetups and tell your friends/colleagues
<!-- omit in toc -->
## Table of Contents
- [I Have a Question](#i-have-a-question)
- [I Want To Contribute](#i-want-to-contribute)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Enhancements](#suggesting-enhancements)
- [Your First Code Contribution](#your-first-code-contribution)
- [Improving The Documentation](#improving-the-documentation)
- [Styleguides](#styleguides)
- [Commit Messages](#commit-messages)
- [Join The Project Team](#join-the-project-team)
## I Have a Question
> If you want to ask a question, we assume that you have read the available [Documentation](https://github.com/9p4/jellyfin-plugin-sso/blob/main/README.md).
Before you ask a question, it is best to search for existing [Issues](https://github.com/9p4/jellyfin-plugin-sso/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first.
If you then still feel the need to ask a question and need clarification, we recommend the following:
- Open an [Issue](https://github.com/9p4/jellyfin-plugin-sso/issues/new).
- Provide as much context as you can about what you're running into.
- Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant.
We will then take care of the issue as soon as possible.
<!--
You might want to create a separate issue tag for questions and include it in this description. People should then tag their issues accordingly.
Depending on how large the project is, you may want to outsource the questioning, e.g. to Stack Overflow or Gitter. You may add additional contact and information possibilities:
- IRC
- Slack
- Gitter
- Stack Overflow tag
- Blog
- FAQ
- Roadmap
- E-Mail List
- Forum
-->
## I Want To Contribute
> ### Legal Notice <!-- omit in toc -->
>
> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license.
### Reporting Bugs
<!-- omit in toc -->
#### Before Submitting a Bug Report
A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible.
- Make sure that you are using the latest version.
- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://github.com/9p4/jellyfin-plugin-sso/blob/main/README.md). If you are looking for support, you might want to check [this section](#i-have-a-question)).
- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/9p4/jellyfin-plugin-ssoissues?q=label%3Abug).
- Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue.
- Collect information about the bug:
- Stack trace (Traceback)
- OS, Platform and Version (Windows, Linux, macOS, x86, ARM)
- Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant.
- Possibly your input and the output
- Can you reliably reproduce the issue? And can you also reproduce it with older versions?
<!-- omit in toc -->
#### How Do I Submit a Good Bug Report?
> You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to <contact@ersei.net>.
<!-- You may add a PGP key to allow the messages to be sent encrypted as well. -->
We use GitHub issues to track bugs and errors. If you run into an issue with the project:
- Open an [Issue](https://github.com/9p4/jellyfin-plugin-sso/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.)
- Explain the behavior you would expect and the actual behavior.
- Please provide as much context as possible and describe the _reproduction steps_ that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case.
- Provide the information you collected in the previous section.
Once it's filed:
- The project team will label the issue accordingly.
- A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced.
- If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution).
<!-- You might want to create an issue template for bugs and errors that can be used as a guide and that defines the structure of the information to be included. If you do so, reference it here in the description. -->
### Suggesting Enhancements
This section guides you through submitting an enhancement suggestion for Jellyfin SSO Plugin, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions.
<!-- omit in toc -->
#### Before Submitting an Enhancement
- Make sure that you are using the latest version.
- Read the [documentation](https://github.com/9p4/jellyfin-plugin-sso/blob/main/README.md) carefully and find out if the functionality is already covered, maybe by an individual configuration.
- Perform a [search](https://github.com/9p4/jellyfin-plugin-sso/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one.
- Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library.
<!-- omit in toc -->
#### How Do I Submit a Good Enhancement Suggestion?
Enhancement suggestions are tracked as [GitHub issues](https://github.com/9p4/jellyfin-plugin-sso/issues).
- Use a **clear and descriptive title** for the issue to identify the suggestion.
- Provide a **step-by-step description of the suggested enhancement** in as many details as possible.
- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you.
- You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. <!-- this should only be included if the project has a GUI -->
- **Explain why this enhancement would be useful** to most Jellyfin SSO Plugin users. You may also want to point out the other projects that solved it better and which could serve as inspiration.
<!-- You might want to create an issue template for enhancement suggestions that can be used as a guide and that defines the structure of the information to be included. If you do so, reference it here in the description. -->
### Your First Code Contribution
<!-- TODO
include Setup of env, IDE and typical getting started instructions?
-->
The project is built with .NET 6. Download it from [here](https://dotnet.microsoft.com/en-us/download).
Any code editor or IDE with .NET support will work out of the box with this program.
(Some) editors:
- [VSCode](https://code.visualstudio.com/docs/languages/dotnet)
- [N/Vim](https://github.com/OmniSharp/Omnisharp-vim)
### Improving The Documentation
<!-- TODO
Updating, improving and correcting the documentation
-->
We are always open to better docs! The main place documentation could be improved is the [providers](https://github.com/9p4/jellyfin-plugin-sso/blob/main/providers.md) documentation. This file keeps track of configurations that are known to work with common SSO providers.
## Styleguides
### Commit Messages
We use [commitlint](https://commitlint.js.org) for linting commit messages.
### C#
We format all C# code according to the .NET formatter. Run `dotnet build .` and fix any warnings that come up.
### HTML/CSS/JS/Markdown
We use [Prettier](https://prettier.io) to format these files.
<!-- TODO
-->
<!-- omit in toc -->
## Attribution
This guide is based on the **contributing-gen**. [Make your own](https://github.com/bttger/contributing-gen)!
-51
View File
@@ -1,51 +0,0 @@
.PHONY: build test lint clean patch minor major hotfix
SLN ?= SSO-Auth.sln
build:
dotnet restore $(SLN)
dotnet build $(SLN) -c Release --no-restore --warnaserror
test: build
dotnet test $(SLN) -c Release --no-build --verbosity minimal
lint:
dotnet format $(SLN) --verify-no-changes
clean:
dotnet clean $(SLN) -c Release || true
rm -rf SSO-Auth/bin SSO-Auth/obj tests/SSO-Auth.Tests/bin tests/SSO-Auth.Tests/obj
# Jellyfin plugin versions are 4-part (System.Version in the plugin manifest),
# so build.yaml holds the version and the release tag must be exactly
# v<that version>; .woodpecker/release.yaml fails the build if they diverge.
_CUR := $(shell sed -n 's/^version: *"\([0-9.]*\)".*/\1/p' build.yaml | head -1)
_A := $(or $(word 1,$(subst ., ,$(_CUR))),0)
_B := $(or $(word 2,$(subst ., ,$(_CUR))),0)
_C := $(or $(word 3,$(subst ., ,$(_CUR))),0)
_D := $(or $(word 4,$(subst ., ,$(_CUR))),0)
define release
@test -n "$(_CUR)" || { echo "cannot parse version from build.yaml"; exit 1; }
@test -z "$$(git status --porcelain)" || { echo "working tree is dirty"; exit 1; }
@sed -i 's|^version: .*|version: "$(1)"|' build.yaml
@sed -i 's|<AssemblyVersion>[^<]*</AssemblyVersion>|<AssemblyVersion>$(1)</AssemblyVersion>|' SSO-Auth/SSO-Auth.csproj
@sed -i 's|<FileVersion>[^<]*</FileVersion>|<FileVersion>$(1)</FileVersion>|' SSO-Auth/SSO-Auth.csproj
@git commit -qam "release $(1)"
@git tag v$(1)
@git push origin HEAD v$(1)
@echo "Released v$(1)"
endef
major:
$(call release,$(shell expr $(_A) + 1).0.0.0)
minor:
$(call release,$(_A).$(shell expr $(_B) + 1).0.0)
patch:
$(call release,$(_A).$(_B).$(shell expr $(_C) + 1).0)
# Fourth component: a rebuild of an existing patch (upstream's 3.5.2.1 shape).
hotfix:
$(call release,$(_A).$(_B).$(_C).$(shell expr $(_D) + 1))
+20 -74
View File
@@ -22,8 +22,6 @@
</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,9 +32,9 @@ Existing users may link new SSO accounts, or remove existing links using self-se
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).
~~There is NO admin configuration! You must use the API to configure the program!~~ Added by [matthewstrasiotto](https://github.com/matthewstrasiotto) 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 or clients supporting [Quick Connect](https://jellyfin.org/docs/general/server/quick-connect)**
**[This is for Jellyfin 10.8](https://github.com/9p4/jellyfin-plugin-sso/issues/3) and only on the Web UI!**
**This README reflects the branch it is currently on! Switch tags to view version-specific documentation!**
@@ -48,13 +46,11 @@ 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/developers/how-connect-works/)
- [OpenID](https://openid.net/what-is-openid/)
- [SAML](https://www.cloudflare.com/learning/access-management/what-is-saml/)
## Security
@@ -95,35 +91,6 @@ The nightly build may have new features unavailable in other builds, but **be wa
## Examples
### Creating A Login Button On The Main Page
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
<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:
```css
a.raised.emby-button {
padding: 0.9em 1em;
color: inherit !important;
}
.disclaimerContainer {
display: block;
}
```
![screenshot of the configuration page with the same code](img/custom-button.png)
For more information, refer to [issue #16](https://github.com/9p4/jellyfin-plugin-sso/issues/16).
### SAML
Example for adding a SAML configuration with the API using [curl](https://curl.se/):
@@ -137,9 +104,9 @@ The SAML provider must have the following configuration (I am using Keycloak, an
- Sign Documents on
- Sign Assertions off
- Client Signature Required off
- Redirect URI: [https://myjellyfin.example.com/sso/SAML/post/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/start/PROVIDER_NAME)
- Redirect URI: [https://myjellyfin.example.com/sso/SAML/p/PROVIDER_NAME](https://myjellyfin.example.com/sso/OID/p/PROVIDER_NAME)
- Base URL: [https://myjellyfin.example.com](https://myjellyfin.example.com)
- Master SAML processing URL: [https://myjellyfin.example.com/sso/SAML/start/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/start/PROVIDER_NAME)
- Master SAML processing URL: [https://myjellyfin.example.com/sso/SAML/p/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/p/PROVIDER_NAME)
Make sure that `clientid` is replaced with the actual client ID and `PROVIDER_NAME` is replaced with the chosen provider name!
@@ -153,7 +120,7 @@ The OpenID provider must have the following configuration (again, I am using Key
- Access Type: Confidential
- Standard Flow Enabled
- Redirect URI: [https://myjellyfin.example.com/sso/OID/redirect/PROVIDER_NAME](https://myjellyfin.example.com/sso/OID/redirect/PROVIDER_NAME)
- Redirect URI: [https://myjellyfin.example.com/sso/OID/r/PROVIDER_NAME](https://myjellyfin.example.com/sso/OID/r/PROVIDER_NAME)
- Base URL: [https://myjellyfin.example.com](https://myjellyfin.example.com)
Make sure that `clientid` is replaced with the actual client ID and `PROVIDER_NAME` is replaced with the chosen provider name!
@@ -166,8 +133,8 @@ The API is all done from a base URL of `/sso/`
#### Flow
- POST `SAML/start/PROVIDER_NAME`: This is the SAML POST endpoint. It accepts a form response from the SAML provider and returns HTML and JavaScript for the client to login with a given provider name.
- GET `SAML/start/PROVIDER_NAME`: This is the SAML initiator: it will begin the authorization flow for SAML with a given provider name.
- POST `SAML/p/PROVIDER_NAME`: This is the SAML POST endpoint. It accepts a form response from the SAML provider and returns HTML and JavaScript for the client to login with a given provider name.
- GET `SAML/p/PROVIDER_NAME`: This is the SAML initiator: it will begin the authorization flow for SAML with a given provider name.
- POST `SAML/Auth/PROVIDER_NAME`: This is the SAML client-side API: the HTML and JavaScript client will call this endpoint to receive Jellyfin credentials given a provider name. Post format is in JSON with the following keys:
- `deviceId`: string. Device ID.
- `deviceName`: string. Device name.
@@ -184,20 +151,14 @@ These all require authorization. Append an API key to the end of the request: `c
- `samlClientId`: string. The SAML client ID.
- `samlCertificate`: string. The base64 encoded SAML certificate.
- `enabled`: boolean. Determines if the provider is enabled or not.
- `enableAuthorization`: boolean: Determines if the plugin sets permissions for the user. If false, the user will start with no permissions and an administrator will add permissions. If disabled, then the permissions of users will not be modified and the Jellyfin defaults will be used instead.
- `enableAuthorization`: boolean: Determines if the plugin sets permissions for the user. If false, the user will start with no permissions and an administrator will add permissions. The permissions of existing users will not be rewritten on subsequent logins.
- `enableAllFolders`: boolean. Determines if the client logging in is allowed access to all folders.
- `enabledFolders`: array of strings. If `enableAllFolders` is set to false, then this will be used to determine what folders the users who log in through this provider are allowed to use.
- `roles`: array of strings. This validates the SAML response against the `Role` attribute. If a user has any of these roles, then the user is authenticated. Leave blank to disable role checking.
- `adminRoles`: array of strings. This uses SAML response's `Role` attributes. If a user has any of these roles, then the user is an admin. Leave blank to disable (default is to not enable admin permissions).
- `enableFolderRoles`: boolean. Determines if role-based folder access should be used.
- `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.
- `enableLiveTvRoles`: boolean. Determines if role-based Live TV access should be used.
- `liveTvRoles`: array of strings. If `enableLiveTvRoles` is enabled, then the user's roles will be checked against these. If the user is granted permission, then the user will be able to view Live TV.
- `liveTvManagementRoles`: array of strings. If `enableLiveTvRoles` is enabled, then the user's roles will be checked against these. If the user is granted permission, then the user will be able to manage Live TV.
- `enableLiveTv`: boolean. Whether to allow Live TV by default. This applies even if `enableLiveTvRoles` is enabled.
- `enableLiveTvManagement`: boolean. Whether to allow Live TV management by default. This applies even if `enableLiveTvRoles` is enabled.
- `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.
- `schemeOverride`: string. Sets the scheme for URLs used. Can be useful if the plugin refuses to use HTTPS URLs.
- GET `SAML/Del/PROVIDER_NAME`: This removes a configuration for SAML for a given provider name.
- GET `SAML/Get`: Lists the configurations currently available.
@@ -205,8 +166,8 @@ These all require authorization. Append an API key to the end of the request: `c
#### Flow
- GET `OID/redirect/PROVIDER_NAME`: This is the OpenID callback path. This will return HTML and JavaScript for the client to login with a given provider name.
- GET `OID/start/PROVIDER_NAME`: This is the OpenID initiator: it will begin the authorization flow for OpenID with a given provider name.
- GET `OID/r/PROVIDER_NAME`: This is the OpenID callback path. This will return HTML and JavaScript for the client to login with a given provider name.
- GET `OID/p/PROVIDER_NAME`: This is the OpenID initiator: it will begin the authorization flow for OpenID with a given provider name.
- POST `OID/Auth/PROVIDER_NAME`: This is the OpenID client-side API: the HTML and JavaScript client will call this endpoint to receive Jellyfin credentials for a given provider name. Post format is in JSON with the following keys:
- `deviceId`: string. Device ID.
- `deviceName`: string. Device name.
@@ -223,29 +184,20 @@ These all require authorization. Append an API key to the end of the request: `c
- `oidClientId`: string. The OpenID client ID.
- `oidSecret`: string. The OpenID secret.
- `enabled`: boolean. Determines if the provider is enabled or not.
- `enableAuthorization`: boolean: Determines if the plugin sets permissions for the user. If false, the user will start with no permissions and an administrator will add permissions. If disabled, then the permissions of users will not be modified and the Jellyfin defaults will be used instead.
- `enableAuthorization`: boolean: Determines if the plugin sets permissions for the user. If false, the user will start with no permissions and an administrator will add permissions. The permissions of existing users will not be rewritten on subsequent logins.
- `enableAllFolders`: boolean. Determines if the client logging in is allowed access to all folders.
- `enabledFolders`: array of strings. If `enableAllFolders` is set to false, then this will be used to determine what folders the users who log in through this provider are allowed to use.
- `roles`: array of strings. This validates the OpenID response against the claim set in `roleClaim`. If a user has any of these roles, then the user is authenticated. Leave blank to disable role checking. This currently only works for Keycloak (to my knowledge).
- `adminRoles`: array of strings. This uses the OpenID response against the claim set in `roleClaim`. If a user has any of these roles, then the user is an admin. Leave blank to disable (default is to not enable admin permissions).
- `enableFolderRoles`: boolean. Determines if role-based folder access should be used.
- `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.
- `enableLiveTvRoles`: boolean. Determines if role-based Live TV access should be used.
- `liveTvRoles`: array of strings. If `enableLiveTvRoles` is enabled, then the user's roles will be checked against these. If the user is granted permission, then the user will be able to view Live TV.
- `liveTvManagementRoles`: array of strings. If `enableLiveTvRoles` is enabled, then the user's roles will be checked against these. If the user is granted permission, then the user will be able to manage Live TV.
- `enableLiveTv`: boolean. Whether to allow Live TV by default. This applies even if `enableLiveTvRoles` is enabled.
- `enableLiveTvManagement`: boolean. Whether to allow Live TV management by default. This applies even if `enableLiveTvRoles` is enabled.
- `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 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`.
- `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.
- `schemeOverride`: string. Sets the scheme for URLs used. Can be useful if the plugin refuses to use HTTPS URLs.
- `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.
@@ -256,38 +208,32 @@ These all require authorization. Append an API key to the end of the request: `c
## Limitations
Logging in with an SSO account that has the same username as an existing Jellyfin account will override the permissions for the user. Use caution when overriding the administrator account!
There is no GUI to sign in. You have to make it yourself! The buttons should redirect to something like this: [https://myjellyfin.example.com/sso/SAML/p/clientid](https://myjellyfin.example.com/sso/SAML/p/clientid) replacing `clientid` with the provider client ID and `SAML` with the auth scheme (either `SAML` or `OID`).
~~There is no GUI to sign in. You have to make it yourself! The buttons should redirect to something like this: [https://myjellyfin.example.com/sso/SAML/start/clientid](https://myjellyfin.example.com/sso/SAML/start/clientid) replacing `clientid` with the provider client ID and `SAML` with the auth scheme (either `SAML` or `OID`).~~
~~Furthermore, there is no functional admin page (yet). PRs for this are welcome. In the meantime, you have to interact with the API to add or remove configurations.~~ 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).
~~Furthermore, there is no functional admin page (yet). PRs for this are welcome. In the meantime, you have to interact with the API to add or remove configurations.~~ Added by [matthewstrasiotto](https://github.com/matthewstrasiotto) in PR [#18](https://github.com/9p4/jellyfin-plugin-sso/pull/18) and [#27](https://github.com/9p4/jellyfin-plugin-sso/pull/27).
There is also no logout callback. Logging out of Jellyfin will log you out of Jellyfin only, instead of the SSO provider as well.
~~This only supports Jellyfin on its own domain (for now). This is because I'm using string concatenation for generating some URLs. A PR is welcome to patch this.~~ Fixed in [PR #1](https://github.com/9p4/jellyfin-plugin-sso/pull/1).
~~This only supports Jellyfin on it's own domain (for now). This is because I'm using string concatenation for generating some URLs. A PR is welcome to patch this.~~ Fixed in [PR #1](https://github.com/9p4/jellyfin-plugin-sso/pull/1).
**This only works on the web UI**. ~~The user must open the Jellyfin web UI BEFORE using the SSO program to populate some values in the localStorage.~~ 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
## Dependencies
This project uses Nix flakes to manage development dependencies. Run `nix develop` to use the same toolchain versions.
## 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 [strazto/jellyfin-plugin-sso-vscode](https://github.com/strazto/jellyfin-plugin-sso-vscode).
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/strazto/jellyfin-plugin-sso-vscode .vscode
git clone https://github.com/matthewstrasiotto/jellyfin-plugin-sso-vscode .vscode
```
## Releasing
@@ -304,7 +250,7 @@ Anything tagged/released as a formal Github release will also be built and publi
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`
manifest file, https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/manifest-release/manifest.json
so that it refers to your fork.
## Credits and Thanks
@@ -313,7 +259,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 [Duende IdentityModel OIDC Client](https://github.com/DuendeSoftware/foss) library for the OpenID side of things.
I use the [IdentityModel OIDC Client](https://github.com/IdentityModel/IdentityModel.OidcClient/) library for the OpenID side of things.
Thanks to these projects, without which I would have been pulling my hair out implementing these protocols from scratch.
-3
View File
@@ -1,3 +0,0 @@
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 -8
View File
@@ -1,12 +1,10 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
# Visual Studio Version 16
VisualStudioVersion = 16.0.30114.105
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO-Auth", "SSO-Auth\SSO-Auth.csproj", "{C30A5CFB-B27E-4E83-9E96-1E0362B36748}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO-Auth.Tests", "tests\SSO-Auth.Tests\SSO-Auth.Tests.csproj", "{7C4C3F90-33B5-4A8E-9C1A-5A6D2F9E8B41}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -20,9 +18,5 @@ Global
{C30A5CFB-B27E-4E83-9E96-1E0362B36748}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C30A5CFB-B27E-4E83-9E96-1E0362B36748}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C30A5CFB-B27E-4E83-9E96-1E0362B36748}.Release|Any CPU.Build.0 = Release|Any CPU
{7C4C3F90-33B5-4A8E-9C1A-5A6D2F9E8B41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7C4C3F90-33B5-4A8E-9C1A-5A6D2F9E8B41}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7C4C3F90-33B5-4A8E-9C1A-5A6D2F9E8B41}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7C4C3F90-33B5-4A8E-9C1A-5A6D2F9E8B41}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
+1 -2
View File
@@ -9,8 +9,7 @@
using System;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Net;
using Microsoft.AspNetCore.Http;
File diff suppressed because it is too large Load Diff
@@ -83,4 +83,4 @@ public class SSOViewsController : ControllerBase
{
return ServeView(viewName);
}
}
}
+1 -106
View File
@@ -89,31 +89,6 @@ public class SamlConfig
/// </summary>
public bool EnableFolderRoles { get; set; }
/// <summary>
/// Gets or sets a value indicating whether RBAC is used to manage Live TV access.
/// </summary>
public bool EnableLiveTvRoles { get; set; }
/// <summary>
/// Gets or sets a value indicating whether Live TV is enabled by default.
/// </summary>
public bool EnableLiveTv { get; set; }
/// <summary>
/// Gets or sets a value indicating whether Live TV is allowed to be managed by default.
/// </summary>
public bool EnableLiveTvManagement { get; set; }
/// <summary>
/// Gets or sets the roles that are checked to determine whether the user is allowed to view Live TV.
/// </summary>
public string[] LiveTvRoles { get; set; }
/// <summary>
/// Gets or sets the roles that are checked to determine whether the user is allowed to manage Live TV.
/// </summary>
public string[] LiveTvManagementRoles { get; set; }
/// <summary>
/// Gets or sets which folders map to what roles in RBAC.
/// </summary>
@@ -126,21 +101,6 @@ public class SamlConfig
/// </summary>
public string DefaultProvider { get; set; }
/// <summary>
/// Gets or sets the redirect scheme override.
/// </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>
@@ -218,31 +178,6 @@ public class OidConfig
/// </summary>
public bool EnableFolderRoles { get; set; }
/// <summary>
/// Gets or sets a value indicating whether RBAC is used to manage Live TV access.
/// </summary>
public bool EnableLiveTvRoles { get; set; }
/// <summary>
/// Gets or sets a value indicating whether Live TV is enabled by default.
/// </summary>
public bool EnableLiveTv { get; set; }
/// <summary>
/// Gets or sets a value indicating whether Live TV is allowed to be managed by default.
/// </summary>
public bool EnableLiveTvManagement { get; set; }
/// <summary>
/// Gets or sets the roles that are checked to determine whether the user is allowed to view Live TV.
/// </summary>
public string[] LiveTvRoles { get; set; }
/// <summary>
/// Gets or sets the roles that are checked to determine whether the user is allowed to manage Live TV.
/// </summary>
public string[] LiveTvManagementRoles { get; set; }
/// <summary>
/// Gets or sets which folders map to what roles in RBAC.
/// </summary>
@@ -265,21 +200,6 @@ public class OidConfig
/// </summary>
public string DefaultProvider { get; set; }
/// <summary>
/// Gets or sets the redirect scheme override.
/// </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>
@@ -303,35 +223,10 @@ 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; }
/// <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.
/// </summary>
public bool DoNotValidateEndpoints { get; set; }
/// <summary>
/// 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; }
public bool RequireHttps { get; set; }
}
/// <summary>
+19 -27
View File
@@ -4,7 +4,7 @@ const ssoConfigurationPage = {
ApiClient.getPluginConfiguration(ssoConfigurationPage.pluginUniqueId).then(
(config) => {
ssoConfigurationPage.populateProviders(page, config.OidConfigs);
},
}
);
const folder_container = page.querySelector("#EnabledFolders");
@@ -43,7 +43,7 @@ const ssoConfigurationPage = {
return ApiClient.getJSON(
ApiClient.getUrl("Library/MediaFolders", {
IsHidden: false,
}),
})
).then((folders) => {
ssoConfigurationPage._populateFolders(container, folders);
});
@@ -119,8 +119,8 @@ const ssoConfigurationPage = {
.then(() =>
ssoConfigurationPage.populateEnabledFolders(
enabled_folders,
checklist,
),
checklist
)
);
elem.querySelector(".sso-role-mapping-name").value = mapping["Role"];
@@ -128,7 +128,7 @@ const ssoConfigurationPage = {
.querySelector(".sso-remove-role-mapping")
.addEventListener(
"click",
ssoConfigurationPage.handleRoleMappingRemove,
ssoConfigurationPage.handleRoleMappingRemove
);
return elem;
@@ -168,11 +168,11 @@ const ssoConfigurationPage = {
const oidc_form = page.querySelector("#sso-new-oidc-provider");
const text_fields = [...oidc_form.querySelectorAll(text_class)].map(
(e) => e.id,
(e) => e.id
);
const json_fields = [...oidc_form.querySelectorAll(json_class)].map(
(e) => e.id,
(e) => e.id
);
const text_list_fields = [
@@ -180,7 +180,7 @@ const ssoConfigurationPage = {
].map((e) => e.id);
const check_fields = [...oidc_form.querySelectorAll(toggle_class)].map(
(e) => e.id,
(e) => e.id
);
const output = {
@@ -230,7 +230,7 @@ const ssoConfigurationPage = {
if (provider[id])
ssoConfigurationPage.fillTextList(
provider[id],
page.querySelector("#" + id),
page.querySelector("#" + id)
);
});
@@ -238,7 +238,7 @@ const ssoConfigurationPage = {
if (provider[id]) {
ssoConfigurationPage.populateEnabledFolders(
provider[id],
page.querySelector(`#${id}`),
page.querySelector(`#${id}`)
);
}
});
@@ -252,20 +252,20 @@ const ssoConfigurationPage = {
if (provider[id])
ssoConfigurationPage.populateRoleMappings(provider[id], elem);
});
},
}
);
},
deleteProvider: (page, provider_name) => {
if (
!window.confirm(
`Are you sure you want to delete the provider ${provider_name}?`,
`Are you sure you want to delete the provider ${provider_name}?`
)
) {
return;
}
return new Promise((resolve) => {
ApiClient.getPluginConfiguration(
ssoConfigurationPage.pluginUniqueId,
ssoConfigurationPage.pluginUniqueId
).then((config) => {
if (!config.OidConfigs.hasOwnProperty(provider_name)) {
resolve();
@@ -275,7 +275,7 @@ const ssoConfigurationPage = {
delete config.OidConfigs[provider_name];
ApiClient.updatePluginConfiguration(
ssoConfigurationPage.pluginUniqueId,
config,
config
).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
ssoConfigurationPage.loadConfiguration(page);
@@ -292,7 +292,7 @@ const ssoConfigurationPage = {
const form_elements = ssoConfigurationPage.listArgumentsByType(page);
ApiClient.getPluginConfiguration(
ssoConfigurationPage.pluginUniqueId,
ssoConfigurationPage.pluginUniqueId
).then((config) => {
var current_config = {};
if (config.OidConfigs.hasOwnProperty(provider_name)) {
@@ -301,20 +301,12 @@ const ssoConfigurationPage = {
form_elements.text_fields.forEach((id) => {
const value = page.querySelector("#" + id).value;
if (value) {
current_config[id] = page.querySelector("#" + id).value;
} else {
current_config[id] = null;
}
if (value) current_config[id] = page.querySelector("#" + id).value;
});
form_elements.json_fields.forEach((id) => {
const value = page.querySelector("#" + id).value;
if (value) {
current_config[id] = JSON.parse(value);
} else {
current_config[id] = null;
}
if (value) current_config[id] = JSON.parse(value);
});
form_elements.check_fields.forEach((id) => {
@@ -323,7 +315,7 @@ const ssoConfigurationPage = {
form_elements.text_list_fields.forEach((id) => {
current_config[id] = ssoConfigurationPage.parseTextList(
page.querySelector("#" + id),
page.querySelector("#" + id)
);
});
@@ -342,7 +334,7 @@ const ssoConfigurationPage = {
ApiClient.updatePluginConfiguration(
ssoConfigurationPage.pluginUniqueId,
config,
config
).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
ssoConfigurationPage.loadConfiguration(page);
+11 -244
View File
@@ -1,4 +1,4 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<title>SSO</title>
@@ -73,7 +73,7 @@
<div class="collapseContent">
<div class="selectContainer">
<label class="selectLabel" for="selectProvider"
>Name of OpenID Provider:
>Name of OID Provider:
</label>
<select
is="emby-select"
@@ -123,7 +123,7 @@
<label
class="inputLabel inputLabelUnfocused"
for="OidProviderName"
>Name of OpenID Provider:</label
>Name of OID Provider:</label
>
<input
is="emby-input"
@@ -133,20 +133,20 @@
class="sso-text"
/>
<div class="fieldDescription">
The name used by Jellyfin to identify the OpenID provider.
The name used by Jellyfin to identify the OID provider.
<br />
If an OpenID provider with a matching name does not exist, a
If an OID provider with a matching name does not exist, a
new provider with this name will be created.
<br />
If an OpenID provider with a matching name already exists,
the settings for that provider will be updated.
If an OID 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"
>OpenID Endpoint:</label
>OID Endpoint:</label
>
<input
is="emby-input"
@@ -181,7 +181,7 @@
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="OidSecret"
>OpenID client secret:</label
>OID Secret:</label
>
<input
is="emby-input"
@@ -191,7 +191,7 @@
class="sso-text"
/>
<div class="fieldDescription">
The OpenID client secret. Randomly generated & shared.
The OpenID secret. Randomly generated & shared.
</div>
</div>
@@ -358,109 +358,6 @@
</div>
</div>
<div
class="checkboxContainer checkboxContainer-withDescription"
>
<label>
<input
is="emby-checkbox"
id="EnableLiveTvRoles"
name="EnableLiveTvRoles"
type="checkbox"
class="sso-toggle"
/>
<span>Enable Live TV RBAC</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Determines whether the roles will be used to grant Live TV
privileges.
</div>
</div>
<div class="inputContainer">
<label
class="inputLabel inputLabelUnfocused"
for="LiveTvRoles"
>Live TV Roles:</label
>
<textarea
is="emby-textarea"
id="LiveTvRoles"
type="text"
class="sso-line-list emby-textarea"
></textarea>
<div class="fieldDescription">
A list of roles, one role per-line to look for in the OpenID
response.
<br />
Like <strong>"Roles"</strong>, but having any of the roles
confers Live TV privileges.
</div>
</div>
<div class="inputContainer">
<label
class="inputLabel inputLabelUnfocused"
for="LiveTvManagementRoles"
>Live TV Management Roles:</label
>
<textarea
is="emby-textarea"
id="LiveTvManagementRoles"
type="text"
class="sso-line-list emby-textarea"
></textarea>
<div class="fieldDescription">
A list of roles, one role per-line to look for in the OpenID
response.
<br />
Like <strong>"Roles"</strong>, but having any of the roles
confers Live TV administration privileges.
</div>
</div>
<div
class="checkboxContainer checkboxContainer-withDescription"
>
<label>
<input
is="emby-checkbox"
id="EnableLiveTv"
name="EnableLiveTv"
type="checkbox"
class="sso-toggle"
/>
<span>Enable Live TV Access By Default</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Determines whether the user can view Live TV by default.
<br />
This value is still used if <strong>Live TV RBAC</strong> is
enabled!
</div>
</div>
<div
class="checkboxContainer checkboxContainer-withDescription"
>
<label>
<input
is="emby-checkbox"
id="EnableLiveTvManagement"
name="EnableLiveTvManagement"
type="checkbox"
class="sso-toggle"
/>
<span>Enable Live TV Management By Default</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Determines whether the user can manage Live TV by default.
<br />
This value is still used if <strong>Live TV RBAC</strong> is
enabled!
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RoleClaim"
>Role Claim:</label
@@ -480,9 +377,7 @@
list of strings from the OIDC server.
<br />
For Keycloak, it is <code>realm_access.roles</code> by
default for realm roles. For client roles, it is
<code>resource_access.&gt;clientId&lt;.roles</code>
(e.g. resource_access.jellyfin.roles)
default.
<br />
For Authelia, it is <code>groups</code>
</div>
@@ -561,134 +456,6 @@
</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
is="emby-checkbox"
id="DisableHttps"
name="DisableHttps"
type="checkbox"
class="sso-toggle"
/>
<span>Disable OpenID HTTPS Discovery (Insecure)</span>
</label>
<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"
>
<label>
<input
is="emby-checkbox"
id="DoNotValidateEndpoints"
name="DoNotValidateEndpoints"
type="checkbox"
class="sso-toggle"
/>
<span>Do Not Validate OpenID Endpoints (Insecure)</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
May be required for Google OpenID
</div>
</div>
<div class="checkboxContainer">
<label>
<input
is="emby-checkbox"
id="DoNotValidateIssuerName"
name="DoNotValidateIssuerName"
type="checkbox"
class="sso-toggle"
/>
<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"
>Scheme Override</label
>
<input
is="emby-input"
id="SchemeOverride"
type="text"
class="sso-text"
/>
<div class="fieldDescription">
If the plugin is redirecting to an insecure URL, set this to
"https"
</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"
+5 -5
View File
@@ -1,4 +1,4 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Polyfill styles that are missing when serving without dashboard -->
@@ -10,7 +10,7 @@
const view = document.querySelector("#sso-config-page");
document.querySelector("#theme-style").href = ApiClient.getUrl(
"/web/themes/dark/theme.css",
"/web/themes/dark/theme.css"
);
const homeButton = document.querySelector("a.emby-button.home");
@@ -61,14 +61,14 @@
is="emby-linkbutton"
href="https://github.com/9p4/jellyfin-plugin-sso"
class="button-link"
>homepage</a
>help page</a
>
and
<a
is="emby-linkbutton"
href="https://github.com/9p4/jellyfin-plugin-sso/issues"
href="https://github.com/9p4/jellyfin-plugin-sso/projects/1"
class="button-link"
>issue tracker
>roadmap
</a>
for more information.
</p>
+10 -10
View File
@@ -15,7 +15,7 @@ const ssoConfigLinking = {
ssoConfigLinking.loadProviderList(
provider_list_oid,
config_names,
"oid",
"oid"
);
});
});
@@ -24,7 +24,7 @@ const ssoConfigLinking = {
ssoConfigLinking.loadProviderList(
provider_list_saml,
config_names,
"saml",
"saml"
);
});
});
@@ -51,7 +51,7 @@ const ssoConfigLinking = {
></div>
`;
var add_provider = provider_config.querySelector(
".sso-provider-add-link",
".sso-provider-add-link"
);
//const provider_name_css = ssoConfigLinking.safeCSSId(provider_name);
@@ -60,7 +60,7 @@ const ssoConfigLinking = {
add_provider.classList.add("sso-provider");
add_provider.href = ApiClient.getUrl(
`/SSO/${provider_mode}/p/${provider_name}?isLinking=true`,
`/SSO/${provider_mode}/p/${provider_name}?isLinking=true`
);
container.appendChild(provider_config);
@@ -74,20 +74,20 @@ const ssoConfigLinking = {
type: "GET",
url: ApiClient.getUrl(`sso/${provider_mode}/links/${currentUserId}`),
},
true,
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}"]`,
`.sso-provider-existing-links-container[data-provider="${provider_name}"]`
);
ssoConfigLinking.populateExistingLinks(
provider_container,
provider_mode,
provider_name,
provider_map[provider_name],
provider_map[provider_name]
);
});
});
@@ -99,7 +99,7 @@ const ssoConfigLinking = {
container,
provider_mode,
provider_name,
canonical_names,
canonical_names
) => {
container
.querySelectorAll(".sso-provider-link-checkbox-wrapper")
@@ -158,7 +158,7 @@ const ssoConfigLinking = {
return ApiClient.fetch({
type: "DELETE",
url: ApiClient.getUrl(
`sso/${provider_mode}/link/${provider_name}/${currentUserId}/${canonical_name}`,
`sso/${provider_mode}/link/${provider_name}/${currentUserId}/${canonical_name}`
),
});
});
@@ -181,6 +181,6 @@ export default function (view) {
view
.querySelector("#btn-delete-selected-links")
.addEventListener("click", (e) =>
ssoConfigLinking.handleDeleteButtonPressed(e, view),
ssoConfigLinking.handleDeleteButtonPressed(e, view)
);
}
+9 -14
View File
@@ -1,10 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.SSO_Auth</RootNamespace>
<AssemblyVersion>5.0.0.0</AssemblyVersion>
<FileVersion>5.0.0.0</FileVersion>
<AssemblyVersion>3.3.0.0</AssemblyVersion>
<FileVersion>3.3.0.0</FileVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
@@ -29,21 +29,16 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Duende.IdentityModel.OidcClient" Version="6.0.1" />
<PackageReference Include="Jellyfin.Controller" Version="12.0.0" />
<PackageReference Include="Jellyfin.Model" Version="12.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<!-- Compile-only: the server supplies this assembly, so this version must track the server's. Shipping it would load a second copy into the plugin load context. -->
<PackageReference Include="StackExchange.Redis" Version="2.13.17" ExcludeAssets="runtime" Private="false" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="SSO-Auth.Tests" />
<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" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
</ItemGroup>
+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 = X509CertificateLoader.LoadCertificate(certificateBytes);
_certificate = new X509Certificate2(certificateBytes);
}
/// <summary>
-44
View File
@@ -1,44 +0,0 @@
using System;
using Jellyfin.Plugin.SSO_Auth.State;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace Jellyfin.Plugin.SSO_Auth;
/// <summary>
/// Registers the plugin's services. The OAuth state store is backed by the shared cache the server
/// already connects to when one is configured, which is what lets a login start on one server instance
/// and be completed on another.
/// </summary>
public class SsoAuthServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
ArgumentNullException.ThrowIfNull(serviceCollection);
serviceCollection.AddSingleton<ISsoStateStore>(sp =>
{
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger<SsoAuthServiceRegistrator>();
var multiplexer = sp.GetService<IConnectionMultiplexer>();
if (multiplexer is null)
{
logger.LogWarning(
"SSO OAuth state store: {Store}. Logins must start and finish on the same server instance.",
nameof(InMemorySsoStateStore));
return new InMemorySsoStateStore();
}
logger.LogInformation(
"SSO OAuth state store: {Store}. Logins are shared across server instances.",
nameof(RedisSsoStateStore));
return new RedisSsoStateStore(multiplexer);
});
}
}
-48
View File
@@ -1,48 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Plugin.SSO_Auth.Api;
namespace Jellyfin.Plugin.SSO_Auth.State;
/// <summary>
/// Stores the in-flight OpenID authorization states of a login or linking flow.
/// </summary>
public interface ISsoStateStore
{
/// <summary>
/// Stores a newly prepared authorization state.
/// </summary>
/// <param name="state">The state to store, keyed on its OpenID state value.</param>
/// <param name="expiry">How long the state remains usable.</param>
/// <returns>A task representing the write.</returns>
Task CreateAsync(TimedAuthorizeState state, TimeSpan expiry);
/// <summary>
/// Reads an authorization state without removing it.
/// </summary>
/// <param name="state">The OpenID state value.</param>
/// <returns>The stored state, or <c>null</c> when it is unknown or expired.</returns>
Task<TimedAuthorizeState> GetAsync(string state);
/// <summary>
/// Persists mutations made to an authorization state and refreshes its expiry.
/// </summary>
/// <param name="state">The state to persist, keyed on its OpenID state value.</param>
/// <param name="expiry">How long the state remains usable.</param>
/// <returns>A task representing the write.</returns>
Task UpdateAsync(TimedAuthorizeState state, TimeSpan expiry);
/// <summary>
/// Reads an authorization state and removes it in the same operation, so it can only be redeemed once.
/// </summary>
/// <param name="state">The OpenID state value.</param>
/// <returns>The stored state, or <c>null</c> when it is unknown, expired or already redeemed.</returns>
Task<TimedAuthorizeState> ConsumeAsync(string state);
/// <summary>
/// Lists every authorization state currently in flight.
/// </summary>
/// <returns>The states in flight.</returns>
Task<IReadOnlyList<TimedAuthorizeState>> ListAsync();
}
-134
View File
@@ -1,134 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Plugin.SSO_Auth.Api;
namespace Jellyfin.Plugin.SSO_Auth.State;
/// <summary>
/// A process-local <see cref="ISsoStateStore"/> used when the server has no shared cache configured.
/// States are held serialized so that this store and <see cref="RedisSsoStateStore"/> behave identically:
/// a mutation is only visible to a later leg of the flow once it has been written back.
/// </summary>
public sealed class InMemorySsoStateStore : ISsoStateStore
{
/// <summary>
/// The most states this store keeps. The endpoint that creates them is unauthenticated, so the store
/// is capped as well as swept; past the cap the states closest to expiring are dropped first.
/// Every state carries the same expiry, so that is the oldest state: the cap trades unbounded growth
/// for a login DoS, where enough anonymous starts evict the in-flight logins of real users.
/// </summary>
public const int Capacity = 1024;
private readonly ConcurrentDictionary<string, Entry> _entries = new ConcurrentDictionary<string, Entry>(StringComparer.Ordinal);
/// <summary>
/// Gets the number of states currently held.
/// </summary>
public int Count => _entries.Count;
/// <inheritdoc />
public Task CreateAsync(TimedAuthorizeState state, TimeSpan expiry)
{
return WriteAsync(state, expiry);
}
/// <inheritdoc />
public Task UpdateAsync(TimedAuthorizeState state, TimeSpan expiry)
{
return WriteAsync(state, expiry);
}
/// <inheritdoc />
public Task<TimedAuthorizeState> GetAsync(string state)
{
if (string.IsNullOrEmpty(state) || !_entries.TryGetValue(state, out var entry))
{
return Task.FromResult<TimedAuthorizeState>(null);
}
if (IsExpired(entry))
{
_entries.TryRemove(state, out _);
return Task.FromResult<TimedAuthorizeState>(null);
}
return Task.FromResult(SsoStateSerializer.Deserialize(entry.Payload));
}
/// <inheritdoc />
public Task<TimedAuthorizeState> ConsumeAsync(string state)
{
if (string.IsNullOrEmpty(state) || !_entries.TryRemove(state, out var entry) || IsExpired(entry))
{
return Task.FromResult<TimedAuthorizeState>(null);
}
return Task.FromResult(SsoStateSerializer.Deserialize(entry.Payload));
}
/// <inheritdoc />
public Task<IReadOnlyList<TimedAuthorizeState>> ListAsync()
{
var states = new List<TimedAuthorizeState>();
foreach (var pair in _entries)
{
if (IsExpired(pair.Value))
{
_entries.TryRemove(pair.Key, out _);
continue;
}
var parsed = SsoStateSerializer.Deserialize(pair.Value.Payload);
if (parsed is not null)
{
states.Add(parsed);
}
}
return Task.FromResult<IReadOnlyList<TimedAuthorizeState>>(states);
}
private static bool IsExpired(Entry entry)
{
return entry.ExpiresAt <= DateTimeOffset.UtcNow;
}
private Task WriteAsync(TimedAuthorizeState state, TimeSpan expiry)
{
ArgumentNullException.ThrowIfNull(state);
_entries[state.State.State] = new Entry(SsoStateSerializer.Serialize(state), DateTimeOffset.UtcNow.Add(expiry));
Evict();
return Task.CompletedTask;
}
private void Evict()
{
foreach (var pair in _entries)
{
if (IsExpired(pair.Value))
{
_entries.TryRemove(pair.Key, out _);
}
}
// ConcurrentDictionary.ToArray() is a locked snapshot; LINQ buffering throws if a write races it.
var snapshot = _entries.ToArray();
var excess = snapshot.Length - Capacity;
if (excess <= 0)
{
return;
}
foreach (var pair in snapshot.OrderBy(p => p.Value.ExpiresAt).Take(excess))
{
_entries.TryRemove(pair.Key, out _);
}
}
private sealed record Entry(string Payload, DateTimeOffset ExpiresAt);
}
-115
View File
@@ -1,115 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Plugin.SSO_Auth.Api;
using StackExchange.Redis;
namespace Jellyfin.Plugin.SSO_Auth.State;
/// <summary>
/// A Redis/Valkey backed <see cref="ISsoStateStore"/>. Every login-path operation addresses a single
/// key so the store works unchanged against a cluster-mode deployment, and expiry is a key TTL rather
/// than a sweep, so a state times out even if no further login ever happens.
/// </summary>
public sealed class RedisSsoStateStore : ISsoStateStore
{
/// <summary>
/// The prefix every OpenID state key is stored under.
/// </summary>
public const string KeyPrefix = "jellyfin:sso:oid:state:";
private readonly IConnectionMultiplexer _redis;
private readonly IDatabase _database;
/// <summary>
/// Initializes a new instance of the <see cref="RedisSsoStateStore"/> class.
/// </summary>
/// <param name="redis">The connection multiplexer shared with the server.</param>
public RedisSsoStateStore(IConnectionMultiplexer redis)
{
ArgumentNullException.ThrowIfNull(redis);
_redis = redis;
_database = redis.GetDatabase();
}
/// <inheritdoc />
public Task CreateAsync(TimedAuthorizeState state, TimeSpan expiry)
{
return WriteAsync(state, expiry);
}
/// <inheritdoc />
public Task UpdateAsync(TimedAuthorizeState state, TimeSpan expiry)
{
return WriteAsync(state, expiry);
}
/// <inheritdoc />
public async Task<TimedAuthorizeState> GetAsync(string state)
{
if (string.IsNullOrEmpty(state))
{
return null;
}
var payload = await _database.StringGetAsync(KeyFor(state)).ConfigureAwait(false);
return SsoStateSerializer.Deserialize(payload);
}
/// <inheritdoc />
public async Task<TimedAuthorizeState> ConsumeAsync(string state)
{
if (string.IsNullOrEmpty(state))
{
return null;
}
var payload = await _database.StringGetDeleteAsync(KeyFor(state)).ConfigureAwait(false);
return SsoStateSerializer.Deserialize(payload);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TimedAuthorizeState>> ListAsync()
{
var states = new List<TimedAuthorizeState>();
foreach (var endpoint in _redis.GetEndPoints())
{
var server = _redis.GetServer(endpoint);
if (!server.IsConnected || server.IsReplica)
{
continue;
}
await foreach (var key in server.KeysAsync(pattern: KeyPrefix + "*").ConfigureAwait(false))
{
var parsed = SsoStateSerializer.Deserialize(await _database.StringGetAsync(key).ConfigureAwait(false));
if (parsed is not null)
{
states.Add(parsed);
}
}
}
return states;
}
private static RedisKey KeyFor(string state)
{
return KeyPrefix + state;
}
private Task WriteAsync(TimedAuthorizeState state, TimeSpan expiry)
{
ArgumentNullException.ThrowIfNull(state);
return _database.StringSetAsync(
KeyFor(state.State.State),
SsoStateSerializer.Serialize(state),
expiry,
keepTtl: false,
when: When.Always,
flags: CommandFlags.None);
}
}
-92
View File
@@ -1,92 +0,0 @@
using System;
using System.Collections.Generic;
using Jellyfin.Plugin.SSO_Auth.Api;
namespace Jellyfin.Plugin.SSO_Auth.State;
/// <summary>
/// A redacted view of an in-flight authorization state. The PKCE code verifier and the authorization
/// URL derived from it are deliberately omitted so that listing flows cannot leak them.
/// </summary>
public class SsoStateInfo
{
/// <summary>
/// Gets or sets the OpenID state value.
/// </summary>
public string State { get; set; }
/// <summary>
/// Gets or sets the redirect URI the provider calls back to.
/// </summary>
public string RedirectUri { get; set; }
/// <summary>
/// Gets or sets when the flow started.
/// </summary>
public DateTime Created { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the flow has been validated.
/// </summary>
public bool Valid { get; set; }
/// <summary>
/// Gets or sets the user tied to the flow.
/// </summary>
public string Username { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user is an administrator.
/// </summary>
public bool Admin { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the flow links an existing account.
/// </summary>
public bool IsLinking { get; set; }
/// <summary>
/// Gets or sets the folders the user is allowed access to.
/// </summary>
public IReadOnlyList<string> Folders { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user is allowed to view live TV.
/// </summary>
public bool EnableLiveTv { get; set; }
/// <summary>
/// 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; }
/// <summary>
/// Projects a stored state onto its redacted view.
/// </summary>
/// <param name="state">The stored state.</param>
/// <returns>The redacted view.</returns>
public static SsoStateInfo FromState(TimedAuthorizeState state)
{
ArgumentNullException.ThrowIfNull(state);
return new SsoStateInfo
{
State = state.State?.State,
RedirectUri = state.State?.RedirectUri,
Created = state.Created,
Valid = state.Valid,
Username = state.Username,
Admin = state.Admin,
IsLinking = state.IsLinking,
Folders = state.Folders,
EnableLiveTv = state.EnableLiveTv,
EnableLiveTvManagement = state.EnableLiveTvManagement,
AvatarURL = state.AvatarURL
};
}
}
-36
View File
@@ -1,36 +0,0 @@
using Jellyfin.Plugin.SSO_Auth.Api;
using Newtonsoft.Json;
namespace Jellyfin.Plugin.SSO_Auth.State;
/// <summary>
/// Converts <see cref="TimedAuthorizeState"/> to and from the representation held by a state store.
/// </summary>
public static class SsoStateSerializer
{
private static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Include,
DateTimeZoneHandling = DateTimeZoneHandling.RoundtripKind
};
/// <summary>
/// Serializes an authorization state.
/// </summary>
/// <param name="state">The state to serialize.</param>
/// <returns>The serialized state.</returns>
public static string Serialize(TimedAuthorizeState state)
{
return JsonConvert.SerializeObject(state, Settings);
}
/// <summary>
/// Deserializes an authorization state.
/// </summary>
/// <param name="payload">The serialized state, which may be null or empty.</param>
/// <returns>The state, or <c>null</c> when there is nothing to deserialize.</returns>
public static TimedAuthorizeState Deserialize(string payload)
{
return string.IsNullOrEmpty(payload) ? null : JsonConvert.DeserializeObject<TimedAuthorizeState>(payload, Settings);
}
}
+4 -4
View File
@@ -66,7 +66,7 @@ export async function serverAddress({ basePath = "/web" }) {
url: obj.url,
config: obj.response.json(),
};
}),
})
);
})
.then((configs) => {
@@ -130,11 +130,11 @@ var localApiClient = new jellyfinApiclient.ApiClient(
appName,
appVersion,
getDeviceName(),
deviceId,
deviceId
);
localApiClient.setAuthenticationInfo(
current_server.AccessToken,
current_server.UserId,
current_server.UserId
);
var connections = new jellyfinApiclient.ConnectionManager(
@@ -143,7 +143,7 @@ var connections = new jellyfinApiclient.ConnectionManager(
appVersion,
getDeviceName(),
deviceId,
capabilities,
capabilities
);
connections.addApiClient(localApiClient);
+13 -21
View File
@@ -229,9 +229,7 @@ Emby Button
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),
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;
@@ -240,42 +238,36 @@ 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 {
+91 -101
View File
@@ -1,5 +1,3 @@
using System.Globalization;
namespace Jellyfin.Plugin.SSO_Auth;
/// <summary>
@@ -11,16 +9,7 @@ public static class WebResponse
/// The shared HTML between all of the responses.
/// </summary>
public static readonly string Base = @"<!DOCTYPE html>
<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>
<html><head></head><body>
<p>Logging in...</p>
<noscript>Please enable Javascript to complete the login</noscript>
<script>
@@ -46,14 +35,25 @@ function isTv() {
return true;
}
return isWeb0s();
if (isWeb0s()) {
return true;
}
return false;
}
function isWeb0s() {
const userAgent = navigator.userAgent.toLowerCase();
return userAgent.indexOf('netcast') !== -1
|| userAgent.indexOf('web0s') !== -1;
if (userAgent.indexOf('netcast') !== -1) {
return true;
}
if (userAgent.indexOf('web0s') !== -1) {
return true;
}
return false;
}
function isMobile(userAgent) {
@@ -100,7 +100,11 @@ function hasKeyboard(browser) {
return true;
}
return !!browser.tv;
if (browser.tv) {
return true;
}
return false;
}
function iOSversion() {
@@ -143,12 +147,8 @@ function web0sVersion(browser) {
// The next is only valid for the app
if (browser.versionMajor >= 94) {
return 23;
} else if (browser.versionMajor >= 87) {
return 22;
} else if (browser.versionMajor >= 79) {
return 6;
if (browser.versionMajor >= 79) {
return 6;
} else if (browser.versionMajor >= 68) {
return 5;
} else if (browser.versionMajor >= 53) {
@@ -179,11 +179,14 @@ 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 && (_supportsCssAnimationWithPrefix === true || _supportsCssAnimationWithPrefix === false)) {
return _supportsCssAnimationWithPrefix;
}
if (_supportsCssAnimation === true || _supportsCssAnimation === false) {
return _supportsCssAnimation;
if (allowPrefix) {
if (_supportsCssAnimationWithPrefix === true || _supportsCssAnimationWithPrefix === false) {
return _supportsCssAnimationWithPrefix;
}
} else {
if (_supportsCssAnimation === true || _supportsCssAnimation === false) {
return _supportsCssAnimation;
}
}
let animation = false;
@@ -195,8 +198,8 @@ function supportsCssAnimation(allowPrefix) {
}
if (animation === false && allowPrefix) {
for (const domPrefix of domPrefixes) {
if (elm.style[domPrefix + 'AnimationName'] !== undefined) {
for (let i = 0; i < domPrefixes.length; i++) {
if (elm.style[domPrefixes[i] + 'AnimationName'] !== undefined) {
animation = true;
break;
}
@@ -215,25 +218,25 @@ function supportsCssAnimation(allowPrefix) {
const uaMatch = function (ua) {
ua = ua.toLowerCase();
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 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 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] || '';
@@ -252,7 +255,7 @@ const uaMatch = function (ua) {
version = version || match[2] || '0';
let versionMajor = parseInt(version.split('.')[0], 10);
let versionMajor = parseInt(version.split('.')[0]);
if (isNaN(versionMajor)) {
versionMajor = 0;
@@ -310,9 +313,7 @@ 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);
@@ -323,7 +324,7 @@ if (browser.web0s) {
delete browser.safari;
const v = (navigator.appVersion).match(/Tizen (\d+).(\d+)/);
browser.tizenVersion = parseInt(v[1], 10);
browser.tizenVersion = parseInt(v[1]);
} else {
browser.orsay = userAgent.toLowerCase().indexOf('smarthub') !== -1;
}
@@ -339,9 +340,11 @@ if (browser.mobile || browser.tv) {
browser.slow = true;
}
/* eslint-disable-next-line compat/compat */
if (typeof document !== 'undefined' && ('ontouchstart' in window) || (navigator.maxTouchPoints > 0)) {
browser.touch = true;
if (typeof document !== 'undefined') {
/* eslint-disable-next-line compat/compat */
if (('ontouchstart' in window) || (navigator.maxTouchPoints > 0)) {
browser.touch = true;
}
}
browser.keyboard = hasKeyboard(browser);
@@ -358,41 +361,39 @@ if (browser.iOS) {
}
function getDeviceName() {
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';
}
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';
}
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;
@@ -415,14 +416,6 @@ 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"");
@@ -436,7 +429,7 @@ async function link(request) {
if (jfUser == null) return;
if (jfToken == null) return;
const url = '" + $"{punycodeBaseUrl}/sso/{mode}/Link/{provider}/" + @"' + jfUser;
const url = '" + $"{baseUrl}/sso/{mode}/Link/{provider}/" + @"' + jfUser;
return new Promise(resolve => {
var xhr = new XMLHttpRequest();
@@ -460,9 +453,6 @@ 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 ||
@@ -479,7 +469,7 @@ async function main() {
if (" + $"{isLinking}".ToLower() + @") await link(request);
var url = '" + punycodeBaseUrl + "/sso/" + mode + "/Auth/" + provider + @"';
var url = '" + baseUrl + "/sso/" + mode + "/Auth/" + provider + @"';
let response = await new Promise(resolve => {
var xhr = new XMLHttpRequest();
@@ -503,7 +493,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('" + punycodeBaseUrl + @"/web/index.html');
window.location.replace('" + baseUrl + @"');
}
document.addEventListener('DOMContentLoaded', function () {
@@ -511,6 +501,6 @@ document.addEventListener('DOMContentLoaded', function () {
});
// https://stackoverflow.com/a/25435165
</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>";
</script><iframe class='docs-texteventtarget-iframe' sandbox='allow-same-origin allow-forms allow-scripts' src='" + baseUrl + "' style='position: absolute;width:0;height:0;border:0;'></iframe></body></html>";
}
}
+6 -18
View File
@@ -1,10 +1,10 @@
name: "SSO Authentication"
guid: "505ce9d1-d916-42fa-86ca-673ef241d7df"
imageUrl: "https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/main/img/logo.png"
version: "5.0.0.0"
targetAbi: "12.0.0.0"
framework: "net10.0"
owner: "unkin"
version: "3.4.0.0"
targetAbi: "10.8.0.0"
framework: "net6.0"
owner: "9p4"
overview: "Authenticate users against an SSO provider."
description: |
This plugin allows users to sign in through an SSO provider (such as Google, Facebook, or your own provider). This enables one-click signin.
@@ -12,21 +12,9 @@ description: |
category: "Authentication"
artifacts:
- "SSO-Auth.dll"
- "Duende.IdentityModel.OidcClient.dll"
- "Duende.IdentityModel.dll"
- "IdentityModel.OidcClient.dll"
- "IdentityModel.dll"
changelog: |
5.0.0.0: Jellyfin 12.0
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
3.5.1.0: Improved paths! No more obscure "p" versus "r" URLs! Improve final redirect for automatic authentication. Add more configuration options for OpenID discovery.
3.5.0.0: Add support for Live TV authentication. Fix various null pointer bugs.
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.
Generated
-27
View File
@@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1760934318,
"narHash": "sha256-/oUYsC0lUCBory65VK+UHqCCsCspbL1Vgfcf1KUYqVw=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "87848bf0cc4f87717fc813a4575f07330c3e743c",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-10
View File
@@ -1,10 +0,0 @@
{
inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; };
outputs = { self, nixpkgs }:
let pkgs = nixpkgs.legacyPackages.x86_64-linux;
in {
devShell.x86_64-linux =
pkgs.mkShell { buildInputs = [ pkgs.nodePackages.prettier pkgs.dotnet-sdk_9 ]; };
};
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

+61 -68
View File
@@ -1,75 +1,68 @@
<?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 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>
<!-- 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>
<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>
+5 -110
View File
@@ -2,8 +2,6 @@
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
@@ -12,15 +10,13 @@ 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
- ✅ Google OIDC
- ❗ Usernames are numeric
- ❗ Requires disabling validating OpenID endpoints
## General Options, when RBAC is supported
@@ -45,25 +41,6 @@ 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:
@@ -75,7 +52,7 @@ identity_providers:
secret: <redacted>
authorization_policy: one_factor
redirect_uris:
- https://jellyfin.example.com/sso/OID/redirect/authelia
- https://jellyfin.example.com/sso/OID/r/authelia
```
### Jellyfin's Config
@@ -91,7 +68,6 @@ authelia:
OidSecret: <redacted>
RoleClaim: groups
OidScopes: ["groups"]
DisablePushedAuthorization: true
```
## authentik
@@ -137,7 +113,6 @@ Now we can add this property mapping to authentik's Jellyfin OAuth provider:
![image](img/authentik-config-04.jpg)
- Edit / Update your Jellyfin OAuth provider
- Verify your **"Redirect URIs/Origins (RegEx)"** follows the format: `https://domain.tld/sso/OID/redirect/Authentik`.
- Under **"Advanced Protocol Settings"**, add the **Group Membership** Scope
![image](img/authentik-config-05.jpg)
@@ -157,8 +132,6 @@ authentik:
OidScopes: ["groups"]
```
If you recieve the error `Error processing request.` from Jellyfin when attempting to login and the Jellyfin logs show `Error loading discovery document: Endpoint belongs to different authority` try setting `Do not validate endpoints` in the plugin settings.
## 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.
@@ -171,8 +144,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
- 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.
@@ -206,9 +178,9 @@ 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/start/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/start/PROVIDER_NAME)
- 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/start/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/start/PROVIDER_NAME)
- 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.
@@ -226,80 +198,3 @@ 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
```
@@ -1,143 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Duende.IdentityModel.OidcClient;
using Jellyfin.Plugin.SSO_Auth.Api;
using Jellyfin.Plugin.SSO_Auth.State;
using Xunit;
namespace Jellyfin.Plugin.SSO_Auth.Tests;
public class InMemorySsoStateStoreTests
{
private static readonly TimeSpan Expiry = TimeSpan.FromSeconds(300);
[Fact]
public async Task GetAsync_WhenUnknown_ReturnsNull()
{
Assert.Null(await new InMemorySsoStateStore().GetAsync("nope"));
}
[Fact]
public async Task Mutations_AreOnlyVisibleOnceWrittenBack()
{
var store = new InMemorySsoStateStore();
await store.CreateAsync(NewState("abc123"), Expiry);
var dropped = await store.GetAsync("abc123");
dropped.Valid = true;
dropped.Username = "mallory";
// Not written back, so the mutation must not be observable.
var reread = await store.GetAsync("abc123");
Assert.False(reread.Valid);
Assert.Null(reread.Username);
reread.Valid = true;
reread.Username = "alice";
reread.Admin = true;
reread.Folders = new List<string> { "movies" };
await store.UpdateAsync(reread, Expiry);
var redeemed = await store.ConsumeAsync("abc123");
Assert.True(redeemed.Valid);
Assert.Equal("alice", redeemed.Username);
Assert.True(redeemed.Admin);
Assert.Equal(new[] { "movies" }, redeemed.Folders);
}
[Fact]
public async Task ConsumeAsync_RedeemsOnlyOnce()
{
var store = new InMemorySsoStateStore();
await store.CreateAsync(NewState("abc123"), Expiry);
Assert.NotNull(await store.ConsumeAsync("abc123"));
Assert.Null(await store.ConsumeAsync("abc123"));
Assert.Null(await store.GetAsync("abc123"));
}
[Fact]
public async Task ExpiredState_IsNotReturned()
{
var store = new InMemorySsoStateStore();
await store.CreateAsync(NewState("abc123"), TimeSpan.FromMilliseconds(-1));
Assert.Null(await store.GetAsync("abc123"));
Assert.Empty(await store.ListAsync());
}
[Fact]
public async Task ListAsync_ReturnsLiveStates()
{
var store = new InMemorySsoStateStore();
await store.CreateAsync(NewState("abc123"), Expiry);
await store.CreateAsync(NewState("def456"), Expiry);
var states = await store.ListAsync();
Assert.Equal(2, states.Count);
}
[Fact]
public async Task Writing_SweepsExpiredEntriesOutOfTheStore()
{
var store = new InMemorySsoStateStore();
await store.CreateAsync(NewState("stale1"), TimeSpan.FromMilliseconds(-1));
await store.CreateAsync(NewState("stale2"), TimeSpan.FromMilliseconds(-1));
await store.CreateAsync(NewState("stale3"), TimeSpan.FromMilliseconds(-1));
await store.CreateAsync(NewState("live"), Expiry);
Assert.Equal(1, store.Count);
}
[Fact]
public async Task Writing_BoundsTheStoreAtItsCapacity()
{
var store = new InMemorySsoStateStore();
for (var i = 0; i < InMemorySsoStateStore.Capacity + 64; i++)
{
await store.CreateAsync(NewState($"state{i}"), Expiry);
}
Assert.Equal(InMemorySsoStateStore.Capacity, store.Count);
}
[Fact]
public async Task Writing_AtCapacity_IsSafeUnderConcurrency()
{
var store = new InMemorySsoStateStore();
for (var i = 0; i < InMemorySsoStateStore.Capacity; i++)
{
await store.CreateAsync(NewState($"seed{i}"), Expiry);
}
const int Writers = 8;
var writers = Enumerable.Range(0, Writers).Select(writer => Task.Run(async () =>
{
for (var i = 0; i < 2000; i++)
{
var state = NewState($"w{writer}-{i}");
await store.CreateAsync(state, Expiry);
await store.UpdateAsync(state, Expiry);
}
}));
await Task.WhenAll(writers);
// Each in-flight writer can miss or repeat one eviction, so the cap holds to within the writer count.
Assert.InRange(store.Count, InMemorySsoStateStore.Capacity - Writers, InMemorySsoStateStore.Capacity + Writers);
}
private static TimedAuthorizeState NewState(string state)
{
return new TimedAuthorizeState(
new AuthorizeState { State = state, CodeVerifier = "code-verifier-secret" },
DateTime.UtcNow);
}
}
@@ -1,219 +0,0 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using Duende.IdentityModel.OidcClient;
using Jellyfin.Plugin.SSO_Auth.Api;
using Jellyfin.Plugin.SSO_Auth.State;
using Moq;
using StackExchange.Redis;
using Xunit;
namespace Jellyfin.Plugin.SSO_Auth.Tests;
public class RedisSsoStateStoreTests
{
[Fact]
public async Task CreateAsync_WritesPrefixedKeyWithExpiry()
{
var db = new Mock<IDatabase>();
RedisKey key = default;
RedisValue value = default;
TimeSpan? expiry = null;
SetupStringSet(db, (k, v, e) =>
{
key = k;
value = v;
expiry = e;
});
var store = new RedisSsoStateStore(CreateMultiplexer(db.Object));
await store.CreateAsync(NewState("abc123"), TimeSpan.FromSeconds(300));
Assert.Equal("jellyfin:sso:oid:state:abc123", key.ToString());
Assert.Equal(TimeSpan.FromSeconds(300), expiry);
Assert.Contains("abc123", value.ToString(), StringComparison.Ordinal);
}
[Fact]
public async Task UpdateAsync_RefreshesExpiry()
{
var db = new Mock<IDatabase>();
TimeSpan? expiry = null;
SetupStringSet(db, (k, v, e) => expiry = e);
var store = new RedisSsoStateStore(CreateMultiplexer(db.Object));
await store.UpdateAsync(NewState("abc123"), TimeSpan.FromSeconds(120));
Assert.Equal(TimeSpan.FromSeconds(120), expiry);
}
[Fact]
public async Task GetAsync_WhenKeyMissing_ReturnsNull()
{
var db = new Mock<IDatabase>();
db.Setup(d => d.StringGetAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync(RedisValue.Null);
var store = new RedisSsoStateStore(CreateMultiplexer(db.Object));
Assert.Null(await store.GetAsync("missing"));
}
[Fact]
public async Task GetAsync_WithoutState_DoesNotTouchRedis()
{
var db = new Mock<IDatabase>(MockBehavior.Strict);
var store = new RedisSsoStateStore(CreateMultiplexer(db.Object));
Assert.Null(await store.GetAsync(null));
Assert.Null(await store.GetAsync(string.Empty));
}
[Fact]
public async Task Mutations_WrittenByTheCallback_AreVisibleToTheAuthLeg()
{
var db = new Mock<IDatabase>();
string stored = null;
SetupStringSet(db, (k, v, e) => stored = v.ToString());
db.Setup(d => d.StringGetAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync(() => stored is null ? RedisValue.Null : (RedisValue)stored);
db.Setup(d => d.StringGetDeleteAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync(() =>
{
var payload = stored;
stored = null;
return payload is null ? RedisValue.Null : (RedisValue)payload;
});
var store = new RedisSsoStateStore(CreateMultiplexer(db.Object));
var expiry = TimeSpan.FromSeconds(300);
// Leg 1: the start endpoint stores a freshly prepared state.
await store.CreateAsync(NewState("abc123"), expiry);
// Leg 2: the callback endpoint validates the response and writes its decision back.
var callbackState = await store.GetAsync("abc123");
Assert.NotNull(callbackState);
Assert.False(callbackState.Valid);
callbackState.Valid = true;
callbackState.Username = "alice";
callbackState.Admin = true;
callbackState.AvatarURL = "https://idp.example/alice.png";
await store.UpdateAsync(callbackState, expiry);
// Leg 3: the auth endpoint redeems the state and sees the callback's decision.
var authState = await store.ConsumeAsync("abc123");
Assert.NotNull(authState);
Assert.True(authState.Valid);
Assert.Equal("alice", authState.Username);
Assert.True(authState.Admin);
Assert.Equal("https://idp.example/alice.png", authState.AvatarURL);
Assert.Equal("code-verifier-secret", authState.State.CodeVerifier);
// A replay of the same response finds nothing.
Assert.Null(await store.ConsumeAsync("abc123"));
}
[Fact]
public async Task ConsumeAsync_UsesAnAtomicGetAndDelete()
{
var db = new Mock<IDatabase>(MockBehavior.Strict);
db.Setup(d => d.StringGetDeleteAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync(RedisValue.Null);
var store = new RedisSsoStateStore(CreateMultiplexer(db.Object));
Assert.Null(await store.ConsumeAsync("abc123"));
db.Verify(
d => d.StringGetDeleteAsync(
It.Is<RedisKey>(k => k.ToString() == "jellyfin:sso:oid:state:abc123"),
It.IsAny<CommandFlags>()),
Times.Once);
}
[Fact]
public async Task ListAsync_ScansPrimariesOnly()
{
var db = new Mock<IDatabase>();
db.Setup(d => d.StringGetAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync((RedisValue)SsoStateSerializer.Serialize(NewState("abc123")));
var primary = CreateServer(isReplica: false, keys: new[] { (RedisKey)"jellyfin:sso:oid:state:abc123" });
var replica = CreateServer(isReplica: true, keys: new[] { (RedisKey)"jellyfin:sso:oid:state:abc123" });
var primaryEndpoint = new DnsEndPoint("primary", 6379);
var replicaEndpoint = new DnsEndPoint("replica", 6379);
var mux = new Mock<IConnectionMultiplexer>();
mux.Setup(m => m.GetDatabase(It.IsAny<int>(), It.IsAny<object>())).Returns(db.Object);
mux.Setup(m => m.GetEndPoints(It.IsAny<bool>())).Returns(new EndPoint[] { primaryEndpoint, replicaEndpoint });
mux.Setup(m => m.GetServer(primaryEndpoint, It.IsAny<object>())).Returns(primary);
mux.Setup(m => m.GetServer(replicaEndpoint, It.IsAny<object>())).Returns(replica);
var states = await new RedisSsoStateStore(mux.Object).ListAsync();
Assert.Single(states);
Assert.Equal("abc123", states[0].State.State);
}
private static TimedAuthorizeState NewState(string state)
{
return new TimedAuthorizeState(
new AuthorizeState
{
State = state,
StartUrl = "https://idp.example/authorize",
RedirectUri = "https://jellyfin.example/sso/OID/redirect/authentik",
CodeVerifier = "code-verifier-secret"
},
DateTime.UtcNow);
}
private static void SetupStringSet(Mock<IDatabase> db, Action<RedisKey, RedisValue, TimeSpan?> capture)
{
db.Setup(d => d.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
It.IsAny<When>(),
It.IsAny<CommandFlags>()))
.Callback<RedisKey, RedisValue, TimeSpan?, bool, When, CommandFlags>(
(key, value, expiry, keepTtl, when, flags) => capture(key, value, expiry))
.ReturnsAsync(true);
}
private static IServer CreateServer(bool isReplica, IReadOnlyList<RedisKey> keys)
{
var server = new Mock<IServer>();
server.SetupGet(s => s.IsConnected).Returns(true);
server.SetupGet(s => s.IsReplica).Returns(isReplica);
server.Setup(s => s.KeysAsync(
It.IsAny<int>(),
It.IsAny<RedisValue>(),
It.IsAny<int>(),
It.IsAny<long>(),
It.IsAny<int>(),
It.IsAny<CommandFlags>()))
.Returns(ToAsyncEnumerable(keys));
return server.Object;
}
private static async IAsyncEnumerable<RedisKey> ToAsyncEnumerable(IReadOnlyList<RedisKey> keys)
{
foreach (var key in keys)
{
yield return key;
}
await Task.CompletedTask;
}
private static IConnectionMultiplexer CreateMultiplexer(IDatabase database)
{
var mux = new Mock<IConnectionMultiplexer>();
mux.Setup(m => m.GetDatabase(It.IsAny<int>(), It.IsAny<object>())).Returns(database);
return mux.Object;
}
}
@@ -1,27 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.SSO_Auth.Tests</RootNamespace>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageReference Include="Moq" Version="4.18.4" />
<PackageReference Include="StackExchange.Redis" Version="2.13.17" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\SSO-Auth\SSO-Auth.csproj" />
</ItemGroup>
</Project>
-238
View File
@@ -1,238 +0,0 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using Duende.IdentityModel.OidcClient;
using Jellyfin.Plugin.SSO_Auth.Api;
using Jellyfin.Plugin.SSO_Auth.Config;
using Jellyfin.Plugin.SSO_Auth.State;
using MediaBrowser.Common.Configuration;
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 MediaBrowser.Model.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using Newtonsoft.Json;
using Xunit;
namespace Jellyfin.Plugin.SSO_Auth.Tests;
public class SSOControllerTests
{
[Fact]
public async Task OidPost_WhenTheStateIsUnknownToTheStore_ReportsAnExpiredState()
{
ConfigurePlugin();
var store = new Mock<ISsoStateStore>();
store.Setup(s => s.GetAsync("state-from-another-pod")).ReturnsAsync((TimedAuthorizeState)null);
var result = await CreateController(store.Object).OidPost("authentik", "state-from-another-pod");
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid or expired state", badRequest.Value);
}
[Fact]
public async Task OidStates_DoesNotExposeTheCodeVerifier()
{
ConfigurePlugin();
var state = new TimedAuthorizeState(
new AuthorizeState
{
State = "abc123",
StartUrl = "https://idp.example/authorize?code_challenge=abc",
RedirectUri = "https://jellyfin.example/sso/OID/redirect/authentik",
CodeVerifier = "code-verifier-secret"
},
DateTime.UtcNow)
{
Username = "alice"
};
var store = new Mock<ISsoStateStore>();
store.Setup(s => s.ListAsync()).ReturnsAsync(new[] { state });
var result = await CreateController(store.Object).OidStates();
var ok = Assert.IsType<OkObjectResult>(result);
var body = JsonConvert.SerializeObject(ok.Value);
Assert.DoesNotContain("code-verifier-secret", body, StringComparison.Ordinal);
Assert.DoesNotContain("CodeVerifier", body, StringComparison.OrdinalIgnoreCase);
Assert.Contains("abc123", body, StringComparison.Ordinal);
Assert.Contains("alice", body, StringComparison.Ordinal);
}
[Fact]
public async Task OidPost_WhenTheStoreIsUnreachable_RejectsTheLoginAndLogsIt()
{
ConfigurePlugin();
var store = new Mock<ISsoStateStore>();
store.Setup(s => s.GetAsync(It.IsAny<string>())).ThrowsAsync(new InvalidOperationException("valkey is down"));
var logger = new Mock<ILogger<SSOController>>();
var result = await CreateController(store.Object, logger.Object).OidPost("authentik", "abc123");
var content = Assert.IsType<ContentResult>(result);
Assert.Equal(StatusCodes.Status503ServiceUnavailable, content.StatusCode);
AssertLoggedAnError(logger);
}
[Fact]
public async Task OidStates_WhenTheStoreIsUnreachable_ReportsItUnavailable()
{
ConfigurePlugin();
var store = new Mock<ISsoStateStore>();
store.Setup(s => s.ListAsync()).ThrowsAsync(new InvalidOperationException("valkey is down"));
var result = await CreateController(store.Object).OidStates();
var content = Assert.IsType<ContentResult>(result);
Assert.Equal(StatusCodes.Status503ServiceUnavailable, content.StatusCode);
}
[Fact]
public async Task OidAuth_WhenTheStoreIsUnreachable_DoesNotAuthenticate()
{
ConfigurePlugin();
var store = new Mock<ISsoStateStore>();
store.Setup(s => s.ConsumeAsync(It.IsAny<string>())).ThrowsAsync(new InvalidOperationException("valkey is down"));
var sessionManager = new Mock<ISessionManager>();
var result = await CreateController(store.Object, sessionManager: sessionManager)
.OidAuth("authentik", new AuthResponse { Data = "abc123" });
var problem = Assert.IsType<ObjectResult>(result);
Assert.Equal(StatusCodes.Status503ServiceUnavailable, problem.StatusCode);
sessionManager.VerifyNoOtherCalls();
}
[Fact]
public async Task CompleteOidState_WhenTheRoleCheckFailed_DiscardsTheStateInsteadOfExtendingIt()
{
ConfigurePlugin();
var store = new Mock<ISsoStateStore>();
var timedState = NewState("abc123");
var result = await CreateController(store.Object)
.CompleteOidState("authentik", "abc123", timedState, "https://jellyfin.example");
var content = Assert.IsType<ContentResult>(result);
Assert.Equal(StatusCodes.Status401Unauthorized, content.StatusCode);
store.Verify(s => s.ConsumeAsync("abc123"), Times.Once);
store.Verify(s => s.UpdateAsync(It.IsAny<TimedAuthorizeState>(), It.IsAny<TimeSpan>()), Times.Never);
}
[Fact]
public async Task CompleteOidState_WhenValid_WritesTheStateBack()
{
ConfigurePlugin();
var store = new Mock<ISsoStateStore>();
var timedState = NewState("abc123");
timedState.Valid = true;
var result = await CreateController(store.Object)
.CompleteOidState("authentik", "abc123", timedState, "https://jellyfin.example");
var content = Assert.IsType<ContentResult>(result);
Assert.Null(content.StatusCode);
store.Verify(s => s.UpdateAsync(timedState, It.IsAny<TimeSpan>()), Times.Once);
store.Verify(s => s.ConsumeAsync(It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task CompleteOidState_WhenTheWriteBackFails_RejectsTheLogin()
{
ConfigurePlugin();
var store = new Mock<ISsoStateStore>();
store.Setup(s => s.UpdateAsync(It.IsAny<TimedAuthorizeState>(), It.IsAny<TimeSpan>()))
.ThrowsAsync(new InvalidOperationException("valkey is down"));
var timedState = NewState("abc123");
timedState.Valid = true;
var result = await CreateController(store.Object)
.CompleteOidState("authentik", "abc123", timedState, "https://jellyfin.example");
var content = Assert.IsType<ContentResult>(result);
Assert.Equal(StatusCodes.Status503ServiceUnavailable, content.StatusCode);
}
private static TimedAuthorizeState NewState(string state)
{
return new TimedAuthorizeState(
new AuthorizeState { State = state, CodeVerifier = "code-verifier-secret" },
DateTime.UtcNow);
}
private static void AssertLoggedAnError(Mock<ILogger<SSOController>> logger)
{
logger.Verify(
l => l.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(),
(Func<It.IsAnyType, Exception, string>)It.IsAny<object>()),
Times.Once);
}
private static void ConfigurePlugin()
{
var root = Path.Combine(Path.GetTempPath(), "sso-auth-tests", Guid.NewGuid().ToString("N"));
var paths = new Mock<IApplicationPaths>();
paths.SetupGet(p => p.PluginsPath).Returns(root);
paths.SetupGet(p => p.PluginConfigurationsPath).Returns(root);
var plugin = new SSOPlugin(paths.Object, new Mock<IXmlSerializer>().Object);
var configuration = new PluginConfiguration();
configuration.OidConfigs["authentik"] = new OidConfig { Enabled = true };
plugin.UpdateConfiguration(configuration);
}
private static SSOController CreateController(
ISsoStateStore store,
ILogger<SSOController> logger = null,
Mock<ISessionManager> sessionManager = null)
{
var problemDetailsFactory = new Mock<ProblemDetailsFactory>();
problemDetailsFactory
.Setup(f => f.CreateProblemDetails(
It.IsAny<HttpContext>(),
It.IsAny<int?>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>()))
.Returns((HttpContext _, int? status, string title, string type, string detail, string instance) =>
new ProblemDetails { Status = status, Title = title, Detail = detail });
var services = new ServiceCollection();
services.AddSingleton(problemDetailsFactory.Object);
return new SSOController(
logger ?? new Mock<ILogger<SSOController>>().Object,
new Mock<ILoggerFactory>().Object,
(sessionManager ?? new Mock<ISessionManager>()).Object,
new Mock<IUserManager>().Object,
new Mock<IAuthorizationContext>().Object,
new Mock<ICryptoProvider>().Object,
new Mock<IProviderManager>().Object,
new Mock<IHttpClientFactory>().Object,
new Mock<IServerConfigurationManager>().Object,
store)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }
}
};
}
}
@@ -1,47 +0,0 @@
using Jellyfin.Plugin.SSO_Auth.State;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using StackExchange.Redis;
using Xunit;
namespace Jellyfin.Plugin.SSO_Auth.Tests;
public class SsoAuthServiceRegistratorTests
{
[Fact]
public void RegisterServices_WithoutAMultiplexer_FallsBackToTheInMemoryStore()
{
var provider = Register(withMultiplexer: false);
Assert.IsType<InMemorySsoStateStore>(provider.GetRequiredService<ISsoStateStore>());
}
[Fact]
public void RegisterServices_WithAMultiplexer_UsesTheSharedStore()
{
var provider = Register(withMultiplexer: true);
Assert.IsType<RedisSsoStateStore>(provider.GetRequiredService<ISsoStateStore>());
}
private static ServiceProvider Register(bool withMultiplexer)
{
var loggerFactory = new Mock<ILoggerFactory>();
loggerFactory.Setup(f => f.CreateLogger(It.IsAny<string>())).Returns(new Mock<ILogger>().Object);
var services = new ServiceCollection();
services.AddSingleton(loggerFactory.Object);
if (withMultiplexer)
{
var mux = new Mock<IConnectionMultiplexer>();
mux.Setup(m => m.GetDatabase(It.IsAny<int>(), It.IsAny<object>())).Returns(new Mock<IDatabase>().Object);
services.AddSingleton(mux.Object);
}
new SsoAuthServiceRegistrator().RegisterServices(services, null);
return services.BuildServiceProvider();
}
}
@@ -1,82 +0,0 @@
using System;
using System.Collections.Generic;
using Duende.IdentityModel.OidcClient;
using Jellyfin.Plugin.SSO_Auth.Api;
using Jellyfin.Plugin.SSO_Auth.State;
using Xunit;
namespace Jellyfin.Plugin.SSO_Auth.Tests;
public class SsoStateSerializerTests
{
[Fact]
public void RoundTrip_PreservesEveryFieldTheFlowDependsOn()
{
var original = new TimedAuthorizeState(
new AuthorizeState
{
State = "state-value",
StartUrl = "https://idp.example/authorize?code_challenge=abc",
RedirectUri = "https://jellyfin.example/sso/OID/redirect/authentik",
CodeVerifier = "code-verifier-secret"
},
new DateTime(2026, 9, 19, 10, 30, 0, DateTimeKind.Utc))
{
Valid = true,
Username = "alice",
Admin = true,
IsLinking = true,
Folders = new List<string> { "movies", "shows" },
EnableLiveTv = true,
EnableLiveTvManagement = true,
AvatarURL = "https://idp.example/avatar/alice.png"
};
var restored = SsoStateSerializer.Deserialize(SsoStateSerializer.Serialize(original));
Assert.NotNull(restored);
Assert.Equal("state-value", restored.State.State);
Assert.Equal("https://idp.example/authorize?code_challenge=abc", restored.State.StartUrl);
Assert.Equal("https://jellyfin.example/sso/OID/redirect/authentik", restored.State.RedirectUri);
// The code verifier is required to complete the token exchange on the pod that handles the callback.
Assert.Equal("code-verifier-secret", restored.State.CodeVerifier);
Assert.Equal(original.Created, restored.Created);
Assert.True(restored.Valid);
Assert.Equal("alice", restored.Username);
Assert.True(restored.Admin);
Assert.True(restored.IsLinking);
Assert.Equal(new[] { "movies", "shows" }, restored.Folders);
Assert.True(restored.EnableLiveTv);
Assert.True(restored.EnableLiveTvManagement);
Assert.Equal("https://idp.example/avatar/alice.png", restored.AvatarURL);
}
[Fact]
public void RoundTrip_PreservesNulls()
{
var original = new TimedAuthorizeState(
new AuthorizeState { State = "state-value", CodeVerifier = null, RedirectUri = null, StartUrl = null },
DateTime.UtcNow);
var restored = SsoStateSerializer.Deserialize(SsoStateSerializer.Serialize(original));
Assert.NotNull(restored);
Assert.Equal("state-value", restored.State.State);
Assert.Null(restored.State.CodeVerifier);
Assert.Null(restored.State.RedirectUri);
Assert.Null(restored.Username);
Assert.Null(restored.AvatarURL);
Assert.Null(restored.Folders);
Assert.False(restored.Valid);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void Deserialize_WithoutPayload_ReturnsNull(string payload)
{
Assert.Null(SsoStateSerializer.Deserialize(payload));
}
}