Compare commits

...
Author SHA1 Message Date
Ersei Saggi 172f22e19f release: prepare for v3.5.1.0 2023-08-06 16:34:57 -04:00
Ersei Saggi 5777c8038e fix: improve final redirect (#109) 2023-08-06 16:29:44 -04:00
Ersei Saggi 95776b427b style: fix configPage style (prettier) 2023-08-06 16:27:01 -04:00
Ersei Saggi b82a3867e4 docs: detail Google requiring disabling of OpenID endpoint validation 2023-08-06 16:24:05 -04:00
Ersei Saggi 657f90ef13 fix: make SAML paths better; improve and add OID discovery defaults 2023-08-06 16:19:53 -04:00
Ersei Saggi dd01e729b8 docs: clarify plugin resetting permissions 2023-08-06 14:47:56 -04:00
Ersei Saggi f2452103bf style: fix README.md style 2023-08-06 14:44:23 -04:00
Ersei Saggi 076cb1b893 docs: document how to set login disclaimer (#16) 2023-08-06 14:43:09 -04:00
Ersei Saggi defab9c4e8 feat: clarify redirect paths (#84) 2023-08-06 14:32:05 -04:00
Ersei Saggi 2ead846ed8 fix: Remove funding 2023-08-05 18:58:04 -04:00
Ersei Saggi 8b755361b4 feat: 3.5.0.0 2023-08-05 18:51:31 -04:00
Ersei Saggi 340eb2c6c3 feat: add flakes, fix prettier 2023-08-05 18:48:33 -04:00
Ersei Saggi 08d24ba0db fix: try to fix prettier action 2023-08-05 18:26:46 -04:00
Ersei Saggi 0d19a3376f feat: Allow Live TV management 2023-08-05 18:04:28 -04:00
Ersei Saggi 1de72effca fix: Check for Roles being null in configuration for openid 2023-08-05 17:31:06 -04:00
Adam Zvolanek 698fbce81b add authentik redirect uri step (#127)
So users do not have to analyze the code for this line https://github.com/9p4/jellyfin-plugin-sso/blob/c6cac45004a32a7d8cf5863102ef947f792ed021/SSO-Auth/Api/SSOController.cs#L67
2023-07-10 12:35:22 -04:00
Esmond c6cac45004 fix: error in logs for #81 (#100) 2023-01-01 16:21:58 -05:00
Esmond dfd519ae9d fix: null check before trim() (#99)
* null check before trim()

* fix: #97 null check before trim()

* fix: apply null check to all trims
2022-11-06 21:53:55 -05:00
9p4 40120dd127 fix: #83 Trim whitespace in user provided strings (#89) 2022-11-03 22:42:56 -04:00
9p4 d78e8c88a3 fix: change matthewstrasiotto to strazto 2022-11-03 22:42:21 -04:00
9p4 42b567d018 fix: add contact email to conduct 2022-10-21 00:41:35 -04:00
9p4 5bedf010b7 docs: add contributing, fix linting on conduct 2022-10-21 00:39:06 -04:00
9p4 05fabf97fc docs: add code of conduct 2022-10-21 00:29:29 -04:00
9p4 ae063abea4 Create config.yml 2022-09-25 18:19:10 -04:00
9p4 fa46806dc5 feat: allow for HTTPS to be disabled in OpenID discovery (#77)
* feat: allow for HTTPS to be disabled in OpenID discovery (#76)
2022-09-09 10:19:48 -04:00
9p4 98ddb9e352 Merge pull request #74 from 9p4/custom-username-claim
feat: allow configuration of preferred username claim
2022-08-23 21:12:59 -04:00
9p4 46eb00bf43 Merge branch 'main' into custom-username-claim 2022-08-23 19:50:01 -04:00
9p4 5723cd718d ci: change commit linter 2022-08-23 19:48:41 -04:00
9p4 96b14f7a5a ci: run commit name lint on every push and pr 2022-08-23 19:42:56 -04:00
9p4 320551bc18 ci: update hash for commit lint action 2022-08-23 19:39:47 -04:00
Sambhav Saggi 9f8626bdf7 Merge branch 'custom-username-claim' of https://github.com/9p4/jellyfin-plugin-sso into custom-username-claim 2022-08-23 19:38:21 -04:00
Sambhav Saggi d5925fc1de docs: add custom username claim api doc 2022-08-23 19:38:01 -04:00
9p4 facf45058f Merge branch 'main' into custom-username-claim 2022-08-23 19:32:16 -04:00
9p4 f3d0497801 ci: lint commits 2022-08-23 19:31:02 -04:00
Sambhav Saggi 672fce8189 fix: prevent null passwords when fallback is set to default provider 2022-08-23 19:22:02 -04:00
Sambhav Saggi c2f50e1e4c style: run prettier 2022-08-23 18:57:57 -04:00
Sambhav Saggi a90e0359e4 feat: add config page for preferred username config 2022-08-23 18:26:58 -04:00
Sambhav Saggi 332f62d76e feat: allow configuration of preferred username claim (#47) 2022-08-23 18:18:42 -04:00
9p4 47db5cb504 Merge pull request #71 from fredriklindberg/improve-get-request-base
fix: don't include port in redirect URL for 80 and 443
2022-08-23 18:12:19 -04:00
Matthew Strasiotto fe1fdad0b0 Merge pull request #72 from fredriklindberg/make-user-creation-more-robust 2022-08-20 21:07:53 +10:00
Fredrik Lindberg d06f680407 fix: make user creation more robust
Reverse logic of CreateCanonicalLinkAndUserIfNotExist() to first check if the user exists,
then create the canonical link. This allows seamless re-creation of users through SSO after
they have been deleted from Jellyfin but not properly de-linked.
2022-08-18 17:55:41 +02:00
Fredrik Lindberg 976a816d8c fix: don't include port in redirect URL for 80 and 443
Skip port number in redirect URLs during the following conditions
 - The request scheme is http and request port is 80
 - The request scheme is https and request port is 443

This creates redirect urls as https://server/path instead of https://server:443/path
2022-08-18 17:48:37 +02:00
Matthew Strasiotto f7ce40fe5e Merge pull request #69 from matthewstrasiotto/main
ci: 📌 change from shogo82148/actions-upload-release-asset to softprops/action-gh-release
2022-08-18 19:09:45 +10:00
Matthew Strasiotto 8666818dfa ci: 📌 change from shogo82148/actions-upload-release-asset to softprops/action-gh-release 2022-08-18 19:09:12 +10:00
Matthew Strasiotto c5e6bf96f9 Merge pull request #68 from matthewstrasiotto/main
ci: 👷 keep our own build workflow and reference that in publish.yml
2022-08-18 18:42:39 +10:00
Matthew Strasiotto fd59b83a0e ci: 👷 keep our own build workflow and reference that in publish.yml
Squash of
- ci: 👷 keep our own build workflow and reference that in publish.yml
- ci: 📌 pin actions/checkout,setup-dotnet odstr13/jellyfin-plugin-repo-manager to approved version

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

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

Per
https://github.com/9p4/jellyfin-plugin-sso/pull/34#discussion_r885060442
2022-08-14 13:13:06 +10:00
Matthew Strasiotto c5e1644d17 implement api for listing and deleting links 2022-08-14 13:13:06 +10:00
Matthew Strasiotto 7ede38d0ab Implement canonical linking 2022-08-14 13:13:06 +10:00
Matthew Strasitoto fad5a62e07 docs: 📝 document new plugin install mechanism
Resolves #51
2022-07-17 17:23:49 +10:00
Matthew Strasiotto 511e5e4f12 Merge pull request #54 from matthewstrasiotto/ci-fix-nightly-checksums
ci: only run checksum agaisnt zip files
2022-07-01 14:04:32 +10:00
Matthew Strasitoto ad6328c1c5 ci: only run checksum agaisnt zip files 2022-07-01 13:43:18 +10:00
Matthew Strasiotto 7bc7e44768 Merge pull request #53 from matthewstrasiotto/ci-add-nightly-checksum
ci: add checksum to nightly builds
2022-07-01 13:29:28 +10:00
Matthew Strasitoto d94199cb8f ci: add checksum to nightly builds 2022-07-01 13:29:08 +10:00
Matthew Strasiotto a0bfa6a3ee Merge pull request #52 from matthewstrasiotto/ci-ignore-prereleases
ci: ignore prereleases when publishing manifest
2022-07-01 12:03:45 +10:00
Matthew Strasitoto 19bae4de9d ci: ignore prereleases when publishing manifest 2022-07-01 12:01:22 +10:00
Matthew Strasiotto 7e232d7cf9 Merge pull request #50 from matthewstrasiotto/ci-pin-jprm-to-sha
ci: pin kevinjil/jprm-action to specific sha
2022-07-01 11:19:55 +10:00
Matthew Strasiotto eaf7e87747 ci: pin kevinjil/jprm-action to specific sha 2022-07-01 11:19:41 +10:00
Matthew Strasiotto 8df2bd94e8 Merge pull request #48 from matthewstrasiotto/ci-pin-jprm-action
instead of the master branch - for security
2022-06-30 14:26:54 +10:00
Matthew Strasiotto 815d6cbe7e pin version of oddstr13/jellyfin-plugin-repository-manager to a commit instead of branch 2022-06-30 14:19:12 +10:00
Matthew Strasiotto 6a0fcfe711 Merge pull request #46 from matthewstrasiotto/update-ci
Revert "Revert "Add github action for publishing releases""
2022-06-20 09:14:44 +10:00
Matthew Strasiotto f9331665e7 pin version for jellyfin-meta-plugin action 2022-06-18 23:19:34 +10:00
Matthew Strasiotto 299f3f7220 change plugin action repo to kevinjil's tagged version 2022-06-18 23:13:18 +10:00
Matthew Strasiotto 4eec8bb789 Revert "Revert "Add github action for publishing releases""
This reverts commit e3efd97964.
2022-06-18 22:48:44 +10:00
Matthew StrasiottoandSambhav Saggi 55d795b9d8 Document Provider Specific Configuration, Small Readme Update (#31)
* Document provider configuration

* Link to provider documentation in readme, remove warning about gui

* Add Keycloak + cleanup

* Linting

* Fix authelia -> authentik

* lint

* Rename Authentik -> authentik

Co-authored-by: Sambhav Saggi <[email protected]>
2022-06-18 22:33:32 +10:00
9p4 5a8d1da6de Merge pull request #40 from 9p4/revert-39-update-ci
Revert "Add github action for publishing releases"
2022-06-07 11:32:09 -04:00
36 changed files with 2830 additions and 148 deletions
-13
View File
@@ -1,13 +0,0 @@
# 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
@@ -0,0 +1,4 @@
contact_links:
- name: Question
url: https://github.com/9p4/jellyfin-plugin-sso/discussions
about: Please ask and answer questions here.
+39
View File
@@ -0,0 +1,39 @@
on:
workflow_call:
inputs:
dotnet-version:
required: false
default: "6.0.x"
description: "The .NET version to setup for the build"
type: string
dotnet-target:
required: false
default: "net6.0"
description: "The .NET target to set for JPRM"
type: string
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v2
- name: Setup .NET
uses: actions/setup-dotnet@v1
with:
dotnet-version: "${{ inputs.dotnet-version }}"
- name: Build Jellyfin Plugin
uses: oddstr13/jellyfin-plugin-repository-manager@b9e92867a6aa279d611a5ea80cf61f6358838c39
id: jprm
with:
dotnet-target: "${{ inputs.dotnet-target }}"
- name: Upload Artifact
uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # tag=v3
with:
name: build-artifact
retention-days: 30
if-no-files-found: error
path: ${{ steps.jprm.outputs.artifact }}
+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
+5 -2
View File
@@ -12,9 +12,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
# Make sure the actual branch is checked out when running on pull requests
ref: ${{ github.head_ref }}
- name: Prettify code
uses: creyD/prettier_action@v4.2
uses: creyD/prettier_action@v4.3
with:
dry: True
prettier_options: '--check **/*.{js,html,md,css,scss}'
+71
View File
@@ -0,0 +1,71 @@
name: Publish Nightly
on:
push:
branches: [ main ]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup .NET
uses: actions/setup-dotnet@v1
with:
dotnet-version: 6.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build Dotnet
run: dotnet build --no-restore --warnaserror
- name: "Flag as nightly in build.yaml"
uses: fjogeleit/[email protected]
with:
valueFile: 'build.yaml'
propertyPath: 'version'
value: "0.0.0.9000"
commitChange: false
updateFile: true
- name: "JPRM: Build"
id: jrpm
uses: oddstr13/jellyfin-plugin-repository-manager@b9e92867a6aa279d611a5ea80cf61f6358838c39
with:
version: "0.0.0.9000"
verbosity: debug
path: .
dotnet-target: "net6.0"
output: _dist
- name: Prepare GitHub Release assets
run: |-
pushd _dist
for file in ./*.zip; do
md5sum ${file#./} >> ${file%.*}.md5
sha256sum ${file#./} >> ${file%.*}.sha256
done
ls -l
popd
- name: Publish output artifacts
id: publish-assets
uses: softprops/action-gh-release@50195ba7f6f93d1ac97ba8332a178e008ad176aa
with:
prerelease: false
fail_on_unmatched_files: true
tag_name: nightly
files: |
_dist/*
build.yaml
body: |
Nightly build
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Plugin Manifest
uses: Kevinjil/jellyfin-plugin-repo-action@a7832ecc44c6b1a45d531970f6647b8682b005b8
with:
ignorePrereleases: true
githubToken: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
pagesBranch: manifest-release
pagesFile: manifest.json
+53
View File
@@ -0,0 +1,53 @@
name: Publish Release
on:
release:
types:
- released
workflow_dispatch:
jobs:
build:
uses: ./.github/workflows/build.yml
with:
dotnet-version: "6.0.*"
dotnet-target: "net6.0"
upload:
runs-on: ubuntu-latest
needs:
- build
steps:
- name: Download Artifact
uses: actions/[email protected]
with:
name: build-artifact
- name: Prepare GitHub Release assets
run: |-
for file in ./*; do
md5sum ${file#./} >> ${file%.*}.md5
sha256sum ${file#./} >> ${file%.*}.sha256
done
ls -l
- name: Publish output artifacts
id: publish-assets
uses: softprops/action-gh-release@50195ba7f6f93d1ac97ba8332a178e008ad176aa
with:
prerelease: false
fail_on_unmatched_files: true
tag_name: ${{ github.event.release.tag_name }}
files: ./*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
generate:
runs-on: ubuntu-latest
needs:
- upload
steps:
- name: Publish Plugin Manifest
uses: Kevinjil/jellyfin-plugin-repo-action@a7832ecc44c6b1a45d531970f6647b8682b005b8
with:
ignorePrereleases: true
githubToken: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
pagesBranch: manifest-release
pagesFile: manifest.json
+1 -4
View File
@@ -448,7 +448,4 @@ $RECYCLE.BIN/
## Visual Studio Code
##
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.vscode
+1
View File
@@ -0,0 +1 @@
*.min.js
+76
View File
@@ -0,0 +1,76 @@
# 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 [[email protected]](mailto:[email protected]). 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
@@ -0,0 +1,181 @@
<!-- 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 <[email protected]>.
<!-- 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)!
+117 -20
View File
@@ -26,11 +26,13 @@ This plugin allows users to sign in through an SSO provider (such as Google, Mic
https://user-images.githubusercontent.com/17993169/149681516-f93b43f5-fa5c-4c1f-a909-e5414878a864.mp4
Existing users may link new SSO accounts, or remove existing links using self-service at `/SSOViews/linking`.
## Current State:
This is 100% alpha software! PRs are welcome to improve the code.
~~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).
~~There is NO admin configuration! You must use the API to configure the program!~~ Added by [strazto](https://github.com/strazto) in PR [#18](https://github.com/9p4/jellyfin-plugin-sso/pull/18) and [#27](https://github.com/9p4/jellyfin-plugin-sso/pull/27).
**[This is for Jellyfin 10.8](https://github.com/9p4/jellyfin-plugin-sso/issues/3) and only on the Web UI!**
@@ -38,8 +40,13 @@ This is 100% alpha software! PRs are welcome to improve the code.
## Tested Providers
[Find provider specific documentation in providers.md](providers.md)
- Authelia
- authentik
- Keycloak
- OIDC & SAML
- Google OpenID: Works, but usernames are all numeric
- Keycloak OpenID and SAML: Works
## Supported Protocols
@@ -52,28 +59,67 @@ This is my first time writing C# so please take all of the code written here wit
## Installing
Add the package repo [https://repo.ersei.net/jellyfin/manifest.json](https://repo.ersei.net/jellyfin/manifest.json) to your Jellyfin configuration. Then, install the package!
Add the package repo [https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/manifest-release/manifest.json](https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/manifest-release/manifest.json) to your Jellyfin plugin repositories.
## Building
Then, install the plugin from the plugin catalog!
This is built with .NET 6.0. Build with `dotnet publish .` for the debug release in the `SSO-Auth` directory. Copy over the `IdentityModel.OidcClient.dll`, the `IdentityModel.dll` and the `SSO-Auth.dll` files in the `/bin/Debug/net6.0/publish` directory to a new folder in your Jellyfin configuration: `config/plugins/sso`.
See [Contributing](#contributing) for instructions on how to build from source.
## Releasing
### (Fallback) Legacy package repo (Versions <= 3.3.0)
This plugin uses [JPRM](https://github.com/oddstr13/jellyfin-plugin-repository-manager) to build the plugin. Refer to the documentation there to install JPRM.
We have transitioned to a release system that automates distribution, packaging & hosting.
This system is new, and if something goes wrong, you can try using the old package repository as a fallback.
Build the zipped plugin with `jprm --verbosity=debug plugin build .`.
Instead add the **old** package repository: [https://repo.ersei.net/jellyfin/manifest.json](https://repo.ersei.net/jellyfin/manifest.json) to your jellyfin plugin repositories.
### Installing cutting edge/nightly builds
If you're impatient/brave/feel like helping us test things out, you can install the nightly build of the plugin, which is automatically built against the main branch.
The nightly build can be installed from the [main plugin repo](https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/manifest-release/manifest.json), and will always have a version number of `0.0.0.9000`.
The nightly build may have new features unavailable in other builds, but **be warned**, things may change frequently in nightly builds, and things may break, and you could lose data.
## Roadmap
- [x] Admin page
- [ ] Automated tests
- [x] Add role/claims support
- [ ] Use canonical usernames instead of preferred usernames
- [x] Use canonical usernames instead of preferred usernames
- [x] Add user self-service
- [ ] Finalize RBAC access for all user properties
## Examples
### 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
<a
href="https://jellyfin.example.com/sso/OID/start/PROVIDER_NAME"
class="raised cancel block emby-button"
>Sign in with SSO</a
>
```
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/):
@@ -87,9 +133,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/p/PROVIDER_NAME](https://myjellyfin.example.com/sso/OID/p/PROVIDER_NAME)
- Redirect URI: [https://myjellyfin.example.com/sso/SAML/start/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/start/PROVIDER_NAME)
- Base URL: [https://myjellyfin.example.com](https://myjellyfin.example.com)
- Master SAML processing URL: [https://myjellyfin.example.com/sso/SAML/p/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/p/PROVIDER_NAME)
- Master SAML processing URL: [https://myjellyfin.example.com/sso/SAML/start/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/start/PROVIDER_NAME)
Make sure that `clientid` is replaced with the actual client ID and `PROVIDER_NAME` is replaced with the chosen provider name!
@@ -103,7 +149,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/r/PROVIDER_NAME](https://myjellyfin.example.com/sso/OID/r/PROVIDER_NAME)
- Redirect URI: [https://myjellyfin.example.com/sso/OID/redirect/PROVIDER_NAME](https://myjellyfin.example.com/sso/OID/redirect/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!
@@ -116,8 +162,8 @@ The API is all done from a base URL of `/sso/`
#### Flow
- 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/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/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.
@@ -141,6 +187,11 @@ These all require authorization. Append an API key to the end of the request: `c
- `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.
- GET `SAML/Del/PROVIDER_NAME`: This removes a configuration for SAML for a given provider name.
- GET `SAML/Get`: Lists the configurations currently available.
@@ -149,8 +200,8 @@ These all require authorization. Append an API key to the end of the request: `c
#### Flow
- 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.
- 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.
- 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.
@@ -174,11 +225,20 @@ These all require authorization. Append an API key to the end of the request: `c
- `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 containing an additional scope name to include in the OIDC request.
- `oidScopes` : array of strings. Each contains an additional scope name to include in the OIDC request.
- For some OIDC providers (For example, [authelia](https://github.com/9p4/jellyfin-plugin-sso/issues/23#issuecomment-1112237616)), additional scopes may be required in order to validate group membership in role claim.
- Leave empty to only request the default scopes.
- `defaultProvider`: string. The set provider then gets assigned to the user after they have logged in. If it is not set, nothing is changed. With this, a user can login with SSO but is still able to log in via other providers later. See the `Unregister` endpoint.
- `defaultUsernameClaim`: string. The provider will use the claim to create the users' usernames. If not set, it fallbacks to `preferred_username`.
- `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.
- 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.
@@ -189,16 +249,53 @@ These all require authorization. Append an API key to the end of the request: `c
## Limitations
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`).
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!
~~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 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).
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 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 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 works on the web UI**. ~~The user must open the Jellyfin web UI BEFORE using the SSO program to populate some values in the localStorage.~~ Fixed by implementing a comment by [Pfuenzle](https://github.com/Pfuenzle) in [Issue #5](https://github.com/9p4/jellyfin-plugin-sso/issues/5#issuecomment-1041864820).
# Contributing
## Building
This is built with .NET 6.0. Build with `dotnet publish .` for the debug release in the `SSO-Auth` directory. Copy over the `IdentityModel.OidcClient.dll`, the `IdentityModel.dll` and the `SSO-Auth.dll` files in the `/bin/Debug/net6.0/publish` directory to a new folder in your Jellyfin configuration: `config/plugins/sso`.
### VSCode Workflow
An example `.vscode` configuration may be found at [strazto/jellyfin-plugin-sso-vscode](https://github.com/strazto/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
```
## Releasing
This plugin uses [JPRM](https://github.com/oddstr13/jellyfin-plugin-repository-manager) to build the plugin. Refer to the documentation there to install JPRM.
Build the zipped plugin with `jprm --verbosity=debug plugin build .`.
### CI Releases
Anything merged to the main branch will be built and published by our CI system.
Anything tagged/released as a formal Github release will also be built and published by our CI system.
If you wish to use releases from your own fork, refer to
[Installing](#installing), however, you will need to change the url to the
manifest file, `https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/manifest-release/manifest.json`
so that it refers to your fork.
## Credits and Thanks
Much thanks to the [Jellyfin LDAP plugin](https://github.com/jellyfin/jellyfin-plugin-ldapauth) for offering a base for me to start on my plugin.
+46
View File
@@ -0,0 +1,46 @@
// The following code is a derivative work of the code from the Jellyfin project,
// which is licensed GPLv2. This code therefore is also licensed under the terms
// of the GNU Public License, verison 2.
// https://github.com/jellyfin/jellyfin/blob/a60cb280a3d31ba19ffb3a94cf83ef300a7473b7/Jellyfin.Api/Helpers/RequestHelpers.cs#L63-L77
// Use of this relatively small snippet complies with fair use
// See https://www.gnu.org/licenses/gpl-faq.en.html#SourceCodeInDocumentation
// These helpers were not published within a Nuget package, so it was neccessary to re-implement.
using System;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Net;
using Microsoft.AspNetCore.Http;
namespace Jellyfin.Plugin.SSO_Auth.Helpers;
/// <summary>
/// Request Extensions.
/// </summary>
public static class RequestHelpers
{
/// <summary>
/// Checks if the user can update an entry.
/// </summary>
/// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
/// <param name="requestContext">The <see cref="HttpRequest"/>.</param>
/// <param name="userId">The user id.</param>
/// <param name="restrictUserPreferences">Whether to restrict the user preferences.</param>
/// <returns>A <see cref="bool"/> whether the user can update the entry.</returns>
internal static async Task<bool> AssertCanUpdateUser(IAuthorizationContext authContext, HttpRequest requestContext, Guid userId, bool restrictUserPreferences)
{
var auth = await authContext.GetAuthorizationInfo(requestContext).ConfigureAwait(false);
var authenticatedUser = auth.User;
// If they're going to update the record of another user, they must be an administrator
if ((!userId.Equals(auth.UserId) && !authenticatedUser.HasPermission(PermissionKind.IsAdministrator))
|| (restrictUserPreferences && !authenticatedUser.EnableUserPreferenceAccess))
{
return false;
}
return true;
}
}
+552 -87
View File
@@ -2,18 +2,23 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Mime;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using IdentityModel.OidcClient;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.SSO_Auth.Config;
using Jellyfin.Plugin.SSO_Auth.Helpers;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Cryptography;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -29,7 +34,9 @@ public class SSOController : ControllerBase
{
private readonly IUserManager _userManager;
private readonly ISessionManager _sessionManager;
private readonly IAuthorizationContext _authContext;
private readonly ILogger<SSOController> _logger;
private readonly ICryptoProvider _cryptoProvider;
private static readonly IDictionary<string, TimedAuthorizeState> StateManager = new Dictionary<string, TimedAuthorizeState>();
/// <summary>
@@ -37,11 +44,15 @@ public class SSOController : ControllerBase
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{SSOController}"/> interface.</param>
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
/// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
public SSOController(ILogger<SSOController> logger, ISessionManager sessionManager, IUserManager userManager)
/// <param name="cryptoProvider">Instance of the <see cref="ICryptoProvider"/> interface.</param>
public SSOController(ILogger<SSOController> logger, ISessionManager sessionManager, IUserManager userManager, IAuthorizationContext authContext, ICryptoProvider cryptoProvider)
{
_sessionManager = sessionManager;
_userManager = userManager;
_authContext = authContext;
_cryptoProvider = cryptoProvider;
_logger = logger;
_logger.LogInformation("SSO Controller initialized");
}
@@ -54,7 +65,8 @@ public class SSOController : ControllerBase
/// <returns>A webpage that will complete the client-side flow.</returns>
// Actually a GET: https://github.com/IdentityModel/IdentityModel.OidcClient/issues/325
[HttpGet("OID/r/{provider}")]
public ActionResult OidPost(
[HttpGet("OID/redirect/{provider}")]
public async Task<ActionResult> OidPost(
[FromRoute] string provider,
[FromQuery] string state) // Although this is a GET function, this function is called `Post` for consistency with SAML
{
@@ -72,16 +84,18 @@ public class SSOController : ControllerBase
{
var options = new OidcClientOptions
{
Authority = config.OidEndpoint,
ClientId = config.OidClientId,
ClientSecret = config.OidSecret,
RedirectUri = GetRequestBase() + "/sso/OID/r/" + provider,
Authority = config.OidEndpoint?.Trim(),
ClientId = config.OidClientId?.Trim(),
ClientSecret = config.OidSecret?.Trim(),
RedirectUri = GetRequestBase() + $"/sso/OID/{(Request.Path.Value.Contains("/start/", StringComparison.InvariantCultureIgnoreCase) ? "redirect" : "r")}/" + provider,
Scope = string.Join(" ", config.OidScopes.Prepend("openid profile")),
};
options.Policy.Discovery.ValidateEndpoints = false; // For Google and other providers with different endpoints
options.Policy.Discovery.ValidateEndpoints = !config.DoNotValidateEndpoints; // For Google and other providers with different endpoints
options.Policy.Discovery.RequireHttps = !config.DisableHttps;
options.Policy.Discovery.ValidateIssuerName = !config.DoNotValidateIssuerName;
var oidcClient = new OidcClient(options);
var currentState = StateManager[state].State;
var result = oidcClient.ProcessResponseAsync(Request.QueryString.Value, currentState).Result;
var result = await oidcClient.ProcessResponseAsync(Request.QueryString.Value, currentState).ConfigureAwait(false);
if (result.IsError)
{
return ReturnError(StatusCodes.Status400BadRequest, result.Error + " Try logging in again.");
@@ -96,12 +110,15 @@ public class SSOController : ControllerBase
StateManager[state].Folders = new List<string>();
}
StateManager[state].EnableLiveTv = config.EnableLiveTv;
StateManager[state].EnableLiveTvManagement = config.EnableLiveTvManagement;
foreach (var claim in result.User.Claims)
{
if (claim.Type == "preferred_username")
if (claim.Type == (config.DefaultUsernameClaim?.Trim() ?? "preferred_username"))
{
StateManager[state].Username = claim.Value;
if (config.Roles.Length == 0)
if (config.Roles == null || config.Roles.Length == 0)
{
StateManager[state].Valid = true;
}
@@ -110,69 +127,97 @@ public class SSOController : ControllerBase
// Role processing
// The regex matches any "." not preceded by a "\": a.b.c will be split into a, b, and c, but a.b\.c will be split into a, b.c (after processing the escaped dots)
// We have to first process the RoleClaim string
string[] segments = Regex.Split(config.RoleClaim, "(?<!\\\\)\\.");
// Now we make sure that any escaped "."s ("\.") are replaced with "."
for (int i = 0; i < segments.Length; i++)
{
segments[i] = segments[i].Replace("\\.", ".");
}
string[] segments = string.IsNullOrEmpty(config.RoleClaim) ? Array.Empty<string>() : Regex.Split(config.RoleClaim.Trim(), "(?<!\\\\)\\.");
if (claim.Type == segments[0])
if (segments.Any())
{
List<string> roles;
// If we are not using JSON values, just use the raw info from the claim value
if (segments.Length == 1)
// Now we make sure that any escaped "."s ("\.") are replaced with "."
segments = segments.Select(i => i.Replace("\\.", ".")).ToArray();
if (claim.Type == segments[0])
{
roles = new List<string> { claim.Value };
}
else
{
// We recursively traverse through the JSON data for the roles and parse it
var json = JsonConvert.DeserializeObject<IDictionary<string, object>>(claim.Value);
for (int i = 1; i < segments.Length - 1; i++)
List<string> roles;
// If we are not using JSON values, just use the raw info from the claim value
if (segments.Length == 1)
{
var segment = segments[i];
json = (json[segment] as JObject).ToObject<IDictionary<string, object>>();
roles = new List<string> { claim.Value };
}
else
{
// We recursively traverse through the JSON data for the roles and parse it
var json = JsonConvert.DeserializeObject<IDictionary<string, object>>(claim.Value);
for (int i = 1; i < segments.Length - 1; i++)
{
var segment = segments[i];
json = (json[segment] as JObject).ToObject<IDictionary<string, object>>();
}
// The final step is to take the JSON and turn it from a dictionary into a string
roles = (json[segments[^1]] as JArray).ToObject<List<string>>();
}
// The final step is to take the JSON and turn it from a dictionary into a string
roles = (json[segments[^1]] as JArray).ToObject<List<string>>();
}
foreach (string role in roles)
{
// Check if allowed to login based on roles
if (config.Roles.Length != 0)
foreach (string role in roles)
{
foreach (string validRoles in config.Roles)
// Check if allowed to login based on roles
if (config.Roles != null && config.Roles.Any())
{
if (role.Equals(validRoles))
foreach (string validRoles in config.Roles)
{
StateManager[state].Valid = true;
if (role.Equals(validRoles))
{
StateManager[state].Valid = true;
}
}
}
}
// Check if admin based on roles
if (config.AdminRoles.Length != 0)
{
foreach (string validAdminRoles in config.AdminRoles)
// Check if admin based on roles
if (config.AdminRoles != null && config.AdminRoles.Any())
{
if (role.Equals(validAdminRoles))
foreach (string validAdminRoles in config.AdminRoles)
{
StateManager[state].Admin = true;
if (role.Equals(validAdminRoles))
{
StateManager[state].Admin = true;
}
}
}
}
// Get allowed folders from roles
if (config.EnableFolderRoles)
{
foreach (FolderRoleMap folderRoleMap in config.FolderRoleMapping)
// Get allowed folders from roles
if (config.EnableFolderRoles)
{
if (role.Equals(folderRoleMap.Role))
foreach (FolderRoleMap folderRoleMap in config.FolderRoleMapping)
{
StateManager[state].Folders.AddRange(folderRoleMap.Folders);
if (role.Equals(folderRoleMap.Role?.Trim()))
{
StateManager[state].Folders.AddRange(folderRoleMap.Folders);
}
}
}
if (config.EnableLiveTvRoles)
{
// Check if allowed Live TV based on roles
if (config.LiveTvRoles != null && config.LiveTvRoles.Any())
{
foreach (string validLiveTvRoles in config.LiveTvRoles)
{
if (role.Equals(validLiveTvRoles))
{
StateManager[state].EnableLiveTv = true;
}
}
}
// Check if allowed Live TV management based on roles
if (config.LiveTvManagementRoles != null && config.LiveTvManagementRoles.Any())
{
foreach (string validLiveTvManagementRoles in config.LiveTvManagementRoles)
{
if (role.Equals(validLiveTvManagementRoles))
{
StateManager[state].EnableLiveTvManagement = true;
}
}
}
}
}
@@ -180,7 +225,7 @@ public class SSOController : ControllerBase
}
}
// If the provider doesn't support preferred_username, then use sub
// If the provider doesn't support the preferred username claim, then use the sub claim
if (!StateManager[state].Valid)
{
foreach (var claim in result.User.Claims)
@@ -196,9 +241,12 @@ public class SSOController : ControllerBase
}
}
bool isLinking = StateManager[state].IsLinking;
if (StateManager[state].Valid)
{
return Content(WebResponse.Generator(data: state, provider: provider, baseUrl: GetRequestBase(), mode: "OID"), MediaTypeNames.Text.Html);
_logger.LogInformation($"Is request linking: {isLinking}");
return Content(WebResponse.Generator(data: state, provider: provider, baseUrl: GetRequestBase(), mode: "OID", isLinking: isLinking), MediaTypeNames.Text.Html);
}
else
{
@@ -220,9 +268,11 @@ public class SSOController : ControllerBase
/// Initiates the login flow for OpenID. This redirects the user to the auth provider.
/// </summary>
/// <param name="provider">The name of the provider.</param>
/// <param name="isLinking">Whether or not this request is to link accounts (Rather than authenticate).</param>
/// <returns>An asynchronous result for the authentication.</returns>
[HttpGet("OID/p/{provider}")]
public async Task<ActionResult> OidChallenge(string provider)
[HttpGet("OID/start/{provider}")]
public async Task<ActionResult> OidChallenge(string provider, [FromQuery] bool isLinking = false)
{
Invalidate();
OidConfig config;
@@ -239,16 +289,19 @@ public class SSOController : ControllerBase
{
var options = new OidcClientOptions
{
Authority = config.OidEndpoint,
ClientId = config.OidClientId,
ClientSecret = config.OidSecret,
RedirectUri = GetRequestBase() + "/sso/OID/r/" + provider,
Authority = config.OidEndpoint?.Trim(),
ClientId = config.OidClientId?.Trim(),
ClientSecret = config.OidSecret?.Trim(),
RedirectUri = GetRequestBase() + $"/sso/OID/{(Request.Path.Value.Contains("/start/", StringComparison.InvariantCultureIgnoreCase) ? "redirect" : "r")}/" + provider,
Scope = string.Join(" ", config.OidScopes.Prepend("openid profile")),
};
options.Policy.Discovery.ValidateEndpoints = false; // For Google and other providers with different endpoints
var oidcClient = new OidcClient(options);
var state = await oidcClient.PrepareLoginAsync().ConfigureAwait(false);
StateManager.Add(state.State, new TimedAuthorizeState(state, DateTime.Now));
// Track whether this is a linking request or not.
StateManager[state.State].IsLinking = isLinking;
return Redirect(state.StartUrl);
}
@@ -293,6 +346,26 @@ public class SSOController : ControllerBase
return Ok(SSOPlugin.Instance.Configuration.OidConfigs);
}
/// <summary>
/// Lists the OpenID providers names only.
/// </summary>
/// <returns>The list of OpenID configurations.</returns>
[HttpGet("OID/GetNames")]
public ActionResult OidProviderNames()
{
return Ok(SSOPlugin.Instance.Configuration.OidConfigs.Keys);
}
/// <summary>
/// Lists the SAML providers names only.
/// </summary>
/// <returns>The list of OpenID configurations.</returns>
[HttpGet("SAML/GetNames")]
public ActionResult SamlProviderNames()
{
return Ok(SSOPlugin.Instance.Configuration.SamlConfigs.Keys);
}
/// <summary>
/// This is a debug endpoint to list all running OpenID flows. Requires administrator privileges.
/// </summary>
@@ -331,7 +404,9 @@ public class SSOController : ControllerBase
{
if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid)
{
var authenticationResult = await Authenticate(kvp.Value.Username, kvp.Value.Admin, config.EnableAuthorization, config.EnableAllFolders, kvp.Value.Folders.ToArray(), response, config.DefaultProvider)
Guid userId = await CreateCanonicalLinkAndUserIfNotExist("oid", provider, kvp.Value.Username);
var authenticationResult = await Authenticate(userId, kvp.Value.Admin, config.EnableAuthorization, config.EnableAllFolders, kvp.Value.Folders.ToArray(), kvp.Value.EnableLiveTv, kvp.Value.EnableLiveTvManagement, response, config.DefaultProvider?.Trim())
.ConfigureAwait(false);
return Ok(authenticationResult);
}
@@ -345,9 +420,14 @@ public class SSOController : ControllerBase
/// This is the callback for the SAML flow. This creates a webpage to complete auth.
/// </summary>
/// <param name="provider">The provider that is calling back.</param>
/// <param name="relayState">
/// RelayState given in the original saml request. If it is equal to "linking",
/// We consider this to be a linking request.
/// </param>
/// <returns>A webpage that will complete the client-side flow.</returns>
[HttpPost("SAML/p/{provider}")]
public ActionResult SamlPost(string provider)
[HttpPost("SAML/post/{provider}")]
public ActionResult SamlPost(string provider, [FromQuery] string relayState = null)
{
SamlConfig config;
try
@@ -359,13 +439,21 @@ public class SSOController : ControllerBase
return BadRequest("No matching provider found");
}
bool isLinking = relayState == "linking";
_logger.LogInformation(
$"SAML request has relayState of {relayState}");
if (config.Enabled)
{
var samlResponse = new Response(config.SamlCertificate, Request.Form["SAMLResponse"]);
bool valid = false;
// If no roles are configured, don't use RBAC
if (config.Roles.Length == 0)
{
return Content(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML"), MediaTypeNames.Text.Html);
valid = true;
}
// Check if user is allowed to log in based on roles
@@ -375,11 +463,23 @@ public class SSOController : ControllerBase
{
if (allowedRole.Equals(role))
{
return Content(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML"), MediaTypeNames.Text.Html);
valid = true;
}
}
}
if (valid)
{
return Content(
WebResponse.Generator(
data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)),
provider: provider,
baseUrl: GetRequestBase(),
mode: "SAML",
isLinking: isLinking),
MediaTypeNames.Text.Html);
}
_logger.LogWarning(
"SAML user: {UserId} has insufficient roles: {@Roles}. Expected any one of: {@ExpectedRoles}",
samlResponse.GetNameID(),
@@ -395,9 +495,11 @@ public class SSOController : ControllerBase
/// Initializes the SAML flow. This will redirect the user to the SAML provider.
/// </summary>
/// <param name="provider">The provider to being the flow with.</param>
/// <param name="isLinking">Whether this flow intends to link an account, or initiate auth.</param>
/// <returns>A redirect to the SAML provider's auth page.</returns>
[HttpGet("SAML/p/{provider}")]
public RedirectResult SamlChallenge(string provider)
[HttpGet("SAML/start/{provider}")]
public RedirectResult SamlChallenge(string provider, [FromQuery] bool isLinking = false)
{
SamlConfig config;
try
@@ -411,11 +513,17 @@ public class SSOController : ControllerBase
if (config.Enabled)
{
var request = new AuthRequest(
config.SamlClientId,
GetRequestBase() + "/sso/SAML/p/" + provider);
string relayState = null;
if (isLinking)
{
relayState = "linking";
}
return Redirect(request.GetRedirectUrl(config.SamlEndpoint));
var request = new AuthRequest(
config.SamlClientId.Trim(),
GetRequestBase() + $"/sso/SAML/{(Request.Path.Value.Contains("/start/", StringComparison.InvariantCultureIgnoreCase) ? "post" : "p")}/" + provider);
return Redirect(request.GetRedirectUrl(config.SamlEndpoint.Trim(), relayState));
}
throw new ArgumentException("Provider does not exist");
@@ -487,6 +595,8 @@ public class SSOController : ControllerBase
if (config.Enabled)
{
bool isAdmin = false;
bool liveTv = config.EnableLiveTv;
bool liveTvManagement = config.EnableLiveTvManagement;
var samlResponse = new Response(config.SamlCertificate, response.Data);
List<string> folders;
if (!config.EnableFolderRoles)
@@ -500,11 +610,14 @@ public class SSOController : ControllerBase
foreach (string role in samlResponse.GetCustomAttributes("Role"))
{
foreach (string allowedRole in config.AdminRoles)
if (config.AdminRoles != null)
{
if (allowedRole.Equals(role))
foreach (string allowedRole in config.AdminRoles)
{
isAdmin = true;
if (allowedRole.Equals(role))
{
isAdmin = true;
}
}
}
@@ -518,9 +631,36 @@ public class SSOController : ControllerBase
}
}
}
if (config.EnableLiveTvRoles)
{
if (config.LiveTvRoles != null)
{
foreach (string allowedLiveTvRole in config.LiveTvRoles)
{
if (allowedLiveTvRole.Equals(role))
{
liveTv = true;
}
}
}
if (config.LiveTvManagementRoles != null)
{
foreach (string allowedLiveTvManagementRole in config.LiveTvManagementRoles)
{
if (allowedLiveTvManagementRole.Equals(role))
{
liveTvManagement = true;
}
}
}
}
}
var authenticationResult = await Authenticate(samlResponse.GetNameID(), isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), response, config.DefaultProvider)
Guid userId = await CreateCanonicalLinkAndUserIfNotExist("saml", provider, samlResponse.GetNameID());
var authenticationResult = await Authenticate(userId, isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), liveTv, liveTvManagement, response, config.DefaultProvider.Trim())
.ConfigureAwait(false);
return Ok(authenticationResult);
}
@@ -544,28 +684,319 @@ public class SSOController : ControllerBase
return Ok();
}
private SerializableDictionary<string, Guid> GetCanonicalLinks(string mode, string provider)
{
SerializableDictionary<string, Guid> links = null;
switch (mode.ToLower())
{
case "saml":
links = SSOPlugin.Instance.Configuration.SamlConfigs[provider].CanonicalLinks;
break;
case "oid":
links = SSOPlugin.Instance.Configuration.OidConfigs[provider].CanonicalLinks;
break;
default:
throw new ArgumentException($"{mode} is not a valid choice between 'saml' and 'oid'");
}
if (links == null)
{
links = new SerializableDictionary<string, Guid>();
}
return links;
}
private async Task<Guid> CreateCanonicalLinkAndUserIfNotExist(string mode, string provider, string canonicalName)
{
User user = null;
user = _userManager.GetUserByName(canonicalName);
if (user == null)
{
_logger.LogInformation($"SSO user {canonicalName} doesn't exist, creating...");
user = await _userManager.CreateUserAsync(canonicalName).ConfigureAwait(false);
user.AuthenticationProviderId = GetType().FullName;
// https://jonathancrozier.com/blog/how-to-generate-a-cryptographically-secure-random-string-in-dot-net-with-c-sharp
user.Password = _cryptoProvider.CreatePasswordHash(Convert.ToBase64String(RandomNumberGenerator.GetBytes(64))).ToString();
// Make sure there aren't any trailing existing links
var links = GetCanonicalLinks(mode, provider);
links.Remove(canonicalName);
UpdateCanonicalLinkConfig(links, mode, provider);
}
Guid userId = Guid.Empty;
try
{
userId = GetCanonicalLink(mode, provider, canonicalName);
}
catch (KeyNotFoundException)
{
userId = Guid.Empty;
}
if (userId == Guid.Empty)
{
_logger.LogInformation("SSO user link doesn't exist, creating...");
userId = user.Id;
CreateCanonicalLink(mode, provider, userId, canonicalName);
}
return userId;
}
private Guid GetCanonicalLink(string mode, string provider, string canonicalName)
{
SerializableDictionary<string, Guid> links = null;
Guid userId = Guid.Empty;
links = GetCanonicalLinks(mode, provider);
userId = links[canonicalName];
return userId;
}
/// <summary>
/// Create a canonical link for a given user. Must be performed by the user being changed, or admin.
/// </summary>
/// <param name="mode">The mode of the function; SAML or OID.</param>
/// <param name="provider">The name of the provider to link to a jellyfin account.</param>
/// <param name="jellyfinUserId">The user ID within jellyfin to link to the provider.</param>
/// <param name="authResponse">The client information to authenticate the user with.</param>
/// <returns>Whether this API endpoint succeeded.</returns>
[Authorize(Policy = "DefaultAuthorization")]
[HttpPost("{mode}/Link/{provider}/{jellyfinUserId}")]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult> AddCanonicalLink([FromRoute] string mode, [FromRoute] string provider, [FromRoute] Guid jellyfinUserId, [FromBody] AuthResponse authResponse)
{
if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, jellyfinUserId, true).ConfigureAwait(false))
{
return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to link SSO providers.");
}
switch (mode.ToLower())
{
case "saml":
return SamlLink(provider, jellyfinUserId, authResponse);
case "oid":
return OidLink(provider, jellyfinUserId, authResponse);
default:
throw new ArgumentException($"{mode} is not a valid choice between 'saml' and 'oid'");
}
}
/// <summary>
/// Unregisters a given mapping from id within provider to user.
/// </summary>
/// <param name="mode">The mode of the function; SAML or OID.</param>
/// <param name="provider">The name of the provider from which the link should be removed.</param>
/// <param name="jellyfinUserId">The user ID within jellyfin to unlink from the provider.</param>
/// <param name="canonicalName">The user ID within jellyfin to unlink.</param>
/// <returns>Whether this API endpoint succeeded.</returns>
[Authorize(Policy = "DefaultAuthorization")]
[HttpDelete("{mode}/Link/{provider}/{jellyfinUserId}/{canonicalName}")]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult> DeleteCanonicalLink([FromRoute] string mode, [FromRoute] string provider, [FromRoute] Guid jellyfinUserId, [FromRoute] string canonicalName)
{
if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, jellyfinUserId, true).ConfigureAwait(false))
{
return StatusCode(StatusCodes.Status403Forbidden, "Current user is not allowed to unlink SSO providers for user ID.");
}
Guid linkedId = GetCanonicalLink(mode, provider, canonicalName);
if (linkedId != jellyfinUserId)
{
return StatusCode(StatusCodes.Status409Conflict, "jellyfin UID does not match id registered to that canonical name.");
}
var links = GetCanonicalLinks(mode, provider);
links.Remove(canonicalName);
return UpdateCanonicalLinkConfig(links, mode, provider);
}
/// <summary>
/// Gets all the saml links for a user.
/// </summary>
/// <param name="jellyfinUserId">The user ID within jellyfin for which to return the links.</param>
/// <returns>A dictionary of provider : link mappings.</returns>
[Authorize(Policy = "DefaultAuthorization")]
[HttpGet("saml/links/{jellyfinUserId}")]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult<SerializableDictionary<string, IEnumerable<string>>>> GetSamlLinksByUser(Guid jellyfinUserId)
{
if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, jellyfinUserId, true).ConfigureAwait(false))
{
return StatusCode(StatusCodes.Status403Forbidden, "Non-admin is not allowed to query other user's mappings.");
}
var mappings = new SerializableDictionary<string, IEnumerable<string>>();
var providerList = SSOPlugin.Instance.Configuration.SamlConfigs;
foreach (var providerName in providerList.Keys)
{
var canonLinks = providerList[providerName].CanonicalLinks;
var canonKeys = from link in canonLinks where link.Value == jellyfinUserId select link.Key;
mappings[providerName] = canonKeys;
}
return mappings;
}
/// <summary>
/// Gets all the oid links for a user.
/// </summary>
/// <param name="jellyfinUserId">The user ID within jellyfin for which to return the links.</param>
/// <returns>A dictionary of provider : link mappings.</returns>
[Authorize(Policy = "DefaultAuthorization")]
[HttpGet("oid/links/{jellyfinUserId}")]
[Produces(MediaTypeNames.Application.Json)]
public async Task<ActionResult<SerializableDictionary<string, IEnumerable<string>>>> GetOidLinksByUser(Guid jellyfinUserId)
{
if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, jellyfinUserId, true).ConfigureAwait(false))
{
return StatusCode(StatusCodes.Status403Forbidden, "Non-admin is not allowed to query other user's mappings.");
}
var mappings = new SerializableDictionary<string, IEnumerable<string>>();
var providerList = SSOPlugin.Instance.Configuration.OidConfigs;
foreach (var providerName in providerList.Keys)
{
var canonLinks = providerList[providerName].CanonicalLinks;
var canonKeys = from link in canonLinks where link.Value == jellyfinUserId select link.Key;
mappings[providerName] = canonKeys;
}
return mappings;
}
/// <summary>
/// Validate a saml link request and create the link if it is valid.
/// </summary>
/// <param name="provider">The provider to authenticate against.</param>
/// <param name="jellyfinUserId">
/// The ID of the account to be linked to the provider.
/// Must be performed by this user, or an admin.
/// </param>
/// <param name="response">The data passed to the client to ensure it is the right one.</param>
/// <returns>JSON for the client to populate information with.</returns>
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
private ActionResult SamlLink(string provider, Guid jellyfinUserId, AuthResponse response)
{
SamlConfig config;
try
{
config = SSOPlugin.Instance.Configuration.SamlConfigs[provider];
}
catch (KeyNotFoundException)
{
return BadRequest("No matching provider found");
}
var samlResponse = new Response(config.SamlCertificate, response.Data);
// TODO: Does saml response require further validation?
string providerUserId = samlResponse.GetNameID();
return CreateCanonicalLink("saml", provider, jellyfinUserId, providerUserId);
}
/// <summary>
/// Validate an OIDC link request and create the link if it is valid.
/// </summary>
/// <param name="provider">The provider to authenticate against.</param>
/// <param name="jellyfinUserId">
/// The ID of the account to be linked to the provider.
/// Must be performed by this user, or an admin.
/// </param>
/// <param name="response">The data passed to the client to ensure it is the right one.</param>
/// <returns>JSON for the client to populate information with.</returns>
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
private ActionResult OidLink(string provider, Guid jellyfinUserId, AuthResponse response)
{
OidConfig config;
try
{
config = SSOPlugin.Instance.Configuration.OidConfigs[provider];
}
catch (KeyNotFoundException)
{
return BadRequest("No matching provider found");
}
foreach (var kvp in StateManager)
{
if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid)
{
string providerUserId = kvp.Value.Username;
return CreateCanonicalLink("oid", provider, jellyfinUserId, providerUserId);
}
}
return Problem("Something went wrong!");
}
private ActionResult CreateCanonicalLink(string mode, string provider, [FromRoute] Guid jellyfinUserId, string providerUserId)
{
SerializableDictionary<string, Guid> links = null;
try
{
links = GetCanonicalLinks(mode, provider);
}
catch (KeyNotFoundException)
{
return BadRequest("No matching provider found");
}
links[providerUserId] = jellyfinUserId;
UpdateCanonicalLinkConfig(links, mode, provider);
return NoContent();
}
private OkResult UpdateCanonicalLinkConfig(SerializableDictionary<string, Guid> links, string mode, string provider)
{
var configuration = SSOPlugin.Instance.Configuration;
switch (mode.ToLower())
{
case "saml":
configuration.SamlConfigs[provider].CanonicalLinks = links;
break;
case "oid":
configuration.OidConfigs[provider].CanonicalLinks = links;
break;
default:
throw new ArgumentException($"{mode} is not a valid choice between 'saml' and 'oid'");
}
SSOPlugin.Instance.UpdateConfiguration(configuration);
return Ok();
}
/// <summary>
/// Authenticates the user with the given information.
/// </summary>
/// <param name="username">The username of the user to authenticate.</param>
/// <param name="userId">The user id of the user to authenticate.</param>
/// <param name="isAdmin">Determines whether this user is an administrator.</param>
/// <param name="enableAuthorization">Determines whether RBAC is used for this user.</param>
/// <param name="enableAllFolders">Determines whether all folders are enabled.</param>
/// <param name="enabledFolders">Determines which folders should be enabled for this client.</param>
/// <param name="enableLiveTv">Determines whether live TV access is allowed for this user.</param>
/// <param name="enableLiveTvAdmin">Determines whether live TV can be managed by this user.</param>
/// <param name="authResponse">The client information to authenticate the user with.</param>
/// <param name="defaultProvider">The default provider of the user to be set after logging in.</param>
private async Task<AuthenticationResult> Authenticate(string username, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse, string defaultProvider)
private async Task<AuthenticationResult> Authenticate(Guid userId, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, bool enableLiveTv, bool enableLiveTvAdmin, AuthResponse authResponse, string defaultProvider)
{
User user = null;
user = _userManager.GetUserByName(username);
if (user == null)
{
_logger.LogInformation("SSO user doesn't exist, creating...");
user = await _userManager.CreateUserAsync(username).ConfigureAwait(false);
user.AuthenticationProviderId = GetType().FullName;
}
User user = _userManager.GetUserById(userId);
if (enableAuthorization)
{
user.SetPermission(PermissionKind.IsAdministrator, isAdmin);
@@ -576,6 +1007,9 @@ public class SSOController : ControllerBase
}
}
user.SetPermission(PermissionKind.EnableLiveTvAccess, enableLiveTv);
user.SetPermission(PermissionKind.EnableLiveTvManagement, enableLiveTvAdmin);
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
var authRequest = new AuthenticationRequest();
@@ -610,7 +1044,19 @@ public class SSOController : ControllerBase
private string GetRequestBase()
{
return Request.Scheme + "://" + Request.Host + Request.PathBase;
int requestPort = Request.Host.Port ?? -1;
if ((requestPort == 80 && string.Equals(Request.Scheme, "http", StringComparison.OrdinalIgnoreCase)) || (requestPort == 443 && string.Equals(Request.Scheme, "https", StringComparison.OrdinalIgnoreCase)))
{
requestPort = -1;
}
return new UriBuilder
{
Scheme = Request.Scheme,
Host = Request.Host.Host,
Port = requestPort,
Path = Request.PathBase
}.ToString().TrimEnd('/');
}
private ContentResult ReturnError(int code, string message)
@@ -670,6 +1116,9 @@ public class TimedAuthorizeState
Created = created;
Valid = false;
Admin = false;
IsLinking = false;
EnableLiveTv = false;
EnableLiveTvManagement = false;
}
/// <summary>
@@ -697,8 +1146,24 @@ public class TimedAuthorizeState
/// </summary>
public bool Admin { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the state is
/// tied to a linking flow (instead of a login flow).
/// </summary>
public bool IsLinking { get; set; }
/// <summary>
/// Gets or sets the folders the user is allowed access to.
/// </summary>
public List<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; }
}
+86
View File
@@ -0,0 +1,86 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model;
using MediaBrowser.Model.Plugins;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SSO_Auth.Views;
/// <summary>
/// The sso views controller.
/// </summary>
[ApiController]
[Route("[controller]")]
public class SSOViewsController : ControllerBase
{
private readonly IUserManager _userManager;
private readonly ISessionManager _sessionManager;
private readonly IAuthorizationContext _authContext;
private readonly ILogger<SSOViewsController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SSOViewsController"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{SSOViewsController}"/> interface.</param>
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
/// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
public SSOViewsController(ILogger<SSOViewsController> logger, ISessionManager sessionManager, IUserManager userManager, IAuthorizationContext authContext)
{
_sessionManager = sessionManager;
_userManager = userManager;
_authContext = authContext;
_logger = logger;
_logger.LogInformation("SSO Views Controller initialized");
}
private ActionResult ServeView(string viewName)
{
IEnumerable<PluginPageInfo> pages = null;
if (SSOPlugin.Instance == null)
{
return BadRequest("No plugin instance found");
}
pages = SSOPlugin.Instance.GetViews();
if (pages == null)
{
return NotFound("Pages is null or empty");
}
var view = pages.FirstOrDefault(pageInfo => pageInfo.Name == viewName, null);
if (view == null)
{
return NotFound("No matching view found");
}
#nullable enable
Stream? stream = SSOPlugin.Instance.GetType().Assembly.GetManifestResourceStream(view.EmbeddedResourcePath);
if (stream == null)
{
_logger.LogError("Failed to get resource {Resource}", view.EmbeddedResourcePath);
return NotFound();
}
#nullable disable
return File(stream, MimeTypes.GetMimeType(view.EmbeddedResourcePath));
}
/// <summary>
/// Gets a html view.
/// </summary>
/// <param name="viewName">The name of the view / asset to fetch.</param>
/// <returns>The html view with the specified name.</returns>
[HttpGet("{viewName}")]
public ActionResult GetView([FromRoute] string viewName)
{
return ServeView(viewName);
}
}
+111
View File
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Xml.Serialization;
@@ -36,6 +37,8 @@ public class PluginConfiguration : MediaBrowser.Model.Plugins.BasePluginConfigur
[XmlRoot("PluginConfiguration")]
public class SamlConfig
{
private SerializableDictionary<string, Guid> _canonicalLinks;
/// <summary>
/// Gets or sets the SAML information endpoint.
/// </summary>
@@ -86,6 +89,31 @@ 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>
@@ -97,6 +125,24 @@ public class SamlConfig
/// Gets or sets the default provider the user after logging in with SSO.
/// </summary>
public string DefaultProvider { get; set; }
/// <summary>
/// Gets or sets a mapping of canonical names from the provider to jellyfin user ids.
/// </summary>
[XmlElement("CanonicalLinks")]
public SerializableDictionary<string, Guid> CanonicalLinks
{
get
{
if (_canonicalLinks == null)
{
return new SerializableDictionary<string, Guid>();
}
return _canonicalLinks;
}
set => _canonicalLinks = value;
}
}
/// <summary>
@@ -105,6 +151,8 @@ public class SamlConfig
[XmlRoot("PluginConfiguration")]
public class OidConfig
{
private SerializableDictionary<string, Guid> _canonicalLinks;
/// <summary>
/// Gets or sets the OpenID well-known information endpoint.
/// </summary>
@@ -155,6 +203,31 @@ 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>
@@ -176,6 +249,44 @@ public class OidConfig
/// Gets or sets the default provider the user after logging in with SSO.
/// </summary>
public string DefaultProvider { get; set; }
/// <summary>
/// Gets or sets a mapping of canonical names from the provider to jellyfin user ids.
/// </summary>
[XmlElement("CanonicalLinks")]
public SerializableDictionary<string, Guid> CanonicalLinks
{
get
{
if (_canonicalLinks == null)
{
return new SerializableDictionary<string, Guid>();
}
return _canonicalLinks;
}
set => _canonicalLinks = value;
}
/// <summary>
/// Gets or sets the default username claim when creating new accounts.
/// </summary>
public string DefaultUsernameClaim { get; set; }
/// <summary>
/// Gets or sets a value indicating whether HTTPS in the discovery endpoint is required.
/// </summary>
public bool DisableHttps { 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>
+20 -17
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)) {
@@ -315,7 +315,7 @@ const ssoConfigurationPage = {
form_elements.text_list_fields.forEach((id) => {
current_config[id] = ssoConfigurationPage.parseTextList(
page.querySelector("#" + id)
page.querySelector("#" + id),
);
});
@@ -334,7 +334,7 @@ const ssoConfigurationPage = {
ApiClient.updatePluginConfiguration(
ssoConfigurationPage.pluginUniqueId,
config
config,
).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
ssoConfigurationPage.loadConfiguration(page);
@@ -397,4 +397,7 @@ export default function (view) {
console.log(current_mappings);
ssoConfigurationPage.populateRoleMappings(current_mappings, container);
});
view.querySelector("#sso-self-service-link").href =
ApiClient.getUrl("/SSOViews/linking");
}
+184 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<title>SSO</title>
@@ -45,6 +45,23 @@
>roadmap
</a>
for more information.
<br />
To allow users to manage their own SSO accounts, including linking
SSO providers, and removing existing links, they need to visit
<a
is="emby-linkbutton"
id="sso-self-service-link"
class="button-link"
>the self service page </a
>. <br />
You can use
<a
is="emby-linkbutton"
href="https://jellyfin.org/docs/general/clients/web-config.html#custom-menu-links"
class="button-link"
>custom menu links
</a>
to accomplish this.
</p>
<form id="sso-load-config" class="esqConfigurationForm">
@@ -341,6 +358,109 @@
</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
@@ -420,6 +540,69 @@
</div>
</div>
<div class="inputContainer">
<label
class="inputLabel inputLabelUnfocused"
for="DefaultUsernameClaim"
>Set default username claim:</label
>
<input
is="emby-input"
id="DefaultUsernameClaim"
type="text"
class="sso-text"
/>
<div class="fieldDescription">
The default username claim to use from OpenID by default. If
it is not set, it defaults to
<code>preferred_username</code>.
</div>
</div>
<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 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>
<button
id="SaveProvider"
is="emby-button"
+99
View File
@@ -0,0 +1,99 @@
<!doctype html>
<html lang="en">
<head>
<!-- Polyfill styles that are missing when serving without dashboard -->
<link rel="stylesheet" href="emby-restyle.css" />
<link id="theme-style" rel="stylesheet" />
<script defer>
import("./ApiClient.js").then((clientModule) => {
import("./linking.js").then((renderer) => {
const view = document.querySelector("#sso-config-page");
document.querySelector("#theme-style").href = ApiClient.getUrl(
"/web/themes/dark/theme.css",
);
const homeButton = document.querySelector("a.emby-button.home");
homeButton.href = ApiClient.serverAddress();
homeButton.style.display = "";
renderer.default(view);
});
});
</script>
<title>SSO Linking</title>
</head>
<body class="force-scroll dashboardDocument mouseIdle">
<div
id="sso-config-page"
data-role="page"
class="page type-interior pluginConfigurationPage esqConfigurationPage"
data-controller="__plugin/SSO-Auth-linking.js"
>
<div data-role="content">
<div class="content-primary">
<div class="sectionTitleContainer flex align-items-center">
<a class="raised emby-button home" style="display: none">
<span class="material-icons home" aria-hidden="true"></span
><span>Home</span>
</a>
<h2 class="sectionTitle">SSO Linking:</h2>
<a
is="emby-button"
class="raised button-alt headerHelpButton"
target="_blank"
href="https://github.com/9p4/jellyfin-plugin-sso"
>${Help}</a
>
</div>
<p>
Use the
<span class="fab" aria-hidden="true">
<span class="material-icons add" aria-hidden="true"></span>
</span>
button to create a new link for the given provider.
<br />
You may remove existing links by selecting them and pressing the
"Delete" button.
</p>
<p>
See the
<a
is="emby-linkbutton"
href="https://github.com/9p4/jellyfin-plugin-sso"
class="button-link"
>help page</a
>
and
<a
is="emby-linkbutton"
href="https://github.com/9p4/jellyfin-plugin-sso/projects/1"
class="button-link"
>roadmap
</a>
for more information.
</p>
<label class="checkbox-wrapper">
<input is="emby-checkbox" id="enable-delete" type="checkbox" />
<span class="checkbox-label">Enable "Delete" button.</span>
</label>
<h1 class="sectionTitle">Link a provider</h1>
<div class="verticalSection" title="Link a provider">
<h2 class="sectionTitle">SAML</h2>
<div id="sso-provider-list-saml" data-id="saml"></div>
<h2 class="sectionTitle">OID</h2>
<div id="sso-provider-list-oid" data-id="oid"></div>
</div>
<button
id="btn-delete-selected-links"
type="button"
class="button-delete raised emby-button"
disabled="true"
>
<span class="material-icons delete" aria-hidden="true"></span>
<span>Delete Selected</span>
</button>
</div>
</div>
</div>
</body>
</html>
+186
View File
@@ -0,0 +1,186 @@
const ssoConfigLinking = {
pluginUniqueId: "505ce9d1-d916-42fa-86ca-673ef241d7df",
loadProviders: (view) => {
const provider_list_id = "sso-provider-list";
const provider_list_saml_id = `${provider_list_id}-saml`;
const provider_list_oid_id = `${provider_list_id}-oid`;
const provider_list_saml = view.querySelector(`#${provider_list_saml_id}`);
const provider_list_oid = view.querySelector(`#${provider_list_oid_id}`);
provider_list_saml.innerHTML = "";
provider_list_oid.innerHTML = "";
fetch(new Request(ApiClient.getUrl("sso/OID/GetNames"))).then((resp) => {
resp.json().then((config_names) => {
ssoConfigLinking.loadProviderList(
provider_list_oid,
config_names,
"oid",
);
});
});
fetch(new Request(ApiClient.getUrl("sso/SAML/GetNames"))).then((resp) => {
resp.json().then((config_names) => {
ssoConfigLinking.loadProviderList(
provider_list_saml,
config_names,
"saml",
);
});
});
},
loadProviderList: (container, providers, provider_mode) => {
providers.forEach((provider_name) => {
var provider_config = document.createElement("div");
provider_config.classList.add("sso-provider-links-container");
provider_config.setAttribute("data-id", provider_name);
provider_config.innerHTML = `
<label
class="inputLabel inputLabelUnfocused sso-provider-link-title"
>${provider_name}
</label>
<a
class="fab emby-button sso-provider-add-link"
>
<span class="material-icons add" aria-hidden="true"></span>
</a>
<div
class="sso-provider-existing-links-container"
data-provider="${provider_name}"
></div>
`;
var add_provider = provider_config.querySelector(
".sso-provider-add-link",
);
//const provider_name_css = ssoConfigLinking.safeCSSId(provider_name);
//provider_link.id = "sso-provider-" + provider_name_css;
//provider_link.classList.add("sso-provider-" + provider_name_css);
add_provider.classList.add("sso-provider");
add_provider.href = ApiClient.getUrl(
`/SSO/${provider_mode}/p/${provider_name}?isLinking=true`,
);
container.appendChild(provider_config);
});
const currentUserId = ApiClient.getCurrentUserId();
if (currentUserId) {
ApiClient.fetch(
{
type: "GET",
url: ApiClient.getUrl(`sso/${provider_mode}/links/${currentUserId}`),
},
true,
).then((resp) => {
resp.json().then((provider_map) => {
console.log({ provider_map, currentUserId });
Object.keys(provider_map).forEach((provider_name) => {
const provider_container = container.querySelector(
`.sso-provider-existing-links-container[data-provider="${provider_name}"]`,
);
ssoConfigLinking.populateExistingLinks(
provider_container,
provider_mode,
provider_name,
provider_map[provider_name],
);
});
});
});
}
},
populateExistingLinks: (
container,
provider_mode,
provider_name,
canonical_names,
) => {
container
.querySelectorAll(".sso-provider-link-checkbox-wrapper")
.forEach((e) => e.remove());
const checkboxes = canonical_names.map((canonical_name) => {
var out = document.createElement("label");
out.classList.add("sso-provider-link-checkbox-wrapper");
out.classList.add("checkbox-wrapper");
out.innerHTML = `
<input
is="emby-checkbox"
class="sso-link-checkbox"
data-id="${canonical_name}"
data-mode="${provider_mode}"
data-provider="${provider_name}"
type="checkbox"
/>
<span class="checkbox-label">${canonical_name}</span>
`;
return out;
});
checkboxes.forEach((e) => {
container.appendChild(e);
});
},
handleDeleteButtonPressed: (evt, view) => {
if (evt.target.disabled) return;
const currentUserId = ApiClient.getCurrentUserId();
if (!currentUserId) return;
const delete_requests = [...view.querySelectorAll(".sso-link-checkbox")]
.filter((checkbox_link) => {
const canonical_name = checkbox_link.getAttribute("data-id");
const provider_name = checkbox_link.getAttribute("data-provider");
const provider_mode = checkbox_link.getAttribute("data-mode");
if (![canonical_name, provider_name, provider_mode].every((e) => e)) {
return false;
}
if (!checkbox_link.checked) {
return false;
}
return true;
})
.map((checked_link) => {
const canonical_name = checked_link.getAttribute("data-id");
const provider_name = checked_link.getAttribute("data-provider");
const provider_mode = checked_link.getAttribute("data-mode");
return ApiClient.fetch({
type: "DELETE",
url: ApiClient.getUrl(
`sso/${provider_mode}/link/${provider_name}/${currentUserId}/${canonical_name}`,
),
});
});
Promise.all(delete_requests).then((values) => {
console.log({ message: "Delete requests handled", values });
window.location.reload();
});
},
};
export default function (view) {
ssoConfigLinking.loadProviders(view);
view.querySelector("#enable-delete").addEventListener("change", (e) => {
view.querySelector("#btn-delete-selected-links").disabled =
!e.target.checked;
});
view
.querySelector("#btn-delete-selected-links")
.addEventListener("click", (e) =>
ssoConfigLinking.handleDeleteButtonPressed(e, view),
);
}
+9
View File
@@ -13,9 +13,18 @@
<None Remove="Config\configPage.html" />
<None Remove="Config\config.js" />
<None Remove="Config\style.css" />
<None Remove="Config\linking.html" />
<None Remove="Views\apiClient.js" />
<None Remove="Views\jellyfin-apiClient.esm.min.js" />
<None Remove="Views\emby-restyle.css" />
<EmbeddedResource Include="Config\configPage.html" />
<EmbeddedResource Include="Config\config.js" />
<EmbeddedResource Include="Config\style.css" />
<EmbeddedResource Include="Config\linking.html" />
<EmbeddedResource Include="Config\linking.js" />
<EmbeddedResource Include="Views\apiClient.js" />
<EmbeddedResource Include="Views\jellyfin-apiClient.esm.min.js" />
<EmbeddedResource Include="Views\emby-restyle.css" />
</ItemGroup>
<ItemGroup>
+52 -1
View File
@@ -11,7 +11,7 @@ namespace Jellyfin.Plugin.SSO_Auth;
/// <summary>
/// The SSO plugin class.
/// </summary>
public class SSOPlugin : BasePlugin<PluginConfiguration>, IHasWebPages
public class SSOPlugin : BasePlugin<PluginConfiguration>, IPlugin, IHasWebPages
{
/// <summary>
/// Initializes a new instance of the <see cref="SSOPlugin"/> class.
@@ -62,6 +62,57 @@ public class SSOPlugin : BasePlugin<PluginConfiguration>, IHasWebPages
Name = Name + ".css",
EmbeddedResourcePath = $"{GetType().Namespace}.Config.style.css"
},
new PluginPageInfo
{
Name = Name + "-linking",
EmbeddedResourcePath = $"{GetType().Namespace}.Config.linking.html"
},
new PluginPageInfo
{
Name = Name + "-linking.js",
EmbeddedResourcePath = $"{GetType().Namespace}.Config.linking.js"
},
};
}
/// <summary>
/// Returns the available user views for this plugin.
/// </summary>
/// <returns>A list of user views for this plugin.</returns>
public IEnumerable<PluginPageInfo> GetViews()
{
return new[]
{
new PluginPageInfo
{
Name = "style.css",
EmbeddedResourcePath = $"{GetType().Namespace}.Config.style.css"
},
new PluginPageInfo
{
Name = "linking",
EmbeddedResourcePath = $"{GetType().Namespace}.Config.linking.html"
},
new PluginPageInfo
{
Name = "linking.js",
EmbeddedResourcePath = $"{GetType().Namespace}.Config.linking.js"
},
new PluginPageInfo
{
Name = "ApiClient.js",
EmbeddedResourcePath = $"{GetType().Namespace}.Views.apiClient.js"
},
new PluginPageInfo
{
Name = "emby-restyle.css",
EmbeddedResourcePath = $"{GetType().Namespace}.Views.emby-restyle.css"
},
new PluginPageInfo
{
Name = "jellyfin-apiClient.esm.min.js",
EmbeddedResourcePath = $"{GetType().Namespace}.Views.jellyfin-apiClient.esm.min.js"
},
};
}
}
+153
View File
@@ -0,0 +1,153 @@
import jellyfinApiclient from "./jellyfin-apiClient.esm.min.js";
window.jellyfinApiclient = jellyfinApiclient;
console.log(jellyfinApiclient);
// https://github.com/jellyfin/jellyfin-web/blob/9067b0e397cc8b38635d661ce86ddd83194f3202/src/scripts/clientUtils.js#L19-L76
export async function serverAddress({ basePath = "/web" }) {
const apiClient = window.ApiClient;
if (apiClient) {
return Promise.resolve(apiClient.serverAddress());
}
const urls = [];
const getViewUrl = (basePath) => {
let url;
const index = window.location.href
.toLowerCase()
.lastIndexOf(basePath.toLowerCase());
if (index != -1) {
url = window.location.href.substring(0, index);
} else {
// Return nothing, let another method handle it
url = undefined;
}
return url;
};
if (urls.length === 0) {
// Otherwise use computed base URL
let url;
url = getViewUrl(basePath) ?? getViewUrl("/web") ?? window.location.origin;
// Don't use bundled app URL (file:) as server URL
if (url.startsWith("file:")) {
return Promise.resolve();
}
urls.push(url);
}
console.debug("URL candidates:", urls);
const promises = urls.map((url) => {
return fetch(`${url}/System/Info/Public`)
.then((resp) => {
return {
url: url,
response: resp,
};
})
.catch(() => {
return Promise.resolve();
});
});
return Promise.all(promises)
.then((responses) => {
responses = responses.filter((obj) => obj && obj.response.ok);
return Promise.all(
responses.map((obj) => {
return {
url: obj.url,
config: obj.response.json(),
};
}),
);
})
.then((configs) => {
const selection =
configs.find((obj) => !obj.config.StartupWizardCompleted) || configs[0];
return Promise.resolve(selection?.url);
})
.catch((error) => {
console.log(error);
return Promise.resolve();
});
}
// TODO: Refactor duplicated code
// ! Duplicated at
// https://github.com/9p4/jellyfin-plugin-sso/blob/38558d762a13422862240af4060bdd1bb1618d57/SSO-Auth/WebResponse.cs#L363-L401
function getDeviceName() {
return "DUMMY";
}
function getDeviceId() {
return localStorage.getItem("_deviceId2");
}
const sleep = (milliseconds) => {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
};
async function awaitLocalStorage() {
while (
localStorage.getItem("_deviceId2") == null ||
localStorage.getItem("jellyfin_credentials") == null ||
JSON.parse(localStorage.getItem("jellyfin_credentials"))["Servers"][0][
"Id"
] == null
) {
// If localStorage isn't initialized yet, try again.
await sleep(100);
}
}
await awaitLocalStorage();
// Fetch credentials
var credentials = new jellyfinApiclient.Credentials();
var server = await serverAddress({ basePath: "/SSOViews" });
console.log({ server: server });
var deviceId = getDeviceId();
var appName = "SSO-Auth";
var appVersion = "0.0.0.9000";
var capabilities = {};
const current_server = credentials
.credentials()
.Servers.find((e) => e.LocalAddress == server || e.ManualAddress == server);
var localApiClient = new jellyfinApiclient.ApiClient(
server,
appName,
appVersion,
getDeviceName(),
deviceId,
);
localApiClient.setAuthenticationInfo(
current_server.AccessToken,
current_server.UserId,
);
var connections = new jellyfinApiclient.ConnectionManager(
credentials,
appName,
appVersion,
getDeviceName(),
deviceId,
capabilities,
);
connections.addApiClient(localApiClient);
window.ApiClient = localApiClient;
export default localApiClient;
+482
View File
@@ -0,0 +1,482 @@
/* Material icons polyfills */
.material-icons {
height: 1em;
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: inherit;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "liga";
}
.material-icons.home {
content: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjRweCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik0xMCAyMHYtNmg0djZoNXYtOGgzTDEyIDMgMiAxMmgzdjh6Ii8+PC9zdmc+");
}
.material-icons.add {
content: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjRweCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik0xOSAxM2gtNnY2aC0ydi02SDV2LTJoNlY1aDJ2Nmg2djJ6Ii8+PC9zdmc+");
}
.material-icons.delete {
content: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjRweCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik02IDE5YzAgMS4xLjkgMiAyIDJoOGMxLjEgMCAyLS45IDItMlY3SDZ2MTJ6TTE5IDRoLTMuNWwtMS0xaC01bC0xIDFINXYyaDE0VjR6Ii8+PC9zdmc+");
}
/*
theme.css
*/
.button-delete {
background: rgb(247, 0, 0);
color: rgba(255, 255, 255, 0.87);
}
.button-delete:disabled {
background: rgb(105, 0, 0);
color: rgba(127, 127, 127, 0.87);
cursor: not-allowed;
pointer-events: none;
}
/*
Emby Button
*/
.emby-button {
position: relative;
display: inline-flex;
align-items: center;
box-sizing: border-box;
margin: 0.3em;
text-align: center;
font-size: inherit;
font-family: inherit;
color: inherit;
/* These are getting an outline in opera tv browsers, which run chrome 30 */
outline: none !important;
outline-width: 0;
-moz-user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
user-select: none;
cursor: pointer;
z-index: 0;
padding: 0.9em 1em;
vertical-align: middle;
border: 0;
border-radius: 0.2em;
font-weight: 600;
/* Disable webkit tap highlighting */
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
text-decoration: none;
/* Not crazy about this but it normalizes heights between anchors and buttons */
line-height: 1.35;
transform-origin: center;
transition: 0.2s;
}
.emby-button.show-focus:focus {
transform: scale(1.2);
z-index: 1;
}
.emby-button::-moz-focus-inner {
border: 0;
}
.button-flat {
background: transparent;
}
.button-link {
background: transparent;
cursor: pointer;
margin: 0;
padding: 0;
vertical-align: initial;
}
.button-link:hover {
text-decoration: underline;
}
.emby-button > .material-icons {
/* For non-fab buttons that have icons */
font-size: 1.36em;
}
.button-link > .material-icons {
font-size: 1em;
}
.fab {
display: inline-flex;
border-radius: 50%;
padding: 0.6em;
box-sizing: border-box;
align-items: center;
justify-content: center;
text-align: center;
}
.emby-button.block {
display: block;
align-items: center;
justify-content: center;
margin: 0.25em 0;
width: 100%;
}
.paper-icon-button-light {
position: relative;
display: inline-flex;
align-items: center;
box-sizing: border-box;
margin: 0 0.29em;
background: transparent;
text-align: center;
font-size: inherit;
font-family: inherit;
color: inherit;
-moz-user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
user-select: none;
cursor: pointer;
z-index: 0;
min-width: initial;
min-height: initial;
width: auto;
height: auto;
padding: 0.556em;
vertical-align: middle;
border: 0;
/* These are getting an outline in opera tv browsers, which run chrome 30 */
outline: none !important;
overflow: hidden;
border-radius: 50%;
/* Disable webkit tap highlighting */
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
justify-content: center;
transform-origin: center;
transition: 0.2s;
}
.paper-icon-button-light.show-focus:focus {
transform: scale(1.3);
z-index: 1;
}
.paper-icon-button-light::-moz-focus-inner {
border: 0;
}
.paper-icon-button-light:disabled {
opacity: 0.3;
cursor: default;
}
.paper-icon-button-light > .material-icons {
font-size: 1.66956521739130434em;
/* Make sure its on top of the ripple */
position: relative;
z-index: 1;
vertical-align: middle;
}
.paper-icon-button-light > div {
max-height: 100%;
transform: scale(1.8);
position: relative;
z-index: 1;
vertical-align: middle;
display: inline;
margin: 0 auto;
}
.emby-button-foreground {
position: relative;
z-index: 1;
}
.btnFilterWithBubble {
position: relative;
}
.filterButtonBubble {
color: #fff;
position: absolute;
top: 0;
right: 0;
width: 1.6em;
height: 1.6em;
z-index: 100000000;
display: flex;
align-items: center;
justify-content: center;
font-size: 82%;
border-radius: 100em;
box-shadow:
0 4px 5px 0 rgba(0, 0, 0, 0.14),
0 1px 10px 0 rgba(0, 0, 0, 0.12),
0 2px 4px -1px rgba(0, 0, 0, 0.2);
background: #03a9f4;
font-weight: bold;
}
/* fonts.scss */
html {
font-family: "Noto Sans", "Noto Sans HK", "Noto Sans JP", "Noto Sans KR",
"Noto Sans SC", "Noto Sans TC", sans-serif;
text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
html[lang|="ja"] {
font-family: "Noto Sans", "Noto Sans JP", "Noto Sans HK", "Noto Sans KR",
"Noto Sans SC", "Noto Sans TC", sans-serif;
}
html[lang|="ko"] {
font-family: "Noto Sans", "Noto Sans KR", "Noto Sans HK", "Noto Sans JP",
"Noto Sans SC", "Noto Sans TC", sans-serif;
}
html[lang|="zh-CN"] {
font-family: "Noto Sans", "Noto Sans SC", "Noto Sans HK", "Noto Sans JP",
"Noto Sans KR", "Noto Sans TC", sans-serif;
}
html[lang|="zh-TW"] {
font-family: "Noto Sans", "Noto Sans TC", "Noto Sans HK", "Noto Sans JP",
"Noto Sans KR", "Noto Sans SC", sans-serif;
}
html[lang|="zh-HK"] {
font-family: "Noto Sans", "Noto Sans HK", "Noto Sans JP", "Noto Sans KR",
"Noto Sans SC", "Noto Sans TC", sans-serif;
}
.layout-tv {
/* Per WebOS and Tizen guidelines, fonts must be 20px minimum.
This takes the 16px baseline and multiplies it by 1.25 to get 20px. */
font-size: 125%;
}
.layout-mobile {
font-size: 90%;
}
/* site.scss */
html {
line-height: 1.35;
}
body {
overflow-x: hidden;
background-color: transparent !important;
-webkit-font-smoothing: antialiased;
}
.clipForScreenReader {
clip: rect(1px, 1px, 1px, 1px);
clip-path: inset(50%);
height: 1px;
width: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
}
.material-icons {
/* Fix font ligatures on older WebOS versions */
font-feature-settings: "liga";
}
.backgroundContainer {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
contain: strict;
}
.layout-mobile,
.layout-tv {
-webkit-touch-callout: none;
user-select: none;
}
.mainAnimatedPage {
contain: style size !important;
}
.pageContainer {
overflow-x: visible !important;
}
.bodyWithPopupOpen {
overflow-y: hidden !important;
}
div[data-role="page"] {
outline: 0;
}
.pageTitle {
margin-top: 0;
font-family: inherit;
}
.fieldDescription {
padding-left: 0.15em;
font-weight: 400;
white-space: normal !important;
}
.fieldDescription + .fieldDescription {
margin-top: 0.3em;
}
.content-primary,
.padded-bottom-page,
.page,
.pageWithAbsoluteTabs .pageTabContent {
/* provides room for the music controls */
padding-bottom: 5em !important;
}
.readOnlyContent {
@media all and (min-width: 50em) {
max-width: 54em;
}
}
form {
@media all and (min-width: 50em) {
max-width: 54em;
}
}
.headerHelpButton {
margin-left: 1.25em !important;
padding-bottom: 0.4em !important;
padding-top: 0.4em !important;
}
.mediaInfoContent {
margin-left: auto;
margin-right: auto;
width: 85%;
}
.headroom {
will-change: transform;
transition: transform 200ms linear;
}
.drawerContent {
/* make sure the bottom of the drawer is visible when music is playing */
padding-bottom: 4em;
}
.force-scroll {
overflow-y: scroll;
}
.hide-scroll {
overflow-y: hidden;
}
.w-100 {
width: 100%;
}
.margin-auto-x {
margin-left: auto;
margin-right: auto;
}
.margin-auto-y {
margin-top: auto;
margin-bottom: auto;
}
/* Fix checkboxes */
/* Customize the label (the container) */
*/ .checkbox-wrapper {
position: relative;
}
.checkbox-wrapper [type="checkbox"] {
/*display: none;*/
position: absolute;
top: 0px;
left: 0px;
height: 20px;
width: 20px;
-webkit-appearance: none;
}
.checkbox-label {
display: flex;
position: relative;
font-size: 20px;
font-weight: 400;
align-items: center;
justify-content: flex-start;
margin-bottom: 20px;
}
.checkbox-label:before,
.checkbox-label:after {
pointer-events: none;
}
.checkbox-label:before {
display: flex;
content: " ";
height: 20px;
width: 20px;
border: 0.14em solid white;
border-radius: 0.14em;
/* background: #fff; */
margin-right: 10px;
}
.checkbox-label:after {
position: absolute;
top: 0;
left: 0;
display: flex;
content: " ";
height: 20px;
width: 20px;
border: 0.14em solid white;
border-radius: 0.14em;
background: none;
}
.checkbox-wrapper input[type="checkbox"]:checked + .checkbox-label:after {
background-color: #2196f3;
content: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjRweCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik05IDE2LjJMNC44IDEybC0xLjQgMS40TDkgMTkgMjEgN2wtMS40LTEuNEw5IDE2LjJ6Ii8+PC9zdmc+");
}
File diff suppressed because one or more lines are too long
+40 -2
View File
@@ -412,10 +412,46 @@ const sleep = (milliseconds) => {
/// <param name="provider">The name of the provider to callback to.</param>
/// <param name="baseUrl">The base URL of the Jellyfin installation.</param>
/// <param name="mode">The mode of the function; SAML or OID.</param>
/// <param name="isLinking">Whether or not this request is to link accounts (Rather than authenticate).</param>
/// <returns>A string with the HTML to serve to the client.</returns>
public static string Generator(string data, string provider, string baseUrl, string mode)
public static string Generator(string data, string provider, string baseUrl, string mode, bool isLinking = false)
{
return Base + @"
async function link(request) {
const jfCredentialsString = localStorage.getItem(""jellyfin_credentials"");
if (jfCredentialsString == null) return;
const jfCredentials = JSON.parse(jfCredentialsString);
const jfUser = jfCredentials['Servers'][0]['UserId'];
const jfToken = jfCredentials['Servers'][0]['AccessToken'];
if (jfUser == null) return;
if (jfToken == null) return;
const url = '" + $"{baseUrl}/sso/{mode}/Link/{provider}/" + @"' + jfUser;
return new Promise(resolve => {
var xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader(
'X-Emby-Authorization',
`MediaBrowser Client=""${request.appName}"",Device=""${request.deviceName}"",DeviceId=""${request.deviceId}"",Version=""${request.appVersion}"",Token=""${jfToken}""`)
xhr.onload = function(e) {
resolve(xhr.response);
};
xhr.onerror = function (e) {
console.log(e);
resolve(undefined);
};
xhr.send(JSON.stringify(request));
})
}
async function main() {
var data = '" + data + @"';
while (localStorage.getItem(""_deviceId2"") == null ||
@@ -431,6 +467,8 @@ async function main() {
var request = {deviceId, appName, appVersion, deviceName, data};
if (" + $"{isLinking}".ToLower() + @") await link(request);
var url = '" + baseUrl + "/sso/" + mode + "/Auth/" + provider + @"';
let response = await new Promise(resolve => {
@@ -455,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('" + baseUrl + @"');
window.location.replace('" + baseUrl + @"/web/index.html');
}
document.addEventListener('DOMContentLoaded', function () {
+4 -1
View File
@@ -1,7 +1,7 @@
name: "SSO Authentication"
guid: "505ce9d1-d916-42fa-86ca-673ef241d7df"
imageUrl: "https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/main/img/logo.png"
version: "3.3.0.0"
version: "3.5.1.0"
targetAbi: "10.8.0.0"
framework: "net6.0"
owner: "9p4"
@@ -15,6 +15,9 @@ artifacts:
- "IdentityModel.OidcClient.dll"
- "IdentityModel.dll"
changelog: |
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.
3.1.0.1: Fix redirect bug in WebResponse (#7)
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1691218994,
"narHash": "sha256-46GJ5vLf9H+Oh7Jii2gJI9GATJHGbx2iQpon5nUSFPI=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "0d2fb29f5071a12d7983319c2c2576be6a130582",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+10
View File
@@ -0,0 +1,10 @@
{
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 ]; };
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

+202
View File
@@ -0,0 +1,202 @@
# Provider Specific Configuration
This plugin has been tested to work against various providers, though not all providers provide support for all of this plugins' features.
## TOC / Tested Providers:
This section is broken into providers that support Role-Based Access Control (RBAC), and those that do not
### Providers that support RBAC
- ✅ [Authelia](#authelia)
- ✅ [authentik](#authentik)
- [✅ Keycloak](#keycloak-oidc)
- Both [OIDC](#keycloak-oidc) & [SAML](#keycloak-saml)
### No RBAC Support
- ✅ Google OIDC
- ❗ Usernames are numeric
- ❗ Requires disabling validating OpenID endpoints
## General Options, when RBAC is supported
For any provider that supports RBAC, we can configure it as we see fit:
```yaml
Enabled: true
EnableAuthorization: true
EnableAllFolders: true
EnabledFolders: []
Roles: ["jellyfin_user"]
AdminRoles: ["jellyfin_admin"]
EnableFolderRoles: false
FolderRoleMapping: []
```
## Authelia
Authelia is simple to configure, and RBAC is straightforward.
### Authelia's Config
Below is the `identity_providers` section of an Authelia config:
```yaml
identity_providers:
oidc:
# hmac secret and private key given by env variables
clients:
- id: jellyfin
description: My media server
# Client secret should be randomly generated
secret: <redacted>
authorization_policy: one_factor
redirect_uris:
- https://jellyfin.example.com/sso/OID/redirect/authelia
```
### Jellyfin's Config
On Jellyfin's end, we need to configure an Authelia provider as follows:
In order to test group membership, we need to request Authelia's `groups` OIDC scope, which we will use to check user roles.
```yaml
authelia:
OidEndpoint: https://authelia.example.com
OidClientId: jellyfin
OidSecret: <redacted>
RoleClaim: groups
OidScopes: ["groups"]
```
## authentik
To begin with, we must set up an OIDC provider + application in authentik. Refer to the official documentation for detailed instruction.
### authentik's Config
authentik supports RBAC, but is slightly more complicated to configure than Authelia, as we need to configure a custom scope binding to include in the OIDC response.
To do this, we:
- create a **Custom Property Mapping**
![image](img/authentik-config-01.jpg)
- Create a **Scope Mapping**
![image](img/authentik-config-02.jpg)
- Assign the following attributes:
![image](img/authentik-config-03.jpg)
```yaml
# A nice, human readable name
name: Group Membership
# The name of the scope a client must request to get access to a user's groups
Scope Name: groups
# A description of what is being requested to show to a user
Description: See Which Groups you belong to
```
- For the **Expression** field, use the following code:
```python
return [group.name for group in user.ak_groups.all()]
```
Now we can add this property mapping to authentik's Jellyfin OAuth provider:
- Navigate to `Applications/providers`
![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)
### Jellyfin's Config
On Jellyfin's end, we need to configure an authentik provider as follows:
In order to test group membership, we need to request authentik's OIDC scope `groups`, which we will use to check user roles.
```yaml
authentik:
OidEndpoint: https://authentik.example.com/application/o/jellyfin
OidClientId: <same-as-in-authentik>
OidSecret: <redacted>
RoleClaim: groups
OidScopes: ["groups"]
```
## Keycloak OIDC
Keycloak in general is a little more complicated than other providers. Ensure that you have a realm created and have some usable users.
### Keycloak's Config
Create a new Keycloak `openid-connect` application. Set the root URL to your Jellyfin URL (ie https://myjellyfin.example.com)
Ensure that the following configuration options are set:
- Access Type: Confidential
- Standard Flow Enabled
- Redirect URI: https://myjellyfin.example.com/sso/OID/redirect/PROVIDER_NAME
- Base URL: https://myjellyfin.example.com
Press the "Save" button at the bottom of the page and open the "Credentials" tab. Note down the secret.
For adding groups and RBAC, go to the "mappers" tab, press "Add Builtin", and select either "Groups", "Realm Roles", or "Client Roles", depending on the role system you are planning on using. Once the mapper is added, edit the mapper and ensure that you note down the Token Claim Name as well as enable all four toggles: "Multivalued", "Add to ID token", "Add to access token", and "Add to userinfo" are enabled.
Note that if you are using the template for the "Client Roles" mapper, the default token claim name has `${client_id}` in it. When noting down this value, make sure you note down the actual Client ID (which should be written above).
### Jellyfin's Config
On Jellyfin's side, we need to configure a Keycloak provider as follows:
```yaml
keycloak:
OidEndpoint: https://keycloak.example.com/realms/<realm>
OidClientId: <same-as-in-keycloak>
OidSecret: <redacted>
RoleClaim: <same-as-token-claim-name>
```
## Keycloak SAML
Keycloak with SAML is very similar to OpenID. Again, Keycloak in general is a little more complicated than other providers. Ensure that you have a realm created and have some usable users.
### Keycloak's Config
Create a new Keycloak `saml` application. Set the root URL to your Jellyfin URL (ie https://myjellyfin.example.com)
Ensure that the following configuration options are set:
- Sign Documents on
- Sign Assertions off
- Client Signature Required off
- Redirect URI: [https://myjellyfin.example.com/sso/SAML/start/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/start/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)
Press the "Save" button at the bottom of the page.
For adding groups and RBAC, go to the "mappers" tab, press "Add Builtin", and select either "Groups", "Realm Roles", or "Client Roles", depending on the role system you are planning on using. Once the mapper is added, edit the mapper and ensure that you note down the Token Claim Name as well as enable all four toggles: "Multivalued", "Add to ID token", "Add to access token", and "Add to userinfo" are enabled.
Note that if you are using the template for the "Client Roles" mapper, the default token claim name has `${client_id}` in it. When noting down this value, make sure you note down the actual Client ID (which should be written above).
Finally, download the certificate. Open the "Installation" tab, select "Mod Auth Mellon files", and download the zip. Extract the zip file, and open the `idp-metadata.xml` file. Note down the contents of the `X509Certificate` value.
### Jellyfin's Config
```yaml
keycloak:
SamlEndpoint: https://keycloak.example.com/realms/<realm>/protocol/saml
SamlClientId: <same-as-in-keycloak>
SamlCertificate: <copied-from-xml-file>
```