mirror of
https://github.com/9p4/jellyfin-plugin-sso.git
synced 2026-09-19 13:12:19 +00:00
Compare commits
137
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
172f22e19f | ||
|
|
5777c8038e | ||
|
|
95776b427b | ||
|
|
b82a3867e4 | ||
|
|
657f90ef13 | ||
|
|
dd01e729b8 | ||
|
|
f2452103bf | ||
|
|
076cb1b893 | ||
|
|
defab9c4e8 | ||
|
|
2ead846ed8 | ||
|
|
8b755361b4 | ||
|
|
340eb2c6c3 | ||
|
|
08d24ba0db | ||
|
|
0d19a3376f | ||
|
|
1de72effca | ||
|
|
698fbce81b | ||
|
|
c6cac45004 | ||
|
|
dfd519ae9d | ||
|
|
40120dd127 | ||
|
|
d78e8c88a3 | ||
|
|
42b567d018 | ||
|
|
5bedf010b7 | ||
|
|
05fabf97fc | ||
|
|
ae063abea4 | ||
|
|
fa46806dc5 | ||
|
|
98ddb9e352 | ||
|
|
46eb00bf43 | ||
|
|
5723cd718d | ||
|
|
96b14f7a5a | ||
|
|
320551bc18 | ||
|
|
9f8626bdf7 | ||
|
|
d5925fc1de | ||
|
|
facf45058f | ||
|
|
f3d0497801 | ||
|
|
672fce8189 | ||
|
|
c2f50e1e4c | ||
|
|
a90e0359e4 | ||
|
|
332f62d76e | ||
|
|
47db5cb504 | ||
|
|
fe1fdad0b0 | ||
|
|
d06f680407 | ||
|
|
976a816d8c | ||
|
|
f7ce40fe5e | ||
|
|
8666818dfa | ||
|
|
c5e6bf96f9 | ||
|
|
fd59b83a0e | ||
|
|
ab36aea359 | ||
|
|
fb268ed290 | ||
|
|
f00bf70597 | ||
|
|
f0d16c33b8 | ||
|
|
5c0e4ecefe | ||
|
|
ac1affa7b4 | ||
|
|
510624f142 | ||
|
|
840da75feb | ||
|
|
56b36e0fd4 | ||
|
|
a4ea12b7f4 | ||
|
|
843873d8d5 | ||
|
|
fc888de045 | ||
|
|
d72e47e7a7 | ||
|
|
a7fb4d4ef0 | ||
|
|
ebfcadc862 | ||
|
|
d8d7d616bd | ||
|
|
bd60d7e32c | ||
|
|
7e5738c65f | ||
|
|
f810ea7259 | ||
|
|
cc05b12a23 | ||
|
|
e7d70b8326 | ||
|
|
c5e1644d17 | ||
|
|
7ede38d0ab | ||
|
|
fad5a62e07 | ||
|
|
511e5e4f12 | ||
|
|
ad6328c1c5 | ||
|
|
7bc7e44768 | ||
|
|
d94199cb8f | ||
|
|
a0bfa6a3ee | ||
|
|
19bae4de9d | ||
|
|
7e232d7cf9 | ||
|
|
eaf7e87747 | ||
|
|
8df2bd94e8 | ||
|
|
815d6cbe7e | ||
|
|
6a0fcfe711 | ||
|
|
f9331665e7 | ||
|
|
299f3f7220 | ||
|
|
4eec8bb789 | ||
|
|
55d795b9d8 | ||
|
|
5a8d1da6de | ||
|
|
e3efd97964 | ||
|
|
9199fce64f | ||
|
|
0e590858c0 | ||
|
|
9b4393981c | ||
|
|
29e902c109 | ||
|
|
b777e3a346 | ||
|
|
5da7e02faf | ||
|
|
07aa74b1d7 | ||
|
|
f98509633b | ||
|
|
f7d066d6b1 | ||
|
|
4600f56863 | ||
|
|
22dd6629eb | ||
|
|
ece0dc31db | ||
|
|
8771f63eb5 | ||
|
|
54ef348009 | ||
|
|
63ea90114c | ||
|
|
3a8e89db82 | ||
|
|
08bf263255 | ||
|
|
b1beef8155 | ||
|
|
062be242ae | ||
|
|
d9458d5933 | ||
|
|
6cb5fc33bf | ||
|
|
fd371006af | ||
|
|
5d28f1ba31 | ||
|
|
c500a5d435 | ||
|
|
2d14342ad7 | ||
|
|
e8276eae87 | ||
|
|
2d459b7945 | ||
|
|
b8cddb3467 | ||
|
|
875fc62728 | ||
|
|
31cd57829b | ||
|
|
8d3b0ea5eb | ||
|
|
7f66f44743 | ||
|
|
0ad890088d | ||
|
|
bceb448c2d | ||
|
|
0d44515442 | ||
|
|
f748bf7ef7 | ||
|
|
b752f6ffaa | ||
|
|
880a5494ec | ||
|
|
495fbcdda4 | ||
|
|
93c5529138 | ||
|
|
653341f3dd | ||
|
|
ff0f6caa3c | ||
|
|
7d1b3fa97d | ||
|
|
4709a65c00 | ||
|
|
1945ae8683 | ||
|
|
fad64fec7c | ||
|
|
1a736d9707 | ||
|
|
7106af19ec | ||
|
|
0eb7cd4f50 | ||
|
|
06ed040381 |
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ""
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Configuration**
|
||||
Add your plugin configuration XML file here formatted as code (with three backticks surrounding the text), or as an upload to a pastebin service.
|
||||
|
||||
**Versions (please complete the following information):**
|
||||
|
||||
- OS: [e.g. Linux]
|
||||
- Browser: [e.g. chrome, safari]
|
||||
- Jellyfin Version: [e.g. 10.8 Alpha 4]
|
||||
- Plugin Version: [e.g. 2.0.1.0 or a Git tag]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here. Was the plugin built from source?
|
||||
@@ -0,0 +1,4 @@
|
||||
contact_links:
|
||||
- name: Question
|
||||
url: https://github.com/9p4/jellyfin-plugin-sso/discussions
|
||||
about: Please ask and answer questions here.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ""
|
||||
labels: enhancement
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,39 @@
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
dotnet-version:
|
||||
required: false
|
||||
default: "6.0.x"
|
||||
description: "The .NET version to setup for the build"
|
||||
type: string
|
||||
dotnet-target:
|
||||
required: false
|
||||
default: "net6.0"
|
||||
description: "The .NET target to set for JPRM"
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v1
|
||||
with:
|
||||
dotnet-version: "${{ inputs.dotnet-version }}"
|
||||
|
||||
- name: Build Jellyfin Plugin
|
||||
uses: oddstr13/jellyfin-plugin-repository-manager@b9e92867a6aa279d611a5ea80cf61f6358838c39
|
||||
id: jprm
|
||||
with:
|
||||
dotnet-target: "${{ inputs.dotnet-target }}"
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # tag=v3
|
||||
with:
|
||||
name: build-artifact
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
path: ${{ steps.jprm.outputs.artifact }}
|
||||
@@ -0,0 +1,11 @@
|
||||
name: Lint Commit Messages
|
||||
on: [pull_request, push]
|
||||
|
||||
jobs:
|
||||
commitlint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: wagoid/commitlint-github-action@4caf21aed4a778f940d0b17eb109942ef167bb27
|
||||
@@ -0,0 +1,25 @@
|
||||
name: .NET
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
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
|
||||
run: dotnet build --no-restore --warnaserror
|
||||
- name: Test
|
||||
run: dotnet test --no-build --verbosity normal
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Prettier Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
|
||||
jobs:
|
||||
prettier:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
# Make sure the actual branch is checked out when running on pull requests
|
||||
ref: ${{ github.head_ref }}
|
||||
- name: Prettify code
|
||||
uses: creyD/[email protected]
|
||||
with:
|
||||
dry: True
|
||||
prettier_options: '--check **/*.{js,html,md,css,scss}'
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Publish Nightly
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v1
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
- name: Build Dotnet
|
||||
run: dotnet build --no-restore --warnaserror
|
||||
- name: "Flag as nightly in build.yaml"
|
||||
uses: fjogeleit/[email protected]
|
||||
with:
|
||||
valueFile: 'build.yaml'
|
||||
propertyPath: 'version'
|
||||
value: "0.0.0.9000"
|
||||
commitChange: false
|
||||
updateFile: true
|
||||
- name: "JPRM: Build"
|
||||
id: jrpm
|
||||
uses: oddstr13/jellyfin-plugin-repository-manager@b9e92867a6aa279d611a5ea80cf61f6358838c39
|
||||
with:
|
||||
version: "0.0.0.9000"
|
||||
verbosity: debug
|
||||
path: .
|
||||
dotnet-target: "net6.0"
|
||||
output: _dist
|
||||
- name: Prepare GitHub Release assets
|
||||
run: |-
|
||||
pushd _dist
|
||||
for file in ./*.zip; do
|
||||
md5sum ${file#./} >> ${file%.*}.md5
|
||||
sha256sum ${file#./} >> ${file%.*}.sha256
|
||||
done
|
||||
ls -l
|
||||
popd
|
||||
- name: Publish output artifacts
|
||||
id: publish-assets
|
||||
uses: softprops/action-gh-release@50195ba7f6f93d1ac97ba8332a178e008ad176aa
|
||||
with:
|
||||
prerelease: false
|
||||
fail_on_unmatched_files: true
|
||||
tag_name: nightly
|
||||
files: |
|
||||
_dist/*
|
||||
build.yaml
|
||||
body: |
|
||||
Nightly build
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Publish Plugin Manifest
|
||||
uses: Kevinjil/jellyfin-plugin-repo-action@a7832ecc44c6b1a45d531970f6647b8682b005b8
|
||||
with:
|
||||
ignorePrereleases: true
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: ${{ github.repository }}
|
||||
pagesBranch: manifest-release
|
||||
pagesFile: manifest.json
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Publish Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- released
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
dotnet-version: "6.0.*"
|
||||
dotnet-target: "net6.0"
|
||||
upload:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download Artifact
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
name: build-artifact
|
||||
- name: Prepare GitHub Release assets
|
||||
run: |-
|
||||
for file in ./*; do
|
||||
md5sum ${file#./} >> ${file%.*}.md5
|
||||
sha256sum ${file#./} >> ${file%.*}.sha256
|
||||
done
|
||||
ls -l
|
||||
- name: Publish output artifacts
|
||||
id: publish-assets
|
||||
uses: softprops/action-gh-release@50195ba7f6f93d1ac97ba8332a178e008ad176aa
|
||||
with:
|
||||
prerelease: false
|
||||
fail_on_unmatched_files: true
|
||||
tag_name: ${{ github.event.release.tag_name }}
|
||||
files: ./*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
generate:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- upload
|
||||
steps:
|
||||
- name: Publish Plugin Manifest
|
||||
uses: Kevinjil/jellyfin-plugin-repo-action@a7832ecc44c6b1a45d531970f6647b8682b005b8
|
||||
with:
|
||||
ignorePrereleases: true
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: ${{ github.repository }}
|
||||
pagesBranch: manifest-release
|
||||
pagesFile: manifest.json
|
||||
+1
-4
@@ -448,7 +448,4 @@ $RECYCLE.BIN/
|
||||
## Visual Studio Code
|
||||
##
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.vscode
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*.min.js
|
||||
+76
@@ -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
@@ -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)!
|
||||
@@ -1,23 +1,52 @@
|
||||
# Jellyfin SSO Plugin
|
||||
<h1 align="center">Jellyfin SSO Plugin</h1>
|
||||
|
||||
<p align="center">
|
||||
|
||||
<img alt="Logo" src="https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/main/img/logo.png"/>
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="https://github.com/9p4/jellyfin-plugin-sso">
|
||||
<img alt="GPL 3.0 License" src="https://img.shields.io/github/license/9p4/jellyfin-plugin-sso.svg"/>
|
||||
</a>
|
||||
<a href="https://github.com/9p4/jellyfin-plugin-sso/actions/workflows/dotnet.yml">
|
||||
<img alt="GitHub Actions Build Status" src="https://github.com/9p4/jellyfin-plugin-sso/actions/workflows/dotnet.yml/badge.svg"/>
|
||||
</a>
|
||||
<a href="https://github.com/9p4/jellyfin-plugin-sso/releases">
|
||||
<img alt="Current Release" src="https://img.shields.io/github/release/9p4/jellyfin-plugin-sso.svg"/>
|
||||
</a>
|
||||
<a href="https://github.com/9p4/jellyfin-plugin-sso/releases.atom">
|
||||
<img alt="Release RSS Feed" src="https://img.shields.io/badge/rss-releases-ffa500?logo=rss" />
|
||||
</a>
|
||||
<a href="https://github.com/9p4/jellyfin-plugin-sso/commits/main.atom">
|
||||
<img alt="Main Commits RSS Feed" src="https://img.shields.io/badge/rss-commits-ffa500?logo=rss" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
This plugin allows users to sign in through an SSO provider (such as Google, Microsoft, or your own provider). This enables one-click signin.
|
||||
|
||||
https://user-images.githubusercontent.com/17993169/149681516-f93b43f5-fa5c-4c1f-a909-e5414878a864.mp4
|
||||
|
||||
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!
|
||||
~~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**
|
||||
**[This is for Jellyfin 10.8](https://github.com/9p4/jellyfin-plugin-sso/issues/3) and only on the Web UI!**
|
||||
|
||||
**This README reflects the __main__ branch! Switch tags to view version-specific documentation!**
|
||||
**This README reflects the branch it is currently on! Switch tags to view version-specific documentation!**
|
||||
|
||||
## 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
|
||||
|
||||
@@ -30,33 +59,72 @@ This is my first time writing C# so please take all of the code written here wit
|
||||
|
||||
## Installing
|
||||
|
||||
Add the package repo [https://repo.saggis.com/jellyfin/manifest.json](https://repo.saggis.com/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
|
||||
|
||||
- [ ] Admin page
|
||||
- [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;
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
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/):
|
||||
|
||||
`curl -v -X POST -H "Content-Type: application/json" -d '{"samlEndpoint": "https://keycloak.example.com/realms/test/protocol/saml", "samlClientId": "jellyfin-saml", "samlCertificate": "Very long base64 encoded string here", "enabled": true, "enableAuthorization": true, "enableAllFolders": false, "enabledFolders": [], "adminRoles": ["jellyfin-admin"], "roles": ["allowed-to-use-jellyfin"], "enableFolderRoles": true, "folderRoleMapping": [{"role": "allowed-to-watch-movies", "folders": ["cc7df17e2f3509a4b5fc1d1ff0a6c4d0", "f137a2dd21bbc1b99aa5c0f6bf02a805"]}]}' "https://myjellyfin.example.com/sso/SAML/Add?api_key=API_KEY_HERE"`
|
||||
`curl -v -X POST -H "Content-Type: application/json" -d '{"samlEndpoint": "https://keycloak.example.com/realms/test/protocol/saml", "samlClientId": "jellyfin-saml", "samlCertificate": "Very long base64 encoded string here", "enabled": true, "enableAuthorization": true, "enableAllFolders": false, "enabledFolders": [], "adminRoles": ["jellyfin-admin"], "roles": ["allowed-to-use-jellyfin"], "enableFolderRoles": true, "folderRoleMapping": [{"role": "allowed-to-watch-movies", "folders": ["cc7df17e2f3509a4b5fc1d1ff0a6c4d0", "f137a2dd21bbc1b99aa5c0f6bf02a805"]}]}' "https://myjellyfin.example.com/sso/SAML/Add/PROVIDER_NAME?api_key=API_KEY_HERE"`
|
||||
|
||||
Make sure that the JSON is the same as the configuration you would like.
|
||||
|
||||
@@ -65,26 +133,26 @@ 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/clientid](https://myjellyfin.example.com/sso/OID/p/clientid)
|
||||
- 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/clientid](https://myjellyfin.example.com/sso/SAML/p/clientid)
|
||||
- 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!
|
||||
Make sure that `clientid` is replaced with the actual client ID and `PROVIDER_NAME` is replaced with the chosen provider name!
|
||||
|
||||
### OpenID
|
||||
|
||||
Example for adding an OpenID configuration with the API using [curl](https://curl.se/)
|
||||
|
||||
`curl -v -X POST -H "Content-Type: application/json" -d '{"oidEndpoint": "https://keycloak.example.com/realms/test", "oidClientId": "jellyfin-oid", "oidSecret": "short secret here", "enabled": true, "enableAuthorization": true, "enableAllFolders": false, "enabledFolders": [], "adminRoles": ["jellyfin-admin"], "roles": ["allowed-to-use-jellyfin"], "enableFolderRoles": true, "folderRoleMapping": [{"role": "allowed-to-watch-movies", "folders": ["cc7df17e2f3509a4b5fc1d1ff0a6c4d0", "f137a2dd21bbc1b99aa5c0f6bf02a805"]}], "roleClaim": "realm_access"}' "https://myjellyfin.example.com/sso/OID/Add?api_key=API_KEY_HERE"`
|
||||
`curl -v -X POST -H "Content-Type: application/json" -d '{"oidEndpoint": "https://keycloak.example.com/realms/test", "oidClientId": "jellyfin-oid", "oidSecret": "short secret here", "enabled": true, "enableAuthorization": true, "enableAllFolders": false, "enabledFolders": [], "adminRoles": ["jellyfin-admin"], "roles": ["allowed-to-use-jellyfin"], "enableFolderRoles": true, "folderRoleMapping": [{"role": "allowed-to-watch-movies", "folders": ["cc7df17e2f3509a4b5fc1d1ff0a6c4d0", "f137a2dd21bbc1b99aa5c0f6bf02a805"]}], "roleClaim": "realm_access", "oidScopes" : [""]}' "https://myjellyfin.example.com/sso/OID/Add/PROVIDER_NAME?api_key=API_KEY_HERE"`
|
||||
|
||||
The OpenID provider must have the following configuration (again, I am using Keycloak)
|
||||
|
||||
- Access Type: Confidential
|
||||
- Standard Flow Enabled
|
||||
- Redirect URI: [https://myjellyfin.example.com/sso/OID/r/clientid](https://myjellyfin.example.com/sso/OID/r/clientid)
|
||||
- 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!
|
||||
Make sure that `clientid` is replaced with the actual client ID and `PROVIDER_NAME` is replaced with the chosen provider name!
|
||||
|
||||
## API Endpoints
|
||||
|
||||
@@ -94,21 +162,20 @@ The API is all done from a base URL of `/sso/`
|
||||
|
||||
#### Flow
|
||||
|
||||
- POST `SAML/p/clientid`: 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.
|
||||
- GET `SAML/p/clientid`: This is the SAML initiator: it will begin the authorization flow for SAML with a given client ID.
|
||||
- POST `SAML/Auth`: This is the SAML client-side API: the HTML and JavaScript client will call this endpoint to receive Jellyfin credentials. Post format is in JSON with the following keys:
|
||||
- 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.
|
||||
- `appName`: string. App name.
|
||||
- `appVersion`: string. App version.
|
||||
- `data`: string. The signed SAML XML request. Used to verify a request.
|
||||
- `provider`: string. The current SAML client ID.
|
||||
|
||||
#### Configuration
|
||||
|
||||
These all require authorization. Append an API key to the end of the request: `curl "http://myjellyfin.example.com/sso/SAML/Get?api_key=API_KEY_HERE"`
|
||||
|
||||
- POST `SAML/Add`: This adds a configuration for SAML. It accepts JSON with the following keys and format:
|
||||
- POST `SAML/Add/PROVIDER_NAME`: This adds or overwrites a configuration for SAML for the given provider name. It accepts JSON with the following keys and format:
|
||||
- `samlEndpoint`: string. The SAML endpoint.
|
||||
- `samlClientId`: string. The SAML client ID.
|
||||
- `samlCertificate`: string. The base64 encoded SAML certificate.
|
||||
@@ -120,29 +187,33 @@ 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.
|
||||
- GET `SAML/Del/clientId`: This removes a configuration for SAML for a given client ID.
|
||||
- `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.
|
||||
|
||||
|
||||
### OpenID
|
||||
|
||||
#### Flow
|
||||
|
||||
- GET `OID/r/clientId`: This is the OpenID callback path. This will return HTML and JavaScript for the client to login.
|
||||
- GET `OID/p/clientId`: This is the OpenID initiator: it will begin the authorization flow for OpenID with a given client ID.
|
||||
- POST `OID/Auth`: This is the OpenID client-side API: the HTML and JavaScript client will call this endpoint to receive Jellyfin credentials. Post format is in JSON with the following keys:
|
||||
- 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.
|
||||
- `appName`: string. App name.
|
||||
- `appVersion`: string. App version.
|
||||
- `data`: string. The OpenID state. Used to verify a request.
|
||||
- `provider`: string. The current OpenID client ID.
|
||||
|
||||
#### Configuration
|
||||
|
||||
These all require authorization. Append an API key to the end of the request: `curl "http://myjellyfin.example.com/sso/OID/Get?api_key=9c6e5fae4ae145669e6b7a3942f813b7"`
|
||||
|
||||
- POST `OID/Add`: This adds a configuration for OpenID. It accepts JSON with the following keys and format:
|
||||
- POST `OID/Add/PROVIDERNAME`: This adds or overwrites a configuration for OpenID with a given provider name. It accepts JSON with the following keys and format:
|
||||
- `oidEndpoint`: string. The OpenID endpoint. Must have a `.well-known` path available.
|
||||
- `oidClientId`: string. The OpenID client ID.
|
||||
- `oidSecret`: string. The OpenID secret.
|
||||
@@ -154,8 +225,21 @@ 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.
|
||||
- `roleClaim`: string. This is the value in the OpenID response to check for roles. For Keycloak, it is `realm_access` by default.
|
||||
- GET `OID/Del/clientId`: This removes a configuration for OpenID for a given client ID.
|
||||
- `enableLiveTvRoles`: boolean. Determines if role-based Live TV access should be used.
|
||||
- `liveTvRoles`: array of strings. If `enableLiveTvRoles` is enabled, then the user's roles will be checked against these. If the user is granted permission, then the user will be able to view Live TV.
|
||||
- `liveTvManagementRoles`: array of strings. If `enableLiveTvRoles` is enabled, then the user's roles will be checked against these. If the user is granted permission, then the user will be able to manage Live TV.
|
||||
- `enableLiveTv`: boolean. Whether to allow Live TV by default. This applies even if `enableLiveTvRoles` is enabled.
|
||||
- `enableLiveTvManagement`: boolean. Whether to allow Live TV management by default. This applies even if `enableLiveTvRoles` is enabled.
|
||||
- `roleClaim`: string. This is the value in the OpenID response to check for roles. For Keycloak, it is `realm_access.roles` by default. The first element is the claim type, the subsequent values are to parse the JSON of the claim value. Use a "\\." to denote a literal ".". This expects a list of strings from the OIDC server.
|
||||
- `oidScopes` : array of strings. Each contains an additional scope name to include in the OIDC request.
|
||||
- For some OIDC providers (For example, [authelia](https://github.com/9p4/jellyfin-plugin-sso/issues/23#issuecomment-1112237616)), additional scopes may be required in order to validate group membership in role claim.
|
||||
- Leave empty to only request the default scopes.
|
||||
- `defaultProvider`: string. The set provider then gets assigned to the user after they have logged in. If it is not set, nothing is changed. With this, a user can login with SSO but is still able to log in via other providers later. See the `Unregister` endpoint.
|
||||
- `defaultUsernameClaim`: string. The provider will use the claim to create the users' usernames. If not set, it fallbacks to `preferred_username`.
|
||||
- `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.
|
||||
|
||||
@@ -165,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.
|
||||
~~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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+873
-197
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SSO_Auth.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The sso views controller.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
public class SSOViewsController : ControllerBase
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly IAuthorizationContext _authContext;
|
||||
private readonly ILogger<SSOViewsController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SSOViewsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{SSOViewsController}"/> interface.</param>
|
||||
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
|
||||
/// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
public SSOViewsController(ILogger<SSOViewsController> logger, ISessionManager sessionManager, IUserManager userManager, IAuthorizationContext authContext)
|
||||
{
|
||||
_sessionManager = sessionManager;
|
||||
_userManager = userManager;
|
||||
_authContext = authContext;
|
||||
_logger = logger;
|
||||
_logger.LogInformation("SSO Views Controller initialized");
|
||||
}
|
||||
|
||||
private ActionResult ServeView(string viewName)
|
||||
{
|
||||
IEnumerable<PluginPageInfo> pages = null;
|
||||
if (SSOPlugin.Instance == null)
|
||||
{
|
||||
return BadRequest("No plugin instance found");
|
||||
}
|
||||
|
||||
pages = SSOPlugin.Instance.GetViews();
|
||||
|
||||
if (pages == null)
|
||||
{
|
||||
return NotFound("Pages is null or empty");
|
||||
}
|
||||
|
||||
var view = pages.FirstOrDefault(pageInfo => pageInfo.Name == viewName, null);
|
||||
|
||||
if (view == null)
|
||||
{
|
||||
return NotFound("No matching view found");
|
||||
}
|
||||
#nullable enable
|
||||
Stream? stream = SSOPlugin.Instance.GetType().Assembly.GetManifestResourceStream(view.EmbeddedResourcePath);
|
||||
|
||||
if (stream == null)
|
||||
{
|
||||
_logger.LogError("Failed to get resource {Resource}", view.EmbeddedResourcePath);
|
||||
return NotFound();
|
||||
}
|
||||
#nullable disable
|
||||
return File(stream, MimeTypes.GetMimeType(view.EmbeddedResourcePath));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a html view.
|
||||
/// </summary>
|
||||
/// <param name="viewName">The name of the view / asset to fetch.</param>
|
||||
/// <returns>The html view with the specified name.</returns>
|
||||
[HttpGet("{viewName}")]
|
||||
public ActionResult GetView([FromRoute] string viewName)
|
||||
{
|
||||
return ServeView(viewName);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Xml.Serialization;
|
||||
|
||||
@@ -13,80 +14,293 @@ public class PluginConfiguration : MediaBrowser.Model.Plugins.BasePluginConfigur
|
||||
/// </summary>
|
||||
public PluginConfiguration()
|
||||
{
|
||||
SamlConfigs = new List<SamlConfig>();
|
||||
OIDConfigs = new List<OIDConfig>();
|
||||
SamlConfigs = new SerializableDictionary<string, SamlConfig>();
|
||||
OidConfigs = new SerializableDictionary<string, OidConfig>();
|
||||
}
|
||||
|
||||
[XmlArray("SamlConfigs")]
|
||||
[XmlArrayItem(typeof(SamlConfig), ElementName = "SamlConfigs")]
|
||||
public List<SamlConfig> SamlConfigs { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the SAML configurations available.
|
||||
/// </summary>
|
||||
[XmlElement("SamlConfigs")]
|
||||
public SerializableDictionary<string, SamlConfig> SamlConfigs { get; set; }
|
||||
|
||||
[XmlArray("OIDConfigs")]
|
||||
[XmlArrayItem(typeof(OIDConfig), ElementName = "OIDConfigs")]
|
||||
public List<OIDConfig> OIDConfigs { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the OpenID configurations available.
|
||||
/// </summary>
|
||||
[XmlElement("OidConfigs")]
|
||||
public SerializableDictionary<string, OidConfig> OidConfigs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The configuration required for a SAML flow.
|
||||
/// </summary>
|
||||
[XmlRoot("PluginConfiguration")]
|
||||
public class SamlConfig
|
||||
{
|
||||
private SerializableDictionary<string, Guid> _canonicalLinks;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SAML information endpoint.
|
||||
/// </summary>
|
||||
public string SamlEndpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SAML provider's client ID.
|
||||
/// </summary>
|
||||
public string SamlClientId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SAML public key.
|
||||
/// </summary>
|
||||
public string SamlCertificate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the provider is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether RBAC is enabled.
|
||||
/// </summary>
|
||||
public bool EnableAuthorization { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether all folders are allowed by default.
|
||||
/// </summary>
|
||||
public bool EnableAllFolders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets what folders should users have access to by default.
|
||||
/// </summary>
|
||||
public string[] EnabledFolders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the roles that are checked to determine whether the user is an administrator.
|
||||
/// </summary>
|
||||
public string[] AdminRoles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets what roles are checked to determine whether the user is allowed to use Jellyfin.
|
||||
/// </summary>
|
||||
public string[] Roles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether RBAC is used to manage folder access.
|
||||
/// </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>
|
||||
[XmlArray("FolderRoleMappings")]
|
||||
[XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMappings")]
|
||||
public List<FolderRoleMap> FolderRoleMapping { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// The configuration required for a OpenID flow.
|
||||
/// </summary>
|
||||
[XmlRoot("PluginConfiguration")]
|
||||
public class OIDConfig
|
||||
public class OidConfig
|
||||
{
|
||||
public string OIDEndpoint { get; set; }
|
||||
private SerializableDictionary<string, Guid> _canonicalLinks;
|
||||
|
||||
public string OIDClientId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the OpenID well-known information endpoint.
|
||||
/// </summary>
|
||||
public string OidEndpoint { get; set; }
|
||||
|
||||
public string OIDSecret { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets OpenID client ID.
|
||||
/// </summary>
|
||||
public string OidClientId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets OpenID shared secret.
|
||||
/// </summary>
|
||||
public string OidSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the provider is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether RBAC is enabled.
|
||||
/// </summary>
|
||||
public bool EnableAuthorization { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether all folders are allowed by default.
|
||||
/// </summary>
|
||||
public bool EnableAllFolders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets what folders should users have access to by default.
|
||||
/// </summary>
|
||||
public string[] EnabledFolders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the roles that are checked to determine whether the user is an administrator.
|
||||
/// </summary>
|
||||
public string[] AdminRoles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets what roles are checked to determine whether the user is allowed to use Jellyfin.
|
||||
/// </summary>
|
||||
public string[] Roles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether RBAC is used to manage folder access.
|
||||
/// </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>
|
||||
[XmlArray("FolderRoleMappings")]
|
||||
[XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMap")]
|
||||
[XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMappings")]
|
||||
public List<FolderRoleMap> FolderRoleMapping { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the claim to check roles against. Separated by "."s.
|
||||
/// </summary>
|
||||
public string RoleClaim { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or Sets additional Scopes to request access to in the authorization request.
|
||||
/// </summary>
|
||||
public string[] OidScopes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// The OpenID client ID.
|
||||
/// </summary>
|
||||
public class FolderRoleMap
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the role of the mapping.
|
||||
/// </summary>
|
||||
public string Role { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the folders that are allowed from the given role.
|
||||
/// </summary>
|
||||
public List<string> Folders { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
const ssoConfigurationPage = {
|
||||
pluginUniqueId: "505ce9d1-d916-42fa-86ca-673ef241d7df",
|
||||
loadConfiguration: (page) => {
|
||||
ApiClient.getPluginConfiguration(ssoConfigurationPage.pluginUniqueId).then(
|
||||
(config) => {
|
||||
ssoConfigurationPage.populateProviders(page, config.OidConfigs);
|
||||
},
|
||||
);
|
||||
|
||||
const folder_container = page.querySelector("#EnabledFolders");
|
||||
ssoConfigurationPage.populateFolders(folder_container);
|
||||
},
|
||||
populateProviders: (page, providers) => {
|
||||
// Clear providers in case there are out of date ones
|
||||
page
|
||||
.querySelector("#selectProvider")
|
||||
.querySelectorAll("option")
|
||||
.forEach((option) => {
|
||||
option.remove();
|
||||
});
|
||||
|
||||
// Add providers as options for the selector
|
||||
|
||||
Object.keys(providers).forEach((provider_name) => {
|
||||
var choice = new Option(provider_name, provider_name);
|
||||
|
||||
page.querySelector("#selectProvider").appendChild(choice);
|
||||
});
|
||||
},
|
||||
populateEnabledFolders: (folder_list, container) => {
|
||||
container.querySelectorAll(".folder-checkbox").forEach((e) => {
|
||||
e.checked = folder_list.includes(e.getAttribute("data-id"));
|
||||
});
|
||||
},
|
||||
serializeEnabledFolders: (container) => {
|
||||
return [...container.querySelectorAll(".folder-checkbox")]
|
||||
.filter((e) => e.checked)
|
||||
.map((e) => {
|
||||
return e.getAttribute("data-id");
|
||||
});
|
||||
},
|
||||
populateFolders: (container) => {
|
||||
return ApiClient.getJSON(
|
||||
ApiClient.getUrl("Library/MediaFolders", {
|
||||
IsHidden: false,
|
||||
}),
|
||||
).then((folders) => {
|
||||
ssoConfigurationPage._populateFolders(container, folders);
|
||||
});
|
||||
},
|
||||
/*
|
||||
container: html element
|
||||
folders.Items: array of objects, with .Id & .Name
|
||||
*/
|
||||
_populateFolders: (container, folders) => {
|
||||
container
|
||||
.querySelectorAll(".emby-checkbox-label")
|
||||
.forEach((e) => e.remove());
|
||||
|
||||
const checkboxes = folders.Items.map((folder) => {
|
||||
var out = document.createElement("label");
|
||||
|
||||
out.innerHTML = `
|
||||
<input
|
||||
is="emby-checkbox"
|
||||
class="folder-checkbox chkFolder"
|
||||
data-id="${folder.Id}"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>${folder.Name}</span>
|
||||
`;
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
checkboxes.forEach((e) => {
|
||||
container.appendChild(e);
|
||||
});
|
||||
},
|
||||
|
||||
populateRoleMappings: (folder_role_mappings, container) => {
|
||||
container
|
||||
.querySelectorAll(".sso-role-mapping-container")
|
||||
.forEach((e) => e.remove());
|
||||
|
||||
const mapping_elements = folder_role_mappings.map((mapping) => {
|
||||
var elem = document.createElement("div");
|
||||
|
||||
elem.classList.add("sso-role-mapping-container");
|
||||
elem.innerHTML = `
|
||||
<label
|
||||
class="inputLabel inputLabelUnfocused sso-role-mapping-input-label"
|
||||
>Role:</label>
|
||||
<div class="listItem">
|
||||
<input
|
||||
is="emby-input"
|
||||
required=""
|
||||
type="text"
|
||||
class="listItemBody sso-role-mapping-name"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
is="paper-icon-button-light"
|
||||
class="listItemButton sso-remove-role-mapping"
|
||||
>
|
||||
<span class="material-icons remove_circle" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="checkboxList paperList sso-folder-list"
|
||||
></div>
|
||||
`;
|
||||
|
||||
var checklist = elem.querySelector(".sso-folder-list");
|
||||
const enabled_folders = mapping["Folders"];
|
||||
|
||||
ssoConfigurationPage
|
||||
.populateFolders(checklist)
|
||||
.then(() =>
|
||||
ssoConfigurationPage.populateEnabledFolders(
|
||||
enabled_folders,
|
||||
checklist,
|
||||
),
|
||||
);
|
||||
|
||||
elem.querySelector(".sso-role-mapping-name").value = mapping["Role"];
|
||||
elem
|
||||
.querySelector(".sso-remove-role-mapping")
|
||||
.addEventListener(
|
||||
"click",
|
||||
ssoConfigurationPage.handleRoleMappingRemove,
|
||||
);
|
||||
|
||||
return elem;
|
||||
});
|
||||
|
||||
mapping_elements.forEach((e) => container.appendChild(e));
|
||||
},
|
||||
serializeRoleMappings: (container) => {
|
||||
var out = [];
|
||||
const roles = [
|
||||
...container.querySelectorAll(".sso-role-mapping-container"),
|
||||
].forEach((elem) => {
|
||||
const role = elem.querySelector(".sso-role-mapping-name").value;
|
||||
const checklist = elem.querySelector(".sso-folder-list");
|
||||
|
||||
out.push({
|
||||
Role: role,
|
||||
Folders: ssoConfigurationPage.serializeEnabledFolders(checklist),
|
||||
});
|
||||
});
|
||||
|
||||
return out;
|
||||
},
|
||||
handleRoleMappingRemove: (evt) => {
|
||||
const targeted_mapping = evt.target.closest(".sso-role-mapping-container");
|
||||
targeted_mapping.remove();
|
||||
},
|
||||
listArgumentsByType: (page) => {
|
||||
const json_class = ".sso-json";
|
||||
const toggle_class = ".sso-toggle";
|
||||
const text_class = ".sso-text";
|
||||
const text_list_class = ".sso-line-list";
|
||||
|
||||
const folder_list_fields = ["EnabledFolders"];
|
||||
const role_map_fields = ["FolderRoleMapping"];
|
||||
|
||||
const oidc_form = page.querySelector("#sso-new-oidc-provider");
|
||||
|
||||
const text_fields = [...oidc_form.querySelectorAll(text_class)].map(
|
||||
(e) => e.id,
|
||||
);
|
||||
|
||||
const json_fields = [...oidc_form.querySelectorAll(json_class)].map(
|
||||
(e) => e.id,
|
||||
);
|
||||
|
||||
const text_list_fields = [
|
||||
...oidc_form.querySelectorAll(text_list_class),
|
||||
].map((e) => e.id);
|
||||
|
||||
const check_fields = [...oidc_form.querySelectorAll(toggle_class)].map(
|
||||
(e) => e.id,
|
||||
);
|
||||
|
||||
const output = {
|
||||
json_fields,
|
||||
text_list_fields,
|
||||
text_fields,
|
||||
check_fields,
|
||||
folder_list_fields,
|
||||
role_map_fields,
|
||||
};
|
||||
|
||||
return output;
|
||||
},
|
||||
fillTextList: (text_list, element) => {
|
||||
// text_list is an array of strings
|
||||
// element is an input element
|
||||
const val = text_list.join("\r\n");
|
||||
element.value = val;
|
||||
},
|
||||
parseTextList: (element) => {
|
||||
// Return the parsed text list
|
||||
var out = element.value
|
||||
.split("\n")
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e);
|
||||
return out;
|
||||
},
|
||||
loadProvider: (page, provider_name) => {
|
||||
ApiClient.getPluginConfiguration(ssoConfigurationPage.pluginUniqueId).then(
|
||||
(config) => {
|
||||
var provider = config.OidConfigs[provider_name] || {};
|
||||
|
||||
const form_elements = ssoConfigurationPage.listArgumentsByType(page);
|
||||
|
||||
page.querySelector("#OidProviderName").value = provider_name;
|
||||
|
||||
form_elements.text_fields.forEach((id) => {
|
||||
if (provider[id]) page.querySelector("#" + id).value = provider[id];
|
||||
});
|
||||
|
||||
form_elements.json_fields.forEach((id) => {
|
||||
if (provider[id])
|
||||
page.querySelector("#" + id).value = JSON.stringify(provider[id]);
|
||||
});
|
||||
|
||||
form_elements.text_list_fields.forEach((id) => {
|
||||
if (provider[id])
|
||||
ssoConfigurationPage.fillTextList(
|
||||
provider[id],
|
||||
page.querySelector("#" + id),
|
||||
);
|
||||
});
|
||||
|
||||
form_elements.folder_list_fields.forEach((id) => {
|
||||
if (provider[id]) {
|
||||
ssoConfigurationPage.populateEnabledFolders(
|
||||
provider[id],
|
||||
page.querySelector(`#${id}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
form_elements.check_fields.forEach((id) => {
|
||||
if (provider[id]) page.querySelector("#" + id).checked = provider[id];
|
||||
});
|
||||
|
||||
form_elements.role_map_fields.forEach((id) => {
|
||||
const elem = page.querySelector(`#${id}`);
|
||||
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}?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
ApiClient.getPluginConfiguration(
|
||||
ssoConfigurationPage.pluginUniqueId,
|
||||
).then((config) => {
|
||||
if (!config.OidConfigs.hasOwnProperty(provider_name)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
delete config.OidConfigs[provider_name];
|
||||
ApiClient.updatePluginConfiguration(
|
||||
ssoConfigurationPage.pluginUniqueId,
|
||||
config,
|
||||
).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
ssoConfigurationPage.loadConfiguration(page);
|
||||
|
||||
Dashboard.alert("Provider removed");
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
saveProvider: (page, provider_name) => {
|
||||
return new Promise((resolve) => {
|
||||
const form_elements = ssoConfigurationPage.listArgumentsByType(page);
|
||||
|
||||
ApiClient.getPluginConfiguration(
|
||||
ssoConfigurationPage.pluginUniqueId,
|
||||
).then((config) => {
|
||||
var current_config = {};
|
||||
if (config.OidConfigs.hasOwnProperty(provider_name)) {
|
||||
current_config = config.OidConfigs[provider_name];
|
||||
}
|
||||
|
||||
form_elements.text_fields.forEach((id) => {
|
||||
const value = page.querySelector("#" + id).value;
|
||||
if (value) current_config[id] = page.querySelector("#" + id).value;
|
||||
});
|
||||
|
||||
form_elements.json_fields.forEach((id) => {
|
||||
const value = page.querySelector("#" + id).value;
|
||||
if (value) current_config[id] = JSON.parse(value);
|
||||
});
|
||||
|
||||
form_elements.check_fields.forEach((id) => {
|
||||
current_config[id] = page.querySelector("#" + id).checked;
|
||||
});
|
||||
|
||||
form_elements.text_list_fields.forEach((id) => {
|
||||
current_config[id] = ssoConfigurationPage.parseTextList(
|
||||
page.querySelector("#" + id),
|
||||
);
|
||||
});
|
||||
|
||||
form_elements.folder_list_fields.forEach((id) => {
|
||||
const elem = page.querySelector(`#${id}`);
|
||||
current_config[id] =
|
||||
ssoConfigurationPage.serializeEnabledFolders(elem);
|
||||
});
|
||||
|
||||
form_elements.role_map_fields.forEach((id) => {
|
||||
const elem = page.querySelector(`#${id}`);
|
||||
current_config[id] = ssoConfigurationPage.serializeRoleMappings(elem);
|
||||
});
|
||||
|
||||
config.OidConfigs[provider_name] = current_config;
|
||||
|
||||
ApiClient.updatePluginConfiguration(
|
||||
ssoConfigurationPage.pluginUniqueId,
|
||||
config,
|
||||
).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
ssoConfigurationPage.loadConfiguration(page);
|
||||
ssoConfigurationPage.loadProvider(page, provider_name);
|
||||
|
||||
page.querySelector("#selectProvider").value = provider_name;
|
||||
Dashboard.alert("Settings saved.");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
addTextAreaStyle: (view) => {
|
||||
var style = document.createElement("link");
|
||||
style.rel = "stylesheet";
|
||||
style.href =
|
||||
ApiClient.getUrl("web/configurationpage") + "?name=SSO-Auth.css";
|
||||
view.appendChild(style);
|
||||
},
|
||||
};
|
||||
|
||||
export default function (view) {
|
||||
ssoConfigurationPage.addTextAreaStyle(view);
|
||||
ssoConfigurationPage.loadConfiguration(view);
|
||||
|
||||
ssoConfigurationPage.listArgumentsByType(view);
|
||||
|
||||
view.querySelector("#SaveProvider").addEventListener("click", (e) => {
|
||||
const target_provider = view.querySelector("#OidProviderName").value;
|
||||
|
||||
ssoConfigurationPage.saveProvider(view, target_provider);
|
||||
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
|
||||
view.querySelector("#LoadProvider").addEventListener("click", (e) => {
|
||||
const target_provider = view.querySelector("#selectProvider").value;
|
||||
|
||||
ssoConfigurationPage.loadProvider(view, target_provider);
|
||||
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
|
||||
view.querySelector("#DeleteProvider").addEventListener("click", (e) => {
|
||||
const target_provider = view.querySelector("#selectProvider").value;
|
||||
|
||||
ssoConfigurationPage.deleteProvider(view, target_provider);
|
||||
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
|
||||
view.querySelector("#AddRoleMapping").addEventListener("click", (e) => {
|
||||
const container = view.querySelector("#FolderRoleMapping");
|
||||
const current_mappings =
|
||||
ssoConfigurationPage.serializeRoleMappings(container);
|
||||
current_mappings.push({ Role: "", Folders: [] });
|
||||
console.log(current_mappings);
|
||||
ssoConfigurationPage.populateRoleMappings(current_mappings, container);
|
||||
});
|
||||
|
||||
view.querySelector("#sso-self-service-link").href =
|
||||
ApiClient.getUrl("/SSOViews/linking");
|
||||
}
|
||||
@@ -1,15 +1,621 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<head>
|
||||
<title>SSO</title>
|
||||
</head>
|
||||
<body>
|
||||
<div data-role="page" class="page type-interior pluginConfigurationPage esqConfigurationPage">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<a href="https://github.com/9p4/jellyfin-plugin-sso/blob/main/README.md">Review the documentation. This plugin is configured via the API.</a>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="sso-config-page"
|
||||
data-role="page"
|
||||
class="page type-interior pluginConfigurationPage esqConfigurationPage"
|
||||
data-controller="__plugin/SSO-Auth.js"
|
||||
>
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<div class="sectionTitleContainer flex align-items-center">
|
||||
<h2 class="sectionTitle">SSO Settings:</h2>
|
||||
<a
|
||||
is="emby-button"
|
||||
class="raised button-alt headerHelpButton"
|
||||
target="_blank"
|
||||
href="https://github.com/9p4/jellyfin-plugin-sso"
|
||||
>${Help}</a
|
||||
>
|
||||
</div>
|
||||
<p>
|
||||
<i>Note:</i>
|
||||
Making changes to this configuration requires a restart of Jellyfin.
|
||||
<br />
|
||||
This plug-in is in early development, not all configuration options
|
||||
have been implented in the UI, for example, SAML provider
|
||||
configuration has not been implemented.
|
||||
<br />
|
||||
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.
|
||||
<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">
|
||||
<div
|
||||
class="verticalSection"
|
||||
is="emby-collapse"
|
||||
title="Select Existing Provider to Modify"
|
||||
>
|
||||
<div class="collapseContent">
|
||||
<div class="selectContainer">
|
||||
<label class="selectLabel" for="selectProvider"
|
||||
>Name of OID Provider:
|
||||
</label>
|
||||
<select
|
||||
is="emby-select"
|
||||
id="selectProvider"
|
||||
name="selectProvider"
|
||||
class="emby-select-withcolor emby-select"
|
||||
></select>
|
||||
<div class="selectArrowContainer">
|
||||
<div style="visibility: hidden; display: none">0</div>
|
||||
<span
|
||||
class="selectArrow material-icons keyboard_arrow_down"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="LoadProvider"
|
||||
is="emby-button"
|
||||
type="button"
|
||||
class="raised button-submit block emby-button"
|
||||
>
|
||||
<span>Load Provider</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="DeleteProvider"
|
||||
is="emby-button"
|
||||
type="button"
|
||||
class="raised button-delete block emby-button"
|
||||
>
|
||||
<span>Delete Provider</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="sso-new-oidc-provider" class="esqConfigurationForm">
|
||||
<div
|
||||
is="emby-collapse"
|
||||
data-expanded="true"
|
||||
title="Add / Update Provider Configuration"
|
||||
class="verticalSection verticalSection-extrabottompadding"
|
||||
>
|
||||
<div class="collapseContent">
|
||||
<div class="inputContainer">
|
||||
<label
|
||||
class="inputLabel inputLabelUnfocused"
|
||||
for="OidProviderName"
|
||||
>Name of OID Provider:</label
|
||||
>
|
||||
<input
|
||||
is="emby-input"
|
||||
id="OidProviderName"
|
||||
required=""
|
||||
type="text"
|
||||
class="sso-text"
|
||||
/>
|
||||
<div class="fieldDescription">
|
||||
The name used by Jellyfin to identify the OID provider.
|
||||
<br />
|
||||
If an OID provider with a matching name does not exist, a
|
||||
new provider with this name will be created.
|
||||
<br />
|
||||
If an OID provider with a matching name already exists, the
|
||||
settings for that provider will be updated.
|
||||
</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label
|
||||
class="inputLabel inputLabelUnfocused"
|
||||
for="OidEndpoint"
|
||||
>OID Endpoint:</label
|
||||
>
|
||||
<input
|
||||
is="emby-input"
|
||||
id="OidEndpoint"
|
||||
required=""
|
||||
type="text"
|
||||
class="sso-text"
|
||||
/>
|
||||
<div class="fieldDescription">
|
||||
The OpenID endpoint. Must have a .well-known path available.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label
|
||||
class="inputLabel inputLabelUnfocused"
|
||||
for="OidClientId"
|
||||
>OpenID Client ID:</label
|
||||
>
|
||||
<input
|
||||
is="emby-input"
|
||||
id="OidClientId"
|
||||
required=""
|
||||
type="text"
|
||||
class="sso-text"
|
||||
/>
|
||||
<div class="fieldDescription">
|
||||
The OpenID client ID, for this media server instance. This
|
||||
is configured on the OIDC provider to uniquely identify
|
||||
<strong>this</strong> Jellyfin instance.
|
||||
</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="OidSecret"
|
||||
>OID Secret:</label
|
||||
>
|
||||
<input
|
||||
is="emby-input"
|
||||
id="OidSecret"
|
||||
required=""
|
||||
type="text"
|
||||
class="sso-text"
|
||||
/>
|
||||
<div class="fieldDescription">
|
||||
The OpenID secret. Randomly generated & shared.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="checkboxContainer checkboxContainer-withDescription"
|
||||
>
|
||||
<label>
|
||||
<input
|
||||
is="emby-checkbox"
|
||||
id="Enabled"
|
||||
name="Enabled"
|
||||
type="checkbox"
|
||||
class="sso-toggle"
|
||||
/>
|
||||
<span>Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="checkboxContainer checkboxContainer-withDescription"
|
||||
>
|
||||
<label>
|
||||
<input
|
||||
is="emby-checkbox"
|
||||
id="EnableAuthorization"
|
||||
name="EnableAuthorization"
|
||||
type="checkbox"
|
||||
class="sso-toggle"
|
||||
/>
|
||||
<span>Enable Authorization by Plugin</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">
|
||||
Determines if the plugin sets permissions for the user.
|
||||
<br />
|
||||
If false, the user will start with no permissions and an
|
||||
administrator will add permissions.
|
||||
<br />
|
||||
The permissions of existing users will not be rewritten on
|
||||
subsequent logins.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="checkboxContainer checkboxContainer-withDescription"
|
||||
>
|
||||
<label>
|
||||
<input
|
||||
is="emby-checkbox"
|
||||
id="EnableAllFolders"
|
||||
name="EnableAllFolders"
|
||||
type="checkbox"
|
||||
class="sso-toggle"
|
||||
/>
|
||||
<span>Enable All Folders</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">
|
||||
If enabled, all libraries will be accessible to any user
|
||||
that logs in through this provider.
|
||||
</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label
|
||||
class="inputLabel inputLabelUnfocused"
|
||||
for="EnabledFolders"
|
||||
>Enabled Folders:</label
|
||||
>
|
||||
<div
|
||||
id="EnabledFolders"
|
||||
class="checkboxList paperList checkboxList-paperList sso-folder-list sso-bordered-list"
|
||||
></div>
|
||||
<div class="fieldDescription">
|
||||
Determines which libraries will be accessible to a user that
|
||||
logs in through this provider.
|
||||
<br />
|
||||
If <strong>"Enable All Folders"</strong> is checked, then
|
||||
this has no effect.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="Roles"
|
||||
>Roles:</label
|
||||
>
|
||||
<textarea
|
||||
is="emby-textarea"
|
||||
id="Roles"
|
||||
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 />
|
||||
If a user has any of these roles, then the user is
|
||||
authenticated. This validates the OpenID response against
|
||||
the claim set in <strong>"RoleClaim"</strong>.
|
||||
<br />
|
||||
Leave blank to disable role checking.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="AdminRoles"
|
||||
>Admin Roles:</label
|
||||
>
|
||||
<textarea
|
||||
is="emby-textarea"
|
||||
id="AdminRoles"
|
||||
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 admin privilege.
|
||||
<br />
|
||||
If unset will not grant admin privileges.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="checkboxContainer checkboxContainer-withDescription"
|
||||
>
|
||||
<label>
|
||||
<input
|
||||
is="emby-checkbox"
|
||||
id="EnableFolderRoles"
|
||||
name="EnableFolderRoles"
|
||||
type="checkbox"
|
||||
class="sso-toggle"
|
||||
/>
|
||||
<span>Enable Role-Based Folder Access:</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">
|
||||
Determines if user roles should be used to control library
|
||||
access.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label
|
||||
class="inputLabel inputLabelUnfocused"
|
||||
for="FolderRoleMapping"
|
||||
>Folder Role Mapping:</label
|
||||
>
|
||||
<button
|
||||
is="emby-button"
|
||||
id="AddRoleMapping"
|
||||
type="button"
|
||||
class="fab btnAddFolder submit"
|
||||
title="${Add}"
|
||||
>
|
||||
<span class="material-icons add" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div id="FolderRoleMapping" class="sso-role-map"></div>
|
||||
<div class="fieldDescription">
|
||||
Map roles (given by <strong>"Role Claim"</strong>) to lists
|
||||
of libraries. If a user has a given role, they will have
|
||||
access to the corresponding libraries. If
|
||||
<strong>"Enable Role-Based Folder Access"</strong> is
|
||||
disabled, has no effect.
|
||||
</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
|
||||
>
|
||||
<input
|
||||
is="emby-input"
|
||||
id="RoleClaim"
|
||||
required=""
|
||||
type="text"
|
||||
class="sso-text"
|
||||
/>
|
||||
<div class="fieldDescription">
|
||||
This is the value in the OpenID response to check for roles.
|
||||
The first element is the claim type, the subsequent values
|
||||
are to parse the JSON of the claim value. Use a
|
||||
<code>"\."</code> to denote a literal ".". This expects a
|
||||
list of strings from the OIDC server.
|
||||
<br />
|
||||
For Keycloak, it is <code>realm_access.roles</code> by
|
||||
default.
|
||||
<br />
|
||||
For Authelia, it is <code>groups</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="OidScopes"
|
||||
>Request Additional Scopes:</label
|
||||
>
|
||||
<textarea
|
||||
is="emby-textarea"
|
||||
id="OidScopes"
|
||||
required=""
|
||||
type="text"
|
||||
class="sso-line-list emby-textarea"
|
||||
></textarea>
|
||||
<div class="fieldDescription">
|
||||
Specify additional scopes to include in the OIDC request.
|
||||
<br />
|
||||
One scope per line, each line should contain a scope name to
|
||||
include in the OIDC request.
|
||||
<br />
|
||||
For some OIDC providers (For example,
|
||||
<a
|
||||
is="emby-linkbutton"
|
||||
href="https://github.com/9p4/jellyfin-plugin-sso/issues/23#issuecomment-1112237616"
|
||||
class="button-link"
|
||||
>authelia</a
|
||||
>), additional scopes may be required in order to validate
|
||||
group membership in role claim.
|
||||
<br />
|
||||
Leave blank to only request the default scopes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label
|
||||
class="inputLabel inputLabelUnfocused"
|
||||
for="DefaultProvider"
|
||||
>Set default Provider:</label
|
||||
>
|
||||
<input
|
||||
is="emby-input"
|
||||
id="DefaultProvider"
|
||||
type="text"
|
||||
class="sso-text"
|
||||
/>
|
||||
<div class="fieldDescription">
|
||||
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.<br />A common option is
|
||||
<code
|
||||
>Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider</code
|
||||
>
|
||||
for the default provider.
|
||||
</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"
|
||||
type="button"
|
||||
class="raised button-submit block emby-button"
|
||||
>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- Polyfill styles that are missing when serving without dashboard -->
|
||||
<link rel="stylesheet" href="emby-restyle.css" />
|
||||
<link id="theme-style" rel="stylesheet" />
|
||||
<script defer>
|
||||
import("./ApiClient.js").then((clientModule) => {
|
||||
import("./linking.js").then((renderer) => {
|
||||
const view = document.querySelector("#sso-config-page");
|
||||
|
||||
document.querySelector("#theme-style").href = ApiClient.getUrl(
|
||||
"/web/themes/dark/theme.css",
|
||||
);
|
||||
|
||||
const homeButton = document.querySelector("a.emby-button.home");
|
||||
homeButton.href = ApiClient.serverAddress();
|
||||
homeButton.style.display = "";
|
||||
renderer.default(view);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<title>SSO Linking</title>
|
||||
</head>
|
||||
<body class="force-scroll dashboardDocument mouseIdle">
|
||||
<div
|
||||
id="sso-config-page"
|
||||
data-role="page"
|
||||
class="page type-interior pluginConfigurationPage esqConfigurationPage"
|
||||
data-controller="__plugin/SSO-Auth-linking.js"
|
||||
>
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<div class="sectionTitleContainer flex align-items-center">
|
||||
<a class="raised emby-button home" style="display: none">
|
||||
<span class="material-icons home" aria-hidden="true"></span
|
||||
><span>Home</span>
|
||||
</a>
|
||||
<h2 class="sectionTitle">SSO Linking:</h2>
|
||||
<a
|
||||
is="emby-button"
|
||||
class="raised button-alt headerHelpButton"
|
||||
target="_blank"
|
||||
href="https://github.com/9p4/jellyfin-plugin-sso"
|
||||
>${Help}</a
|
||||
>
|
||||
</div>
|
||||
<p>
|
||||
Use the
|
||||
<span class="fab" aria-hidden="true">
|
||||
<span class="material-icons add" aria-hidden="true"></span>
|
||||
</span>
|
||||
button to create a new link for the given provider.
|
||||
<br />
|
||||
You may remove existing links by selecting them and pressing the
|
||||
"Delete" button.
|
||||
</p>
|
||||
<p>
|
||||
See the
|
||||
<a
|
||||
is="emby-linkbutton"
|
||||
href="https://github.com/9p4/jellyfin-plugin-sso"
|
||||
class="button-link"
|
||||
>help page</a
|
||||
>
|
||||
and
|
||||
<a
|
||||
is="emby-linkbutton"
|
||||
href="https://github.com/9p4/jellyfin-plugin-sso/projects/1"
|
||||
class="button-link"
|
||||
>roadmap
|
||||
</a>
|
||||
for more information.
|
||||
</p>
|
||||
<label class="checkbox-wrapper">
|
||||
<input is="emby-checkbox" id="enable-delete" type="checkbox" />
|
||||
<span class="checkbox-label">Enable "Delete" button.</span>
|
||||
</label>
|
||||
<h1 class="sectionTitle">Link a provider</h1>
|
||||
<div class="verticalSection" title="Link a provider">
|
||||
<h2 class="sectionTitle">SAML</h2>
|
||||
<div id="sso-provider-list-saml" data-id="saml"></div>
|
||||
<h2 class="sectionTitle">OID</h2>
|
||||
<div id="sso-provider-list-oid" data-id="oid"></div>
|
||||
</div>
|
||||
<button
|
||||
id="btn-delete-selected-links"
|
||||
type="button"
|
||||
class="button-delete raised emby-button"
|
||||
disabled="true"
|
||||
>
|
||||
<span class="material-icons delete" aria-hidden="true"></span>
|
||||
<span>Delete Selected</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,186 @@
|
||||
const ssoConfigLinking = {
|
||||
pluginUniqueId: "505ce9d1-d916-42fa-86ca-673ef241d7df",
|
||||
loadProviders: (view) => {
|
||||
const provider_list_id = "sso-provider-list";
|
||||
const provider_list_saml_id = `${provider_list_id}-saml`;
|
||||
const provider_list_oid_id = `${provider_list_id}-oid`;
|
||||
|
||||
const provider_list_saml = view.querySelector(`#${provider_list_saml_id}`);
|
||||
const provider_list_oid = view.querySelector(`#${provider_list_oid_id}`);
|
||||
provider_list_saml.innerHTML = "";
|
||||
provider_list_oid.innerHTML = "";
|
||||
|
||||
fetch(new Request(ApiClient.getUrl("sso/OID/GetNames"))).then((resp) => {
|
||||
resp.json().then((config_names) => {
|
||||
ssoConfigLinking.loadProviderList(
|
||||
provider_list_oid,
|
||||
config_names,
|
||||
"oid",
|
||||
);
|
||||
});
|
||||
});
|
||||
fetch(new Request(ApiClient.getUrl("sso/SAML/GetNames"))).then((resp) => {
|
||||
resp.json().then((config_names) => {
|
||||
ssoConfigLinking.loadProviderList(
|
||||
provider_list_saml,
|
||||
config_names,
|
||||
"saml",
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
loadProviderList: (container, providers, provider_mode) => {
|
||||
providers.forEach((provider_name) => {
|
||||
var provider_config = document.createElement("div");
|
||||
provider_config.classList.add("sso-provider-links-container");
|
||||
provider_config.setAttribute("data-id", provider_name);
|
||||
|
||||
provider_config.innerHTML = `
|
||||
<label
|
||||
class="inputLabel inputLabelUnfocused sso-provider-link-title"
|
||||
>${provider_name}
|
||||
</label>
|
||||
<a
|
||||
class="fab emby-button sso-provider-add-link"
|
||||
>
|
||||
<span class="material-icons add" aria-hidden="true"></span>
|
||||
</a>
|
||||
<div
|
||||
class="sso-provider-existing-links-container"
|
||||
data-provider="${provider_name}"
|
||||
></div>
|
||||
`;
|
||||
var add_provider = provider_config.querySelector(
|
||||
".sso-provider-add-link",
|
||||
);
|
||||
|
||||
//const provider_name_css = ssoConfigLinking.safeCSSId(provider_name);
|
||||
//provider_link.id = "sso-provider-" + provider_name_css;
|
||||
//provider_link.classList.add("sso-provider-" + provider_name_css);
|
||||
add_provider.classList.add("sso-provider");
|
||||
|
||||
add_provider.href = ApiClient.getUrl(
|
||||
`/SSO/${provider_mode}/p/${provider_name}?isLinking=true`,
|
||||
);
|
||||
|
||||
container.appendChild(provider_config);
|
||||
});
|
||||
|
||||
const currentUserId = ApiClient.getCurrentUserId();
|
||||
|
||||
if (currentUserId) {
|
||||
ApiClient.fetch(
|
||||
{
|
||||
type: "GET",
|
||||
url: ApiClient.getUrl(`sso/${provider_mode}/links/${currentUserId}`),
|
||||
},
|
||||
true,
|
||||
).then((resp) => {
|
||||
resp.json().then((provider_map) => {
|
||||
console.log({ provider_map, currentUserId });
|
||||
|
||||
Object.keys(provider_map).forEach((provider_name) => {
|
||||
const provider_container = container.querySelector(
|
||||
`.sso-provider-existing-links-container[data-provider="${provider_name}"]`,
|
||||
);
|
||||
ssoConfigLinking.populateExistingLinks(
|
||||
provider_container,
|
||||
provider_mode,
|
||||
provider_name,
|
||||
provider_map[provider_name],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
populateExistingLinks: (
|
||||
container,
|
||||
provider_mode,
|
||||
provider_name,
|
||||
canonical_names,
|
||||
) => {
|
||||
container
|
||||
.querySelectorAll(".sso-provider-link-checkbox-wrapper")
|
||||
.forEach((e) => e.remove());
|
||||
|
||||
const checkboxes = canonical_names.map((canonical_name) => {
|
||||
var out = document.createElement("label");
|
||||
out.classList.add("sso-provider-link-checkbox-wrapper");
|
||||
out.classList.add("checkbox-wrapper");
|
||||
out.innerHTML = `
|
||||
<input
|
||||
is="emby-checkbox"
|
||||
class="sso-link-checkbox"
|
||||
data-id="${canonical_name}"
|
||||
data-mode="${provider_mode}"
|
||||
data-provider="${provider_name}"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span class="checkbox-label">${canonical_name}</span>
|
||||
`;
|
||||
return out;
|
||||
});
|
||||
|
||||
checkboxes.forEach((e) => {
|
||||
container.appendChild(e);
|
||||
});
|
||||
},
|
||||
|
||||
handleDeleteButtonPressed: (evt, view) => {
|
||||
if (evt.target.disabled) return;
|
||||
|
||||
const currentUserId = ApiClient.getCurrentUserId();
|
||||
if (!currentUserId) return;
|
||||
|
||||
const delete_requests = [...view.querySelectorAll(".sso-link-checkbox")]
|
||||
.filter((checkbox_link) => {
|
||||
const canonical_name = checkbox_link.getAttribute("data-id");
|
||||
const provider_name = checkbox_link.getAttribute("data-provider");
|
||||
const provider_mode = checkbox_link.getAttribute("data-mode");
|
||||
|
||||
if (![canonical_name, provider_name, provider_mode].every((e) => e)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!checkbox_link.checked) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((checked_link) => {
|
||||
const canonical_name = checked_link.getAttribute("data-id");
|
||||
const provider_name = checked_link.getAttribute("data-provider");
|
||||
const provider_mode = checked_link.getAttribute("data-mode");
|
||||
|
||||
return ApiClient.fetch({
|
||||
type: "DELETE",
|
||||
url: ApiClient.getUrl(
|
||||
`sso/${provider_mode}/link/${provider_name}/${currentUserId}/${canonical_name}`,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
Promise.all(delete_requests).then((values) => {
|
||||
console.log({ message: "Delete requests handled", values });
|
||||
window.location.reload();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default function (view) {
|
||||
ssoConfigLinking.loadProviders(view);
|
||||
|
||||
view.querySelector("#enable-delete").addEventListener("change", (e) => {
|
||||
view.querySelector("#btn-delete-selected-links").disabled =
|
||||
!e.target.checked;
|
||||
});
|
||||
|
||||
view
|
||||
.querySelector("#btn-delete-selected-links")
|
||||
.addEventListener("click", (e) =>
|
||||
ssoConfigLinking.handleDeleteButtonPressed(e, view),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
.emby-textarea {
|
||||
display: block;
|
||||
margin: 0;
|
||||
margin-bottom: 0 !important;
|
||||
|
||||
/* Remove select styling */
|
||||
|
||||
/* Font size must the 16px or larger to prevent iOS page zoom on focus */
|
||||
font-size: inherit;
|
||||
|
||||
/* General select styles: change as needed */
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
padding: 0.35em 0.25em;
|
||||
|
||||
/* Prevent padding from causing width overflow */
|
||||
box-sizing: border-box;
|
||||
outline: none !important;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.emby-textarea::-moz-focus-inner {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.textareaLabel {
|
||||
display: inline-block;
|
||||
transition: all 0.2s ease-out;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.emby-textarea + .fieldDescription {
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
.sso-role-mapping-container,
|
||||
.sso-bordered-list {
|
||||
/*
|
||||
border-color: #101010;
|
||||
border-color: #383838;
|
||||
*/
|
||||
border-color: rgba(255, 255, 255, 0.135);
|
||||
|
||||
padding-top: 0.5em;
|
||||
border-style: solid;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
.sso-role-mapping-container + .sso-role-mapping-container,
|
||||
.sso-bordered-list + .sso-bordered-list {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.sso-role-mapping-container .sso-folder-list {
|
||||
padding-left: 1em;
|
||||
padding-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.sso-role-mapping-input-label {
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
@@ -3,22 +3,35 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.SSO_Auth</RootNamespace>
|
||||
<AssemblyVersion>3.1.0.1</AssemblyVersion>
|
||||
<FileVersion>3.1.0.1</FileVersion>
|
||||
<AssemblyVersion>3.3.0.0</AssemblyVersion>
|
||||
<FileVersion>3.3.0.0</FileVersion>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="IdentityModel.OidcClient" Version="5.0.0" />
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.*-*" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.*-*" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<PackageReference Include="System.Security.Cryptography.Xml" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
+89
-4
@@ -8,26 +8,111 @@ using MediaBrowser.Model.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SSO_Auth;
|
||||
|
||||
public class SSOPlugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
/// <summary>
|
||||
/// The SSO plugin class.
|
||||
/// </summary>
|
||||
public class SSOPlugin : BasePlugin<PluginConfiguration>, IPlugin, IHasWebPages
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SSOPlugin"/> class.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">Internal Jellyfin interface for the ApplicationPath.</param>
|
||||
/// <param name="xmlSerializer">Internal Jellyfin interface for the XML information.</param>
|
||||
public SSOPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the instance of the SSO plugin.
|
||||
/// </summary>
|
||||
public static SSOPlugin Instance { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the SSO plugin.
|
||||
/// </summary>
|
||||
public override string Name => "SSO-Auth";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the GUID of the SSO plugin.
|
||||
/// </summary>
|
||||
public override Guid Id => Guid.Parse("505ce9d1-d916-42fa-86ca-673ef241d7df");
|
||||
|
||||
/// <summary>
|
||||
/// Returns the available internal web pages of this plugin.
|
||||
/// </summary>
|
||||
/// <returns>A list of internal webpages in this application.</returns>
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
yield return new PluginPageInfo
|
||||
return new[]
|
||||
{
|
||||
Name = Name,
|
||||
EmbeddedResourcePath = $"{GetType().Namespace}.Config.configPage.html"
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name,
|
||||
EmbeddedResourcePath = $"{GetType().Namespace}.Config.configPage.html"
|
||||
},
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name + ".js",
|
||||
EmbeddedResourcePath = $"{GetType().Namespace}.Config.config.js"
|
||||
},
|
||||
new PluginPageInfo
|
||||
{
|
||||
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"
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,33 +18,61 @@ using System.Xml;
|
||||
|
||||
namespace Jellyfin.Plugin.SSO_Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SAML response.
|
||||
/// </summary>
|
||||
public class Response
|
||||
{
|
||||
private readonly X509Certificate2 _certificate;
|
||||
private XmlDocument _xmlDoc;
|
||||
private XmlNamespaceManager _xmlNameSpaceManager; // we need this one to run our XPath queries on the SAML XML
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Response"/> class.
|
||||
/// </summary>
|
||||
/// <param name="certificateStr">The certificate formatted as a Base64 string.</param>
|
||||
/// <param name="responseString">The SAML response formatted as a string.</param>
|
||||
public Response(string certificateStr, string responseString)
|
||||
: this(Convert.FromBase64String(certificateStr), responseString)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Response"/> class.
|
||||
/// </summary>
|
||||
/// <param name="certificateBytes">The certificate formatted as an array of bytes.</param>
|
||||
/// <param name="responseString">The SAML response formatted as a string.</param>
|
||||
public Response(byte[] certificateBytes, string responseString) : this(certificateBytes)
|
||||
{
|
||||
LoadXmlFromBase64(responseString);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Response"/> class.
|
||||
/// </summary>
|
||||
/// <param name="certificateStr">The certificate formatted as a Base64 string.</param>
|
||||
public Response(string certificateStr) : this(Convert.FromBase64String(certificateStr))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Response"/> class.
|
||||
/// </summary>
|
||||
/// <param name="certificateBytes">The certificate formatted as an array of bytes.</param>
|
||||
public Response(byte[] certificateBytes)
|
||||
{
|
||||
_certificate = new X509Certificate2(certificateBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SAML response's XML data.
|
||||
/// </summary>
|
||||
public string Xml => _xmlDoc.OuterXml;
|
||||
|
||||
/// <summary>
|
||||
/// Loads XML from the parameter into the instance's XML data.
|
||||
/// </summary>
|
||||
/// <param name="xml">The XML string to put into the class.</param>
|
||||
public void LoadXml(string xml)
|
||||
{
|
||||
_xmlDoc = new XmlDocument();
|
||||
@@ -55,11 +83,19 @@ public class Response
|
||||
_xmlNameSpaceManager = GetNamespaceManager(); // lets construct a "manager" for XPath queries
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads Base64 encoded XML from the parameter into the instance's XML data.
|
||||
/// </summary>
|
||||
/// <param name="response">The Base64 encoded XML string to put into the class.</param>
|
||||
public void LoadXmlFromBase64(string response)
|
||||
{
|
||||
LoadXml(Encoding.UTF8.GetString(Convert.FromBase64String(response)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the XML response is valid by verifying the signature.
|
||||
/// </summary>
|
||||
/// <returns>Whether the XML response is valid.</returns>
|
||||
public bool IsValid()
|
||||
{
|
||||
var nodeList = _xmlDoc.SelectNodes("//ds:Signature", _xmlNameSpaceManager);
|
||||
@@ -118,17 +154,29 @@ public class Response
|
||||
return DateTime.UtcNow > expirationDate.ToUniversalTime();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name ID attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The name ID attribute.</returns>
|
||||
public string GetNameID()
|
||||
{
|
||||
var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:Subject/saml:NameID", _xmlNameSpaceManager);
|
||||
return node.InnerText;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the UPN attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The UPN attribute.</returns>
|
||||
public virtual string GetUpn()
|
||||
{
|
||||
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the email attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The email attribute.</returns>
|
||||
public virtual string GetEmail()
|
||||
{
|
||||
return GetCustomAttribute("User.email")
|
||||
@@ -138,6 +186,10 @@ public class Response
|
||||
?? GetCustomAttribute("mail");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the First Name attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The First Name attribute.</returns>
|
||||
public virtual string GetFirstName()
|
||||
{
|
||||
return GetCustomAttribute("first_name")
|
||||
@@ -148,6 +200,10 @@ public class Response
|
||||
?? GetCustomAttribute("givenName");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Last Name attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The Last Name attribute.</returns>
|
||||
public virtual string GetLastName()
|
||||
{
|
||||
return GetCustomAttribute("last_name")
|
||||
@@ -158,18 +214,30 @@ public class Response
|
||||
?? GetCustomAttribute("sn");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the department attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The department attribute.</returns>
|
||||
public virtual string GetDepartment()
|
||||
{
|
||||
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department")
|
||||
?? GetCustomAttribute("department");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the phone attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The phone attribute.</returns>
|
||||
public virtual string GetPhone()
|
||||
{
|
||||
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/homephone")
|
||||
?? GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/telephonenumber");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the company attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The company attribute.</returns>
|
||||
public virtual string GetCompany()
|
||||
{
|
||||
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/companyname")
|
||||
@@ -177,18 +245,32 @@ public class Response
|
||||
?? GetCustomAttribute("User.CompanyName");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the location attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The location attribute.</returns>
|
||||
public virtual string GetLocation()
|
||||
{
|
||||
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/location")
|
||||
?? GetCustomAttribute("physicalDeliveryOfficeName");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the first custom attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <param name="attr">The custom attribute to query.</param>
|
||||
/// <returns>The custom attribute.</returns>
|
||||
public string GetCustomAttribute(string attr)
|
||||
{
|
||||
var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager);
|
||||
return node?.InnerText;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the values for a custom attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <param name="attr">The custom attribute to query.</param>
|
||||
/// <returns>The custom attributes.</returns>
|
||||
public List<string> GetCustomAttributes(string attr)
|
||||
{
|
||||
var node = _xmlDoc.SelectNodes("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager);
|
||||
@@ -197,6 +279,7 @@ public class Response
|
||||
{
|
||||
output.Add(item?.InnerText);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
@@ -213,6 +296,9 @@ public class Response
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SAML request.
|
||||
/// </summary>
|
||||
public class AuthRequest
|
||||
{
|
||||
private readonly string _id;
|
||||
@@ -221,6 +307,11 @@ public class AuthRequest
|
||||
private readonly string _issuer;
|
||||
private readonly string _assertionConsumerServiceUrl;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AuthRequest"/> class..
|
||||
/// </summary>
|
||||
/// <param name="issuer">The issuer of the SAML request.</param>
|
||||
/// <param name="assertionConsumerServiceUrl">The SAML assertion URL.</param>
|
||||
public AuthRequest(string issuer, string assertionConsumerServiceUrl)
|
||||
{
|
||||
_id = "_" + Guid.NewGuid().ToString();
|
||||
@@ -230,6 +321,9 @@ public class AuthRequest
|
||||
_assertionConsumerServiceUrl = assertionConsumerServiceUrl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The formatting of the AuthRequest.
|
||||
/// </summary>
|
||||
public enum AuthRequestFormat
|
||||
{
|
||||
/// <summary>
|
||||
@@ -238,6 +332,11 @@ public class AuthRequest
|
||||
Base64 = 1
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SAML request.
|
||||
/// </summary>
|
||||
/// <param name="format">The format the request should be returned in.</param>
|
||||
/// <returns>The request as a string, either Base64 or not, depending on the format parameter.</returns>
|
||||
public string GetRequest(AuthRequestFormat format)
|
||||
{
|
||||
using var sw = new StringWriter();
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Xml.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// For some reason, the generic Dictionary in .net 2.0 is not XML serializable. The following code snippet is a xml serializable generic dictionary. The dictionary is serializable by implementing the IXmlSerializable interface.
|
||||
/// Also see https://weblogs.asp.net/pwelter34/444961 for additional information.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">Type of the dictionary key.</typeparam>
|
||||
/// <typeparam name="TValue">Type of the dictionary value.</typeparam>
|
||||
[XmlRoot("dictionary")]
|
||||
public class SerializableDictionary<TKey, TValue>
|
||||
: Dictionary<TKey, TValue>, IXmlSerializable
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SerializableDictionary{TKey,TValue}"/> class.
|
||||
/// </summary>
|
||||
public SerializableDictionary()
|
||||
{
|
||||
// Empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SerializableDictionary{TKey,TValue}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dictionary">Dictionary to convert from.</param>
|
||||
public SerializableDictionary(IDictionary<TKey, TValue> dictionary) : base(dictionary)
|
||||
{
|
||||
// Empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SerializableDictionary{TKey,TValue}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dictionary">Dictionary to convert from.</param>
|
||||
/// <param name="comparer">Comparer for the dictionary.</param>
|
||||
public SerializableDictionary(IDictionary<TKey, TValue> dictionary, IEqualityComparer<TKey> comparer) : base(dictionary, comparer)
|
||||
{
|
||||
// Empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SerializableDictionary{TKey,TValue}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="comparer">Comparer for the dictionary.</param>
|
||||
public SerializableDictionary(IEqualityComparer<TKey> comparer) : base(comparer)
|
||||
{
|
||||
// Empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SerializableDictionary{TKey,TValue}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="capacity">Capacity of the dictionary.</param>
|
||||
public SerializableDictionary(int capacity) : base(capacity)
|
||||
{
|
||||
// Empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SerializableDictionary{TKey,TValue}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="capacity">Capacity of the dictionary.</param>
|
||||
/// <param name="comparer">Comparer for the dictionary.</param>
|
||||
public SerializableDictionary(int capacity, IEqualityComparer<TKey> comparer) : base(capacity, comparer)
|
||||
{
|
||||
// Empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema of the XML object.
|
||||
/// </summary>
|
||||
/// <returns>Nothing.</returns>
|
||||
public System.Xml.Schema.XmlSchema GetSchema()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads XML and changes this object to be an instance of that data.
|
||||
/// </summary>
|
||||
/// <param name="reader">The XML reader to read from.</param>
|
||||
public void ReadXml(System.Xml.XmlReader reader)
|
||||
{
|
||||
XmlSerializer keySerializer = new XmlSerializer(typeof(TKey));
|
||||
XmlSerializer valueSerializer = new XmlSerializer(typeof(TValue));
|
||||
|
||||
bool wasEmpty = reader.IsEmptyElement;
|
||||
reader.Read();
|
||||
|
||||
if (wasEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (reader.NodeType != System.Xml.XmlNodeType.EndElement)
|
||||
{
|
||||
reader.ReadStartElement("item");
|
||||
|
||||
reader.ReadStartElement("key");
|
||||
TKey key = (TKey)keySerializer.Deserialize(reader);
|
||||
reader.ReadEndElement();
|
||||
|
||||
reader.ReadStartElement("value");
|
||||
TValue value = (TValue)valueSerializer.Deserialize(reader);
|
||||
reader.ReadEndElement();
|
||||
|
||||
this.Add(key, value);
|
||||
|
||||
reader.ReadEndElement();
|
||||
reader.MoveToContent();
|
||||
}
|
||||
|
||||
reader.ReadEndElement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes XML to the XML writer from this object.
|
||||
/// </summary>
|
||||
/// <param name="writer">An instance of the XmlWriter class.</param>
|
||||
public void WriteXml(System.Xml.XmlWriter writer)
|
||||
{
|
||||
XmlSerializer keySerializer = new XmlSerializer(typeof(TKey));
|
||||
XmlSerializer valueSerializer = new XmlSerializer(typeof(TValue));
|
||||
|
||||
foreach (TKey key in this.Keys)
|
||||
{
|
||||
writer.WriteStartElement("item");
|
||||
|
||||
writer.WriteStartElement("key");
|
||||
keySerializer.Serialize(writer, key);
|
||||
writer.WriteEndElement();
|
||||
|
||||
writer.WriteStartElement("value");
|
||||
TValue value = this[key];
|
||||
valueSerializer.Serialize(writer, value);
|
||||
writer.WriteEndElement();
|
||||
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import jellyfinApiclient from "./jellyfin-apiClient.esm.min.js";
|
||||
window.jellyfinApiclient = jellyfinApiclient;
|
||||
console.log(jellyfinApiclient);
|
||||
|
||||
// https://github.com/jellyfin/jellyfin-web/blob/9067b0e397cc8b38635d661ce86ddd83194f3202/src/scripts/clientUtils.js#L19-L76
|
||||
export async function serverAddress({ basePath = "/web" }) {
|
||||
const apiClient = window.ApiClient;
|
||||
|
||||
if (apiClient) {
|
||||
return Promise.resolve(apiClient.serverAddress());
|
||||
}
|
||||
|
||||
const urls = [];
|
||||
|
||||
const getViewUrl = (basePath) => {
|
||||
let url;
|
||||
const index = window.location.href
|
||||
.toLowerCase()
|
||||
.lastIndexOf(basePath.toLowerCase());
|
||||
|
||||
if (index != -1) {
|
||||
url = window.location.href.substring(0, index);
|
||||
} else {
|
||||
// Return nothing, let another method handle it
|
||||
url = undefined;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
if (urls.length === 0) {
|
||||
// Otherwise use computed base URL
|
||||
let url;
|
||||
|
||||
url = getViewUrl(basePath) ?? getViewUrl("/web") ?? window.location.origin;
|
||||
|
||||
// Don't use bundled app URL (file:) as server URL
|
||||
if (url.startsWith("file:")) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
urls.push(url);
|
||||
}
|
||||
|
||||
console.debug("URL candidates:", urls);
|
||||
|
||||
const promises = urls.map((url) => {
|
||||
return fetch(`${url}/System/Info/Public`)
|
||||
.then((resp) => {
|
||||
return {
|
||||
url: url,
|
||||
response: resp,
|
||||
};
|
||||
})
|
||||
.catch(() => {
|
||||
return Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.all(promises)
|
||||
.then((responses) => {
|
||||
responses = responses.filter((obj) => obj && obj.response.ok);
|
||||
return Promise.all(
|
||||
responses.map((obj) => {
|
||||
return {
|
||||
url: obj.url,
|
||||
config: obj.response.json(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
})
|
||||
.then((configs) => {
|
||||
const selection =
|
||||
configs.find((obj) => !obj.config.StartupWizardCompleted) || configs[0];
|
||||
return Promise.resolve(selection?.url);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
return Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Refactor duplicated code
|
||||
// ! Duplicated at
|
||||
// https://github.com/9p4/jellyfin-plugin-sso/blob/38558d762a13422862240af4060bdd1bb1618d57/SSO-Auth/WebResponse.cs#L363-L401
|
||||
function getDeviceName() {
|
||||
return "DUMMY";
|
||||
}
|
||||
|
||||
function getDeviceId() {
|
||||
return localStorage.getItem("_deviceId2");
|
||||
}
|
||||
|
||||
const sleep = (milliseconds) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
};
|
||||
|
||||
async function awaitLocalStorage() {
|
||||
while (
|
||||
localStorage.getItem("_deviceId2") == null ||
|
||||
localStorage.getItem("jellyfin_credentials") == null ||
|
||||
JSON.parse(localStorage.getItem("jellyfin_credentials"))["Servers"][0][
|
||||
"Id"
|
||||
] == null
|
||||
) {
|
||||
// If localStorage isn't initialized yet, try again.
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
await awaitLocalStorage();
|
||||
|
||||
// Fetch credentials
|
||||
|
||||
var credentials = new jellyfinApiclient.Credentials();
|
||||
|
||||
var server = await serverAddress({ basePath: "/SSOViews" });
|
||||
console.log({ server: server });
|
||||
var deviceId = getDeviceId();
|
||||
var appName = "SSO-Auth";
|
||||
var appVersion = "0.0.0.9000";
|
||||
var capabilities = {};
|
||||
|
||||
const current_server = credentials
|
||||
.credentials()
|
||||
.Servers.find((e) => e.LocalAddress == server || e.ManualAddress == server);
|
||||
|
||||
var localApiClient = new jellyfinApiclient.ApiClient(
|
||||
server,
|
||||
appName,
|
||||
appVersion,
|
||||
getDeviceName(),
|
||||
deviceId,
|
||||
);
|
||||
localApiClient.setAuthenticationInfo(
|
||||
current_server.AccessToken,
|
||||
current_server.UserId,
|
||||
);
|
||||
|
||||
var connections = new jellyfinApiclient.ConnectionManager(
|
||||
credentials,
|
||||
appName,
|
||||
appVersion,
|
||||
getDeviceName(),
|
||||
deviceId,
|
||||
capabilities,
|
||||
);
|
||||
|
||||
connections.addApiClient(localApiClient);
|
||||
|
||||
window.ApiClient = localApiClient;
|
||||
|
||||
export default localApiClient;
|
||||
@@ -0,0 +1,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
+56
-5
@@ -1,7 +1,13 @@
|
||||
namespace Jellyfin.Plugin.SSO_Auth;
|
||||
|
||||
/// <summary>
|
||||
/// A helper class to return HTML for the client's auth flow.
|
||||
/// </summary>
|
||||
public static class WebResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The shared HTML between all of the responses.
|
||||
/// </summary>
|
||||
public static readonly string Base = @"<!DOCTYPE html>
|
||||
<html><head></head><body>
|
||||
<p>Logging in...</p>
|
||||
@@ -399,9 +405,53 @@ const sleep = (milliseconds) => {
|
||||
|
||||
";
|
||||
|
||||
public static string Generator(string data, string provider, string baseUrl, string mode)
|
||||
/// <summary>
|
||||
/// A generator for the web response that incorporates the data from the server.
|
||||
/// </summary>
|
||||
/// <param name="data">The data of the auth flow. Is signed XML for SAML and a state ID for OpenID.</param>
|
||||
/// <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, 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 ||
|
||||
@@ -414,11 +464,12 @@ async function main() {
|
||||
var appName = ""Jellyfin Web"";
|
||||
var appVersion = ""10.8.0"";
|
||||
var deviceName = getDeviceName();
|
||||
var provider = '" + provider + @"';
|
||||
|
||||
var request = {deviceId, appName, appVersion, deviceName, data, provider: '" + provider + @"'};
|
||||
var request = {deviceId, appName, appVersion, deviceName, data};
|
||||
|
||||
var url = '" + baseUrl + "/sso/" + mode + @"/Auth';
|
||||
if (" + $"{isLinking}".ToLower() + @") await link(request);
|
||||
|
||||
var url = '" + baseUrl + "/sso/" + mode + "/Auth/" + provider + @"';
|
||||
|
||||
let response = await new Promise(resolve => {
|
||||
var xhr = new XMLHttpRequest();
|
||||
@@ -442,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 () {
|
||||
|
||||
+6
-1
@@ -1,7 +1,7 @@
|
||||
name: "SSO Authentication"
|
||||
guid: "505ce9d1-d916-42fa-86ca-673ef241d7df"
|
||||
imageUrl: "https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/main/img/logo.png"
|
||||
version: "3.1.0.1"
|
||||
version: "3.5.1.0"
|
||||
targetAbi: "10.8.0.0"
|
||||
framework: "net6.0"
|
||||
owner: "9p4"
|
||||
@@ -15,6 +15,11 @@ 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)
|
||||
3.1.0.0: Simplify auth flow so loading the web UI is not required
|
||||
3.0.0.0: Add more RBAC features and option to unregister user from SSO
|
||||
|
||||
Generated
+27
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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**
|
||||
|
||||

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

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

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

|
||||
|
||||
- Edit / Update your Jellyfin OAuth provider
|
||||
- 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
|
||||
|
||||

|
||||
|
||||
### 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>
|
||||
```
|
||||
Reference in New Issue
Block a user