Compare commits

...
Author SHA1 Message Date
9p4 46eb00bf43 Merge branch 'main' into custom-username-claim 2022-08-23 19:50:01 -04:00
9p4 5723cd718d ci: change commit linter 2022-08-23 19:48:41 -04:00
9p4 96b14f7a5a ci: run commit name lint on every push and pr 2022-08-23 19:42:56 -04:00
9p4 320551bc18 ci: update hash for commit lint action 2022-08-23 19:39:47 -04:00
Sambhav Saggi 9f8626bdf7 Merge branch 'custom-username-claim' of https://github.com/9p4/jellyfin-plugin-sso into custom-username-claim 2022-08-23 19:38:21 -04:00
Sambhav Saggi d5925fc1de docs: add custom username claim api doc 2022-08-23 19:38:01 -04:00
9p4 facf45058f Merge branch 'main' into custom-username-claim 2022-08-23 19:32:16 -04:00
9p4 f3d0497801 ci: lint commits 2022-08-23 19:31:02 -04:00
Sambhav Saggi 672fce8189 fix: prevent null passwords when fallback is set to default provider 2022-08-23 19:22:02 -04:00
Sambhav Saggi c2f50e1e4c style: run prettier 2022-08-23 18:57:57 -04:00
Sambhav Saggi a90e0359e4 feat: add config page for preferred username config 2022-08-23 18:26:58 -04:00
Sambhav Saggi 332f62d76e feat: allow configuration of preferred username claim (#47) 2022-08-23 18:18:42 -04:00
9p4 47db5cb504 Merge pull request #71 from fredriklindberg/improve-get-request-base
fix: don't include port in redirect URL for 80 and 443
2022-08-23 18:12:19 -04:00
Matthew Strasiotto fe1fdad0b0 Merge pull request #72 from fredriklindberg/make-user-creation-more-robust 2022-08-20 21:07:53 +10:00
Fredrik Lindberg d06f680407 fix: make user creation more robust
Reverse logic of CreateCanonicalLinkAndUserIfNotExist() to first check if the user exists,
then create the canonical link. This allows seamless re-creation of users through SSO after
they have been deleted from Jellyfin but not properly de-linked.
2022-08-18 17:55:41 +02:00
Fredrik Lindberg 976a816d8c fix: don't include port in redirect URL for 80 and 443
Skip port number in redirect URLs during the following conditions
 - The request scheme is http and request port is 80
 - The request scheme is https and request port is 443

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

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

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

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

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

* Add Keycloak + cleanup

* Linting

* Fix authelia -> authentik

* lint

* Rename Authentik -> authentik

Co-authored-by: Sambhav Saggi <[email protected]>
2022-06-18 22:33:32 +10:00
9p4 5a8d1da6de Merge pull request #40 from 9p4/revert-39-update-ci
Revert "Add github action for publishing releases"
2022-06-07 11:32:09 -04:00
9p4 e3efd97964 Revert "Add github action for publishing releases" 2022-06-07 11:31:47 -04:00
9p4 9199fce64f Merge pull request #39 from matthewstrasiotto/update-ci
Add github action for publishing releases
2022-06-07 11:29:35 -04:00
Matthew Strasiotto 0e590858c0 add github action 2022-06-04 20:35:45 +10:00
Sambhav Saggi 9b4393981c Merge branch 'main' of https://github.com/9p4/jellyfin-plugin-sso 2022-05-30 15:20:03 -04:00
Sambhav Saggi 29e902c109 Update domain 2022-05-30 15:19:52 -04:00
9p4 b777e3a346 Merge pull request #33 from matthewstrasiotto/fix_provider_reset
Fix ID Provider incorrectly resetting on login
2022-05-26 16:38:31 -04:00
Matthew Strasiotto 5da7e02faf Fix ID Provider incorrectly resetting on login 2022-05-26 19:17:58 +10:00
Sambhav Saggi 07aa74b1d7 3.3.0.0 Release 2022-05-22 12:13:40 -04:00
Matthew Strasiottoand9p4 f98509633b Improve UX on config page (#27)
* scope listArgumentsByType to oidc form, select on class

scope listArgumentsByType to correct form

Select arguments by class instead of type

* String arrays now given as newline separated lists

* Implement UI for checklisting folders, and role mapping

start making enabled folders checklist-able

Implement save/load folder lists

change enabledfolders to checklist

refactor: rename saveFolderList -> serializeEnabledFolders, fillFolderList -> populateEnabledFolders

refactor listArgumentsByType

start implementing load provider role mapping

implement role mapping

Move remove button markup, change hierachy

remove logging statements

resolve folder population promise before trying to check folders

* Improve markup, add stylesheet, update help strings

Add stylesheet

update markup + styling of specialized forms

improve markup, assign checkboxes as emby-checkbox es

imrpove markup, remove old comments

run stylesheet through prettier

Update help strings

* Markup changes: Adjust role mapping whitespace

* scope listArgumentsByType to oidc form, select on class

scope listArgumentsByType to correct form

Select arguments by class instead of type

* String arrays now given as newline separated lists

* Implement UI for checklisting folders, and role mapping

start making enabled folders checklist-able

Implement save/load folder lists

change enabledfolders to checklist

refactor: rename saveFolderList -> serializeEnabledFolders, fillFolderList -> populateEnabledFolders

refactor listArgumentsByType

start implementing load provider role mapping

implement role mapping

Move remove button markup, change hierachy

remove logging statements

resolve folder population promise before trying to check folders

* Improve markup, add stylesheet, update help strings

Add stylesheet

update markup + styling of specialized forms

improve markup, assign checkboxes as emby-checkbox es

imrpove markup, remove old comments

run stylesheet through prettier

Update help strings

* Markup changes: Adjust role mapping whitespace

* Proper stylesheet import with nonstandard base URL

* Style

* Ensure rolemappings are using arrays instead of dicts

* Add confirmation for deletion

* Linting

* Update admin page status in README

Co-authored-by: 9p4 <[email protected]>
2022-05-22 12:09:57 -04:00
Matthew Strasiotto f7d066d6b1 CI: lint css files in prettier workflow (#30) 2022-05-14 09:09:33 -04:00
Sambhav Saggi 4600f56863 Lint issue templates / bug reports 2022-04-29 16:24:35 -04:00
Sambhav Saggi 22dd6629eb Add "on push" for prettier 2022-04-29 16:22:58 -04:00
Sambhav Saggi ece0dc31db Update prettier to check README 2022-04-29 16:21:32 -04:00
Matthew Strasiotto 8771f63eb5 CI: Remove explicit branch ref from checkout (#25) 2022-04-29 16:13:59 -04:00
Matthew Strasiotto 54ef348009 Allow requesting additional scopes in OIDC flows (#24)
* Allow requesting additional scopes in OIDC flows

* Supply OidScopes as an array

* Document `OidScopes` in readme
2022-04-29 16:04:45 -04:00
Matthew Strasiotto 63ea90114c Add prettier ci workflow (#22) 2022-04-28 17:31:52 -04:00
Matthew Strasiotto 3a8e89db82 Add UI Page for OIDC Configuration (#18)
* Add UI Page for Configuration

For now, this only implements OID configuration.

Add configuration

More frontend additions

implement more frontend for save / load config

Fully implement save + load

implement delete provider

* Cleanup UI page markup, spelling, help text, add more useful titles

* Tidy up help strings

https://github.com/9p4/jellyfin-plugin-sso/pull/18#discussion_r857147540

https://github.com/9p4/jellyfin-plugin-sso/pull/18#discussion_r857147695

https://github.com/9p4/jellyfin-plugin-sso/pull/18#discussion_r857147715

https://github.com/9p4/jellyfin-plugin-sso/pull/18#discussion_r857147806

https://github.com/9p4/jellyfin-plugin-sso/pull/18#discussion_r857147832
2022-04-27 19:53:19 -04:00
Sambhav Saggi 08bf263255 [skip ci] Update issue template to have configuration 2022-04-16 13:42:55 -04:00
Sambhav Saggi b1beef8155 Merge branch 'main' of https://github.com/9p4/jellyfin-plugin-sso 2022-04-16 13:39:25 -04:00
Sambhav Saggi 062be242ae Add docs for fallback provider (#9) 2022-04-16 13:39:09 -04:00
9p4 d9458d5933 [skip ci] Update issue templates 2022-04-09 20:37:59 -04:00
9p4 6cb5fc33bf Merge pull request #9 from Pfuenzle/patch-2
Fallback Authentication Provider
2022-04-07 16:16:23 -04:00
Sambhav Saggi fd371006af Remove build script 2022-04-07 16:13:02 -04:00
Sambhav Saggi 5d28f1ba31 Make CI happy 2022-04-07 16:09:09 -04:00
9p4 c500a5d435 Merge pull request #12 from crobibero/api-cleanup
Clean up controller
2022-04-07 16:06:50 -04:00
Pfuenzle 2d14342ad7 Merge branch 'main' into patch-2 2022-04-07 10:20:11 +02:00
username e8276eae87 Removed errors and warnings 2022-04-07 10:17:41 +02:00
username 2d459b7945 Moved default provider function to authenticate() 2022-04-07 09:43:36 +02:00
username b8cddb3467 Added comment, removed whitespace etc. 2022-04-07 09:33:55 +02:00
Cody Robibero 875fc62728 Clean up controller
Signed-off-by: Cody Robibero <[email protected]>
2022-03-21 16:46:52 -06:00
Sambhav Saggi 31cd57829b 3.2.0.0 release 2022-03-13 15:09:47 -04:00
Sambhav Saggi 8d3b0ea5eb Merge branch 'main' of https://github.com/9p4/jellyfin-plugin-sso 2022-03-13 15:02:00 -04:00
Sambhav Saggi 7f66f44743 Use hashmaps instead of lists for performance 2022-03-13 15:01:26 -04:00
9p4 0ad890088d [skip ci] Update README.md
Fix RSS link for commits
2022-03-06 17:10:57 -05:00
9p4 bceb448c2d Create FUNDING.yml
Might as well.
2022-03-01 22:25:27 -05:00
Sambhav Saggi 0d44515442 Merge branch 'main' of https://github.com/9p4/jellyfin-plugin-sso 2022-02-25 19:52:11 -05:00
Sambhav Saggi f748bf7ef7 Add expected roles for debug 2022-02-25 19:51:54 -05:00
Sambhav Saggi b752f6ffaa Log permissions on permission error 2022-02-25 19:47:06 -05:00
9p4 880a5494ec [skip ci] Add why only Jellyfin 10.8 is supported. 2022-02-21 15:30:04 -05:00
9p4 495fbcdda4 [skip ci] Make README even prettier 2022-02-21 14:59:44 -05:00
9p4 93c5529138 [skip ci] Make README prettier 2022-02-21 14:50:24 -05:00
Sambhav Saggi 653341f3dd Merge branch 'main' of https://github.com/9p4/jellyfin-plugin-sso into patch-2 2022-02-21 14:41:01 -05:00
Sambhav Saggi ff0f6caa3c Merge branch 'main' of https://github.com/9p4/jellyfin-plugin-sso 2022-02-21 14:36:34 -05:00
Sambhav Saggi 7d1b3fa97d Add XML docs + no more warnings 2022-02-21 14:36:29 -05:00
9p4 4709a65c00 [CI SKIP] Error on warn 2022-02-21 12:44:48 -05:00
9p4 1945ae8683 Add GH action for CI/CD 2022-02-21 12:42:09 -05:00
Sambhav Saggi fad64fec7c Clean up logging, add better errors 2022-02-21 11:29:46 -05:00
Sambhav Saggi 1a736d9707 Fix docs and escaped characters 2022-02-21 11:16:57 -05:00
Sambhav Saggi 7106af19ec Add nested OIDC claim support 2022-02-21 11:16:12 -05:00
Pfuenzle 0eb7cd4f50 Update SSOController.cs 2022-02-20 18:08:31 +01:00
Pfuenzle 06ed040381 Added DefaultProvider to PluginConfiguration 2022-02-20 18:05:05 +01:00
Sambhav Saggi 54586640fb 3.1.0.1 release 2022-02-18 14:31:02 -05:00
Sambhav Saggi ee26556ddf Fix #7 2022-02-18 14:29:17 -05:00
Sambhav Saggi f19cd74c19 3.1.0.0 release 2022-02-18 13:43:32 -05:00
9p4 8700686e2b Remove Facebook since they don't support OIDC
Facebook only officially supports oauth2, not OpenID. Therefore, they are not officially compatible with this plugin.
2022-02-18 13:11:20 -05:00
9p4 9c8f6c64c7 Merge pull request #6 from Pfuenzle/patch-1
Fixed DeviceName and inconsistent SAML and OID behaviour
2022-02-18 13:07:13 -05:00
Sambhav Saggi 075b4fb69f Consolidate responses and improve localstorage checks 2022-02-18 13:02:04 -05:00
Sambhav Saggi 09bbe3aa47 Finalize client login flow via WebResponse 2022-02-18 12:52:01 -05:00
Sambhav Saggi aa135cdc65 Clean up and simplify JS in WebResponse 2022-02-18 12:38:55 -05:00
Pfuenzle dae0043192 Fixed DeviceName
Replaced the string "deviceName" with the correct variable for obvious reasons.
2022-02-17 15:09:55 +01:00
Sambhav Saggi 32e009969b Wait until iFrame is loaded properly (all browsers) 2022-02-16 22:40:40 -05:00
Sambhav Saggi 9cb3057c90 Update README to reflect proper Keycloak defaults 2022-02-16 21:49:55 -05:00
Sambhav Saggi d5315a5263 Clarify config values, add iframe for localstorage-less login 2022-02-16 19:16:53 -05:00
Sambhav Saggi e9273ca82c Add error message when nojs or localstorage isn't populated 2022-02-15 21:38:39 -05:00
Sambhav Saggi e13c403f95 3.0.0.0 release 2022-02-15 21:01:04 -05:00
Sambhav Saggi 387fa5c67b Add option to remove user from the SSO login 2022-02-15 20:52:54 -05:00
Sambhav Saggi 7f69d4481e Update README 2022-02-15 20:23:40 -05:00
Sambhav Saggi 052e78b44d Add more RBAC 2022-02-15 20:20:15 -05:00
36 changed files with 4035 additions and 402 deletions
+13
View File
@@ -0,0 +1,13 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: a055
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+37
View File
@@ -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?
+19
View File
@@ -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.
+39
View File
@@ -0,0 +1,39 @@
on:
workflow_call:
inputs:
dotnet-version:
required: false
default: "6.0.x"
description: "The .NET version to setup for the build"
type: string
dotnet-target:
required: false
default: "net6.0"
description: "The .NET target to set for JPRM"
type: string
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v2
- name: Setup .NET
uses: actions/setup-dotnet@v1
with:
dotnet-version: "${{ inputs.dotnet-version }}"
- name: Build Jellyfin Plugin
uses: oddstr13/jellyfin-plugin-repository-manager@b9e92867a6aa279d611a5ea80cf61f6358838c39
id: jprm
with:
dotnet-target: "${{ inputs.dotnet-target }}"
- name: Upload Artifact
uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # tag=v3
with:
name: build-artifact
retention-days: 30
if-no-files-found: error
path: ${{ steps.jprm.outputs.artifact }}
+11
View File
@@ -0,0 +1,11 @@
name: Lint Commit Messages
on: [pull_request, push]
jobs:
commitlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: wagoid/commitlint-github-action@4caf21aed4a778f940d0b17eb109942ef167bb27
+25
View File
@@ -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
+20
View File
@@ -0,0 +1,20 @@
name: Prettier Lint
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
prettier:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Prettify code
uses: creyD/[email protected]
with:
dry: True
prettier_options: '--check **/*.{js,html,md,css,scss}'
+71
View File
@@ -0,0 +1,71 @@
name: Publish Nightly
on:
push:
branches: [ main ]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup .NET
uses: actions/setup-dotnet@v1
with:
dotnet-version: 6.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build Dotnet
run: dotnet build --no-restore --warnaserror
- name: "Flag as nightly in build.yaml"
uses: fjogeleit/[email protected]
with:
valueFile: 'build.yaml'
propertyPath: 'version'
value: "0.0.0.9000"
commitChange: false
updateFile: true
- name: "JPRM: Build"
id: jrpm
uses: oddstr13/jellyfin-plugin-repository-manager@b9e92867a6aa279d611a5ea80cf61f6358838c39
with:
version: "0.0.0.9000"
verbosity: debug
path: .
dotnet-target: "net6.0"
output: _dist
- name: Prepare GitHub Release assets
run: |-
pushd _dist
for file in ./*.zip; do
md5sum ${file#./} >> ${file%.*}.md5
sha256sum ${file#./} >> ${file%.*}.sha256
done
ls -l
popd
- name: Publish output artifacts
id: publish-assets
uses: softprops/action-gh-release@50195ba7f6f93d1ac97ba8332a178e008ad176aa
with:
prerelease: false
fail_on_unmatched_files: true
tag_name: nightly
files: |
_dist/*
build.yaml
body: |
Nightly build
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Plugin Manifest
uses: Kevinjil/jellyfin-plugin-repo-action@a7832ecc44c6b1a45d531970f6647b8682b005b8
with:
ignorePrereleases: true
githubToken: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
pagesBranch: manifest-release
pagesFile: manifest.json
+53
View File
@@ -0,0 +1,53 @@
name: Publish Release
on:
release:
types:
- released
workflow_dispatch:
jobs:
build:
uses: ./.github/workflows/build.yml
with:
dotnet-version: "6.0.*"
dotnet-target: "net6.0"
upload:
runs-on: ubuntu-latest
needs:
- build
steps:
- name: Download Artifact
uses: actions/[email protected]
with:
name: build-artifact
- name: Prepare GitHub Release assets
run: |-
for file in ./*; do
md5sum ${file#./} >> ${file%.*}.md5
sha256sum ${file#./} >> ${file%.*}.sha256
done
ls -l
- name: Publish output artifacts
id: publish-assets
uses: softprops/action-gh-release@50195ba7f6f93d1ac97ba8332a178e008ad176aa
with:
prerelease: false
fail_on_unmatched_files: true
tag_name: ${{ github.event.release.tag_name }}
files: ./*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
generate:
runs-on: ubuntu-latest
needs:
- upload
steps:
- name: Publish Plugin Manifest
uses: Kevinjil/jellyfin-plugin-repo-action@a7832ecc44c6b1a45d531970f6647b8682b005b8
with:
ignorePrereleases: true
githubToken: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
pagesBranch: manifest-release
pagesFile: manifest.json
+1 -4
View File
@@ -448,7 +448,4 @@ $RECYCLE.BIN/
## Visual Studio Code ## Visual Studio Code
## ##
.vscode/* .vscode/*
!.vscode/settings.json .vscode
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
+1
View File
@@ -0,0 +1 @@
*.min.js
+132 -37
View File
@@ -1,21 +1,52 @@
# Jellyfin SSO Plugin <h1 align="center">Jellyfin SSO Plugin</h1>
This plugin allows users to sign in through an SSO provider (such as Google, Facebook, or your own provider). This enables one-click signin. <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 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: ## Current State:
This is 100% alpha software! PRs are welcome to improve the code. 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 [matthewstrasiotto](https://github.com/matthewstrasiotto) in PR [#18](https://github.com/9p4/jellyfin-plugin-sso/pull/18) and [#27](https://github.com/9p4/jellyfin-plugin-sso/pull/27).
**This is for Jellyfin 10.8** **[This is for Jellyfin 10.8](https://github.com/9p4/jellyfin-plugin-sso/issues/3) and only on the Web UI!**
**This README reflects the branch it is currently on! Switch tags to view version-specific documentation!**
## Tested Providers ## Tested Providers
[Find provider specific documentation in providers.md](providers.md)
- Authelia
- authentik
- Keycloak
- OIDC & SAML
- Google OpenID: Works, but usernames are all numeric - Google OpenID: Works, but usernames are all numeric
- Keycloak OpenID and SAML: Works
## Supported Protocols ## Supported Protocols
@@ -28,24 +59,35 @@ This is my first time writing C# so please take all of the code written here wit
## Installing ## 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 ## Roadmap
- [ ] Admin page - [x] Admin page
- [ ] Automated tests - [ ] Automated tests
- [x] Add role/claims support - [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 ## Examples
@@ -53,7 +95,7 @@ Build the zipped plugin with `jprm --verbosity=debug plugin build .`.
Example for adding a SAML configuration with the API using [curl](https://curl.se/): 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, "enableAllFolders": true, "enabledFolders": ["folder1", "folder2"], "adminRoles": [], "roles": []}' "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. Make sure that the JSON is the same as the configuration you would like.
@@ -62,26 +104,26 @@ The SAML provider must have the following configuration (I am using Keycloak, an
- Sign Documents on - Sign Documents on
- Sign Assertions off - Sign Assertions off
- Client Signature Required 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/p/PROVIDER_NAME](https://myjellyfin.example.com/sso/OID/p/PROVIDER_NAME)
- Base URL: [https://myjellyfin.example.com](https://myjellyfin.example.com) - 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/p/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/p/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 ### OpenID
Example for adding an OpenID configuration with the API using [curl](https://curl.se/) 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, "enableAllFolders": true, "enabledFolders": ["folder3", "folder4"], "adminRoles": [], "roles": []}' "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) The OpenID provider must have the following configuration (again, I am using Keycloak)
- Access Type: Confidential - Access Type: Confidential
- Standard Flow Enabled - 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/r/PROVIDER_NAME](https://myjellyfin.example.com/sso/OID/r/PROVIDER_NAME)
- Base URL: [https://myjellyfin.example.com](https://myjellyfin.example.com) - 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 ## API Endpoints
@@ -91,75 +133,124 @@ The API is all done from a base URL of `/sso/`
#### Flow #### 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. - POST `SAML/p/PROVIDER_NAME`: This is the SAML POST endpoint. It accepts a form response from the SAML provider and returns HTML and JavaScript for the client to login with a given provider name.
- GET `SAML/p/clientid`: This is the SAML initiator: it will begin the authorization flow for SAML with a given client ID. - GET `SAML/p/PROVIDER_NAME`: This is the SAML initiator: it will begin the authorization flow for SAML with a given provider name.
- POST `SAML/Auth`: 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/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. - `deviceId`: string. Device ID.
- `deviceName`: string. Device name. - `deviceName`: string. Device name.
- `appName`: string. App name. - `appName`: string. App name.
- `appVersion`: string. App version. - `appVersion`: string. App version.
- `data`: string. The signed SAML XML request. Used to verify a request. - `data`: string. The signed SAML XML request. Used to verify a request.
- `provider`: string. The current SAML client ID.
#### Configuration #### 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"` 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. - `samlEndpoint`: string. The SAML endpoint.
- `samlClientId`: string. The SAML client ID. - `samlClientId`: string. The SAML client ID.
- `samlCertificate`: string. The base64 encoded SAML certificate. - `samlCertificate`: string. The base64 encoded SAML certificate.
- `enabled`: boolean. Determines if the provider is enabled or not. - `enabled`: boolean. Determines if the provider is enabled or not.
- `enableAuthorization`: boolean: Determines if the plugin sets permissions for the user. If false, the user will start with no permissions and an administrator will add permissions. The permissions of existing users will not be rewritten on subsequent logins.
- `enableAllFolders`: boolean. Determines if the client logging in is allowed access to all folders. - `enableAllFolders`: boolean. Determines if the client logging in is allowed access to all folders.
- `enabledFolders`: array of strings. If `enableAllFolders` is set to false, then this will be used to determine what folders the users who log in through this provider are allowed to use. - `enabledFolders`: array of strings. If `enableAllFolders` is set to false, then this will be used to determine what folders the users who log in through this provider are allowed to use.
- `roles`: array of strings. This validates the SAML response against the `Role` attribute. If a user has any of these roles, then the user is authenticated. Leave blank to disable role checking. - `roles`: array of strings. This validates the SAML response against the `Role` attribute. If a user has any of these roles, then the user is authenticated. Leave blank to disable role checking.
- `adminRoles`: array of strings. This uses SAML response's `Role` attributes. If a user has any of these roles, then the user is an admin. Leave blank to disable (default is to not enable admin permissions). - `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).
- GET `SAML/Del/clientId`: This removes a configuration for SAML for a given client ID. - `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.
- `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. - GET `SAML/Get`: Lists the configurations currently available.
### OpenID ### OpenID
#### Flow #### Flow
- GET `OID/r/clientId`: This is the OpenID callback path. This will return HTML and JavaScript for the client to login. - GET `OID/r/PROVIDER_NAME`: This is the OpenID callback path. This will return HTML and JavaScript for the client to login with a given provider name.
- GET `OID/p/clientId`: This is the OpenID initiator: it will begin the authorization flow for OpenID with a given client ID. - GET `OID/p/PROVIDER_NAME`: This is the OpenID initiator: it will begin the authorization flow for OpenID with a given provider name.
- POST `OID/Auth`: 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: - 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. - `deviceId`: string. Device ID.
- `deviceName`: string. Device name. - `deviceName`: string. Device name.
- `appName`: string. App name. - `appName`: string. App name.
- `appVersion`: string. App version. - `appVersion`: string. App version.
- `data`: string. The OpenID state. Used to verify a request. - `data`: string. The OpenID state. Used to verify a request.
- `provider`: string. The current OpenID client ID.
#### Configuration #### 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"` 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. - `oidEndpoint`: string. The OpenID endpoint. Must have a `.well-known` path available.
- `oidClientId`: string. The OpenID client ID. - `oidClientId`: string. The OpenID client ID.
- `oidSecret`: string. The OpenID secret. - `oidSecret`: string. The OpenID secret.
- `enabled`: boolean. Determines if the provider is enabled or not. - `enabled`: boolean. Determines if the provider is enabled or not.
- `enableAuthorization`: boolean: Determines if the plugin sets permissions for the user. If false, the user will start with no permissions and an administrator will add permissions. The permissions of existing users will not be rewritten on subsequent logins.
- `enableAllFolders`: boolean. Determines if the client logging in is allowed access to all folders. - `enableAllFolders`: boolean. Determines if the client logging in is allowed access to all folders.
- `enabledFolders`: array of strings. If `enableAllFolders` is set to false, then this will be used to determine what folders the users who log in through this provider are allowed to use. - `enabledFolders`: array of strings. If `enableAllFolders` is set to false, then this will be used to determine what folders the users who log in through this provider are allowed to use.
- `roles`: array of strings. This validates the OpenID response against the `realm_access` claim. If a user has any of these roles, then the user is authenticated. Leave blank to disable role checking. This currently only works for Keycloak (to my knowledge). - `roles`: array of strings. This validates the OpenID response against the claim set in `roleClaim`. If a user has any of these roles, then the user is authenticated. Leave blank to disable role checking. This currently only works for Keycloak (to my knowledge).
- `adminRoles`: array of strings. This uses the OpenID response against the `realm_access` claim. 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). - `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).
- GET `OID/Del/clientId`: This removes a configuration for OpenID for a given client ID. - `enableFolderRoles`: boolean. Determines if role-based folder access should be used.
- `folderRoleMapping`: object in the format "role": string and "folders": array of strings. The user with this role will have access to the following folders if `enableFolderRoles` is enabled. To get the IDs of the folders, GET the `/Library/MediaFolders` URL with an API key. Look for the `Id` attribute.
- `roleClaim`: string. This is the value in the OpenID response to check for roles. For Keycloak, it is `realm_access.roles` by default. The first element is the claim type, the subsequent values are to parse the JSON of the claim value. Use a "\\." to denote a literal ".". This expects a list of strings from the OIDC server.
- `oidScopes` : array of strings. each containing an additional scope name to include in the OIDC request.
- 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`.
- 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/Get`: Lists the configurations currently available.
- GET `OID/States`: Lists currently active OpenID flows in progress. - GET `OID/States`: Lists currently active OpenID flows in progress.
### Misc
- POST `Unregister/username`: This "unregisters" a user from SSO. A JSON-formatted string must be posted with the new authentication provider. To reset to the default provider, use `Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider` like so: `curl -X POST -H "Content-Type: application/json" -d '"Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider"' "https://myjellyfin.example.com/sso/Unregister/username?api_key=API_KEY`
## Limitations ## 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`). 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`).
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. ~~Furthermore, there is no functional admin page (yet). PRs for this are welcome. In the meantime, you have to interact with the API to add or remove configurations.~~ Added by [matthewstrasiotto](https://github.com/matthewstrasiotto) in PR [#18](https://github.com/9p4/jellyfin-plugin-sso/pull/18) and [#27](https://github.com/9p4/jellyfin-plugin-sso/pull/27).
There is also no logout callback. Logging out of Jellyfin will log you out of Jellyfin only, instead of the SSO provider as well. 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 it's own domain (for now). This is because I'm using string concatenation for generating some URLs. A PR is welcome to patch this.~~ Fixed in [PR #1](https://github.com/9p4/jellyfin-plugin-sso/pull/1).
**This only works on the web UI**. The user must open the Jellyfin web UI BEFORE using the SSO program to populate some values in the localStorage. **This only works on the web UI**. ~~The user must open the Jellyfin web UI BEFORE using the SSO program to populate some values in the localStorage.~~ Fixed by implementing a comment by [Pfuenzle](https://github.com/Pfuenzle) in [Issue #5](https://github.com/9p4/jellyfin-plugin-sso/issues/5#issuecomment-1041864820).
# Contributing
## Building
This is built with .NET 6.0. Build with `dotnet publish .` for the debug release in the `SSO-Auth` directory. Copy over the `IdentityModel.OidcClient.dll`, the `IdentityModel.dll` and the `SSO-Auth.dll` files in the `/bin/Debug/net6.0/publish` directory to a new folder in your Jellyfin configuration: `config/plugins/sso`.
### VSCode Workflow
An example `.vscode` configuration may be found at [matthewstrasiotto/jellyfin-plugin-sso-vscode](https://github.com/matthewstrasiotto/jellyfin-plugin-sso-vscode).
From the root of this repo, you may clone that to `.vscode`
```bash
# From repo root
git clone https://github.com/matthewstrasiotto/jellyfin-plugin-sso-vscode .vscode
```
## Releasing
This plugin uses [JPRM](https://github.com/oddstr13/jellyfin-plugin-repository-manager) to build the plugin. Refer to the documentation there to install JPRM.
Build the zipped plugin with `jprm --verbosity=debug plugin build .`.
### CI Releases
Anything merged to the main branch will be built and published by our CI system.
Anything tagged/released as a formal Github release will also be built and published by our CI system.
If you wish to use releases from your own fork, refer to
[Installing](#installing), however, you will need to change the url to the
manifest file, https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/manifest-release/manifest.json
so that it refers to your fork.
## Credits and Thanks ## Credits and Thanks
@@ -170,3 +261,7 @@ I use the [AspNet SAML](https://github.com/jitbit/AspNetSaml/) library for the S
I use the [IdentityModel OIDC Client](https://github.com/IdentityModel/IdentityModel.OidcClient/) library for the OpenID side of things. I use the [IdentityModel OIDC Client](https://github.com/IdentityModel/IdentityModel.OidcClient/) library for the OpenID side of things.
Thanks to these projects, without which I would have been pulling my hair out implementing these protocols from scratch. Thanks to these projects, without which I would have been pulling my hair out implementing these protocols from scratch.
## Something funny about the origins of this plugin
It totally slipped my mind, but I had [requested this functionality a few years back](https://github.com/jellyfin/jellyfin/issues/2012). What goes around comes around, I guess.
+46
View File
@@ -0,0 +1,46 @@
// The following code is a derivative work of the code from the Jellyfin project,
// which is licensed GPLv2. This code therefore is also licensed under the terms
// of the GNU Public License, verison 2.
// https://github.com/jellyfin/jellyfin/blob/a60cb280a3d31ba19ffb3a94cf83ef300a7473b7/Jellyfin.Api/Helpers/RequestHelpers.cs#L63-L77
// Use of this relatively small snippet complies with fair use
// See https://www.gnu.org/licenses/gpl-faq.en.html#SourceCodeInDocumentation
// These helpers were not published within a Nuget package, so it was neccessary to re-implement.
using System;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Net;
using Microsoft.AspNetCore.Http;
namespace Jellyfin.Plugin.SSO_Auth.Helpers;
/// <summary>
/// Request Extensions.
/// </summary>
public static class RequestHelpers
{
/// <summary>
/// Checks if the user can update an entry.
/// </summary>
/// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
/// <param name="requestContext">The <see cref="HttpRequest"/>.</param>
/// <param name="userId">The user id.</param>
/// <param name="restrictUserPreferences">Whether to restrict the user preferences.</param>
/// <returns>A <see cref="bool"/> whether the user can update the entry.</returns>
internal static async Task<bool> AssertCanUpdateUser(IAuthorizationContext authContext, HttpRequest requestContext, Guid userId, bool restrictUserPreferences)
{
var auth = await authContext.GetAuthorizationInfo(requestContext).ConfigureAwait(false);
var authenticatedUser = auth.User;
// If they're going to update the record of another user, they must be an administrator
if ((!userId.Equals(auth.UserId) && !authenticatedUser.HasPermission(PermissionKind.IsAdministrator))
|| (restrictUserPreferences && !authenticatedUser.EnableUserPreferenceAccess))
{
return false;
}
return true;
}
}
File diff suppressed because it is too large Load Diff
+86
View File
@@ -0,0 +1,86 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model;
using MediaBrowser.Model.Plugins;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SSO_Auth.Views;
/// <summary>
/// The sso views controller.
/// </summary>
[ApiController]
[Route("[controller]")]
public class SSOViewsController : ControllerBase
{
private readonly IUserManager _userManager;
private readonly ISessionManager _sessionManager;
private readonly IAuthorizationContext _authContext;
private readonly ILogger<SSOViewsController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SSOViewsController"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{SSOViewsController}"/> interface.</param>
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
/// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
public SSOViewsController(ILogger<SSOViewsController> logger, ISessionManager sessionManager, IUserManager userManager, IAuthorizationContext authContext)
{
_sessionManager = sessionManager;
_userManager = userManager;
_authContext = authContext;
_logger = logger;
_logger.LogInformation("SSO Views Controller initialized");
}
private ActionResult ServeView(string viewName)
{
IEnumerable<PluginPageInfo> pages = null;
if (SSOPlugin.Instance == null)
{
return BadRequest("No plugin instance found");
}
pages = SSOPlugin.Instance.GetViews();
if (pages == null)
{
return NotFound("Pages is null or empty");
}
var view = pages.FirstOrDefault(pageInfo => pageInfo.Name == viewName, null);
if (view == null)
{
return NotFound("No matching view found");
}
#nullable enable
Stream? stream = SSOPlugin.Instance.GetType().Assembly.GetManifestResourceStream(view.EmbeddedResourcePath);
if (stream == null)
{
_logger.LogError("Failed to get resource {Resource}", view.EmbeddedResourcePath);
return NotFound();
}
#nullable disable
return File(stream, MimeTypes.GetMimeType(view.EmbeddedResourcePath));
}
/// <summary>
/// Gets a html view.
/// </summary>
/// <param name="viewName">The name of the view / asset to fetch.</param>
/// <returns>The html view with the specified name.</returns>
[HttpGet("{viewName}")]
public ActionResult GetView([FromRoute] string viewName)
{
return ServeView(viewName);
}
}
+186 -12
View File
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Xml.Serialization; using System.Xml.Serialization;
@@ -13,55 +14,228 @@ public class PluginConfiguration : MediaBrowser.Model.Plugins.BasePluginConfigur
/// </summary> /// </summary>
public PluginConfiguration() public PluginConfiguration()
{ {
SamlConfigs = new List<SamlConfig>(); SamlConfigs = new SerializableDictionary<string, SamlConfig>();
OIDConfigs = new List<OIDConfig>(); OidConfigs = new SerializableDictionary<string, OidConfig>();
} }
[XmlArray("SamlConfigs")] /// <summary>
[XmlArrayItem(typeof(SamlConfig), ElementName = "SamlConfigs")] /// Gets or sets the SAML configurations available.
public List<SamlConfig> SamlConfigs { get; set; } /// </summary>
[XmlElement("SamlConfigs")]
public SerializableDictionary<string, SamlConfig> SamlConfigs { get; set; }
[XmlArray("OIDConfigs")] /// <summary>
[XmlArrayItem(typeof(OIDConfig), ElementName = "OIDConfigs")] /// Gets or sets the OpenID configurations available.
public List<OIDConfig> OIDConfigs { get; set; } /// </summary>
[XmlElement("OidConfigs")]
public SerializableDictionary<string, OidConfig> OidConfigs { get; set; }
} }
/// <summary>
/// The configuration required for a SAML flow.
/// </summary>
[XmlRoot("PluginConfiguration")] [XmlRoot("PluginConfiguration")]
public class SamlConfig public class SamlConfig
{ {
private SerializableDictionary<string, Guid> _canonicalLinks;
/// <summary>
/// Gets or sets the SAML information endpoint.
/// </summary>
public string SamlEndpoint { get; set; } public string SamlEndpoint { get; set; }
/// <summary>
/// Gets or sets the SAML provider's client ID.
/// </summary>
public string SamlClientId { get; set; } public string SamlClientId { get; set; }
/// <summary>
/// Gets or sets the SAML public key.
/// </summary>
public string SamlCertificate { get; set; } public string SamlCertificate { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the provider is enabled.
/// </summary>
public bool Enabled { get; set; } 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; } public bool EnableAllFolders { get; set; }
/// <summary>
/// Gets or sets what folders should users have access to by default.
/// </summary>
public string[] EnabledFolders { get; set; } 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; } 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; } 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 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")] [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; } 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; } public bool EnableAllFolders { get; set; }
/// <summary>
/// Gets or sets what folders should users have access to by default.
/// </summary>
public string[] EnabledFolders { get; set; } 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; } 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; } 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 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 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>
/// 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; }
} }
+403
View File
@@ -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");
}
+466 -95
View File
@@ -1,103 +1,474 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>SSO</title> <title>SSO</title>
</head> </head>
<body> <body>
<div data-role="page" class="page type-interior pluginConfigurationPage esqConfigurationPage"> <div
<div data-role="content"> id="sso-config-page"
<div class="content-primary"> data-role="page"
<form class="esqConfigurationForm"> class="page type-interior pluginConfigurationPage esqConfigurationPage"
<div class="verticalSection verticalSection-extrabottompadding"> data-controller="__plugin/SSO-Auth.js"
<div class="sectionTitleContainer flex align-items-center"> >
<h2 class="sectionTitle">SSO Settings:</h2> <div data-role="content">
<a is="emby-button" class="raised button-alt headerHelpButton" target="_blank" href="https://github.com/9p4/jellyfin-plugin-sso">${Help}</a> <div class="content-primary">
</div> <div class="sectionTitleContainer flex align-items-center">
<div class="verticalSection" is="emby-collapse" title="SSO Server Settings"> <h2 class="sectionTitle">SSO Settings:</h2>
<button id="newSaml" is="emby-button"> <a
<span>Add new SAML provider</span> is="emby-button"
</button> class="raised button-alt headerHelpButton"
<button id="newOID" is="emby-button"> target="_blank"
<span>Add new OpenID provider</span> href="https://github.com/9p4/jellyfin-plugin-sso"
</button> >${Help}</a
<div class="collapseContent" id="default"> >
<div class="samlProviderWrapper"> </div>
<div class="samlProvider"> <p>
<div class="inputContainer"> <i>Note:</i>
<input is="emby-input" type="text" id="txtSamlEndpoint" required placeholder="https://saml-provider.example.com/login" label="SAML Endpoint" /> Making changes to this configuration requires a restart of Jellyfin.
</div> <br />
<div class="inputContainer"> This plug-in is in early development, not all configuration options
<input is="emby-input" type="text" id="txtSamlClientId" required placeholder="myjellyfin.example.com" label="SAML Client ID" /> have been implented in the UI, for example, SAML provider
</div> configuration has not been implemented.
<div class="inputContainer"> <br />
<textarea is="emby-textarea" id="txtSamlCertificate" placeholder="Base64-encoded realm certificate" label="SAML Certificate"></textarea> See the
</div> <a
<div class="checkboxContainer checkboxContainer-withDescription"> is="emby-linkbutton"
<label> href="https://github.com/9p4/jellyfin-plugin-sso"
<input type="checkbox" is="emby-checkbox" id="chkSamlEnable" /> class="button-link"
<span>Enable Provider</span> >help page</a
</label> >
</div> and
</div> <a
</div> 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>
<button id="btnSaveSettings" is="emby-button" type="submit" value="submit" class="raised button block"> <form id="sso-load-config" class="esqConfigurationForm">
<span>Update Provider</span> <div
</button> class="verticalSection"
</div> is="emby-collapse"
</div> title="Select Existing Provider to Modify"
</div> >
</form> <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> </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="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>
<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>
<script type="text/javascript">
var SSOConfigurationPage = {
pluginUniqueId: "505ce9d1-d916-42fa-86ca-673ef241d7df",
};
document.querySelector('.esqConfigurationPage').addEventListener("pageshow", function () {
Dashboard.showLoadingMsg();
window.ApiClient.getPluginConfiguration(SSOConfigurationPage.pluginUniqueId).then(function (config) {
SSOConfigurationPage.txtSamlEndpoint.value = config.SamlEndpoint;
SSOConfigurationPage.txtSamlCertificate.value = config.SamlCertificate;
SSOConfigurationPage.txtSamlClientId.value = config.SamlClientId;
});
Dashboard.hideLoadingMsg();
});
var form = document.querySelector(".esqConfigurationForm");
form.addEventListener("submit", function(e){
e.preventDefault();
Dashboard.showLoadingMsg();
window.ApiClient.getPluginConfiguration(SSOConfigurationPage.pluginUniqueId).then(function (config) {
config.SamlCertificate = SSOConfigurationPage.txtSamlCertificate.value;
config.SamlEndpoint = SSOConfigurationPage.txtSamlEndpoint.value;
config.SamlClientId = SSOConfigurationPage.txtSamlClientId.value;
window.ApiClient.updatePluginConfiguration(SSOConfigurationPage.pluginUniqueId, config).then(Dashboard.processPluginConfigurationUpdateResult);
});
// Disable default form submission
return false;
});
var newSaml = document.getElementById("newSaml");
newSaml.addEventListener("click", function(e) {
e.preventDefault();
Dashboard.showLoadingMsg();
fetch(window.ApiClient.getUrl("sso/SAML/Get?api_key=" + window.ApiClient.accessToken()))
.then(response => {
if (!response.ok) {
throw new Error("HTTP error " + response.status);
}
return response.json();
}).then(json => {console.log(json)});
});
</script>
</div> </div>
</body> </body>
</html> </html>
+99
View File
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Polyfill styles that are missing when serving without dashboard -->
<link rel="stylesheet" href="emby-restyle.css" />
<link id="theme-style" rel="stylesheet" />
<script defer>
import("./ApiClient.js").then((clientModule) => {
import("./linking.js").then((renderer) => {
const view = document.querySelector("#sso-config-page");
document.querySelector("#theme-style").href = ApiClient.getUrl(
"/web/themes/dark/theme.css"
);
const homeButton = document.querySelector("a.emby-button.home");
homeButton.href = ApiClient.serverAddress();
homeButton.style.display = "";
renderer.default(view);
});
});
</script>
<title>SSO Linking</title>
</head>
<body class="force-scroll dashboardDocument mouseIdle">
<div
id="sso-config-page"
data-role="page"
class="page type-interior pluginConfigurationPage esqConfigurationPage"
data-controller="__plugin/SSO-Auth-linking.js"
>
<div data-role="content">
<div class="content-primary">
<div class="sectionTitleContainer flex align-items-center">
<a class="raised emby-button home" style="display: none">
<span class="material-icons home" aria-hidden="true"></span
><span>Home</span>
</a>
<h2 class="sectionTitle">SSO Linking:</h2>
<a
is="emby-button"
class="raised button-alt headerHelpButton"
target="_blank"
href="https://github.com/9p4/jellyfin-plugin-sso"
>${Help}</a
>
</div>
<p>
Use the
<span class="fab" aria-hidden="true">
<span class="material-icons add" aria-hidden="true"></span>
</span>
button to create a new link for the given provider.
<br />
You may remove existing links by selecting them and pressing the
"Delete" button.
</p>
<p>
See the
<a
is="emby-linkbutton"
href="https://github.com/9p4/jellyfin-plugin-sso"
class="button-link"
>help page</a
>
and
<a
is="emby-linkbutton"
href="https://github.com/9p4/jellyfin-plugin-sso/projects/1"
class="button-link"
>roadmap
</a>
for more information.
</p>
<label class="checkbox-wrapper">
<input is="emby-checkbox" id="enable-delete" type="checkbox" />
<span class="checkbox-label">Enable "Delete" button.</span>
</label>
<h1 class="sectionTitle">Link a provider</h1>
<div class="verticalSection" title="Link a provider">
<h2 class="sectionTitle">SAML</h2>
<div id="sso-provider-list-saml" data-id="saml"></div>
<h2 class="sectionTitle">OID</h2>
<div id="sso-provider-list-oid" data-id="oid"></div>
</div>
<button
id="btn-delete-selected-links"
type="button"
class="button-delete raised emby-button"
disabled="true"
>
<span class="material-icons delete" aria-hidden="true"></span>
<span>Delete Selected</span>
</button>
</div>
</div>
</div>
</body>
</html>
+186
View File
@@ -0,0 +1,186 @@
const ssoConfigLinking = {
pluginUniqueId: "505ce9d1-d916-42fa-86ca-673ef241d7df",
loadProviders: (view) => {
const provider_list_id = "sso-provider-list";
const provider_list_saml_id = `${provider_list_id}-saml`;
const provider_list_oid_id = `${provider_list_id}-oid`;
const provider_list_saml = view.querySelector(`#${provider_list_saml_id}`);
const provider_list_oid = view.querySelector(`#${provider_list_oid_id}`);
provider_list_saml.innerHTML = "";
provider_list_oid.innerHTML = "";
fetch(new Request(ApiClient.getUrl("sso/OID/GetNames"))).then((resp) => {
resp.json().then((config_names) => {
ssoConfigLinking.loadProviderList(
provider_list_oid,
config_names,
"oid"
);
});
});
fetch(new Request(ApiClient.getUrl("sso/SAML/GetNames"))).then((resp) => {
resp.json().then((config_names) => {
ssoConfigLinking.loadProviderList(
provider_list_saml,
config_names,
"saml"
);
});
});
},
loadProviderList: (container, providers, provider_mode) => {
providers.forEach((provider_name) => {
var provider_config = document.createElement("div");
provider_config.classList.add("sso-provider-links-container");
provider_config.setAttribute("data-id", provider_name);
provider_config.innerHTML = `
<label
class="inputLabel inputLabelUnfocused sso-provider-link-title"
>${provider_name}
</label>
<a
class="fab emby-button sso-provider-add-link"
>
<span class="material-icons add" aria-hidden="true"></span>
</a>
<div
class="sso-provider-existing-links-container"
data-provider="${provider_name}"
></div>
`;
var add_provider = provider_config.querySelector(
".sso-provider-add-link"
);
//const provider_name_css = ssoConfigLinking.safeCSSId(provider_name);
//provider_link.id = "sso-provider-" + provider_name_css;
//provider_link.classList.add("sso-provider-" + provider_name_css);
add_provider.classList.add("sso-provider");
add_provider.href = ApiClient.getUrl(
`/SSO/${provider_mode}/p/${provider_name}?isLinking=true`
);
container.appendChild(provider_config);
});
const currentUserId = ApiClient.getCurrentUserId();
if (currentUserId) {
ApiClient.fetch(
{
type: "GET",
url: ApiClient.getUrl(`sso/${provider_mode}/links/${currentUserId}`),
},
true
).then((resp) => {
resp.json().then((provider_map) => {
console.log({ provider_map, currentUserId });
Object.keys(provider_map).forEach((provider_name) => {
const provider_container = container.querySelector(
`.sso-provider-existing-links-container[data-provider="${provider_name}"]`
);
ssoConfigLinking.populateExistingLinks(
provider_container,
provider_mode,
provider_name,
provider_map[provider_name]
);
});
});
});
}
},
populateExistingLinks: (
container,
provider_mode,
provider_name,
canonical_names
) => {
container
.querySelectorAll(".sso-provider-link-checkbox-wrapper")
.forEach((e) => e.remove());
const checkboxes = canonical_names.map((canonical_name) => {
var out = document.createElement("label");
out.classList.add("sso-provider-link-checkbox-wrapper");
out.classList.add("checkbox-wrapper");
out.innerHTML = `
<input
is="emby-checkbox"
class="sso-link-checkbox"
data-id="${canonical_name}"
data-mode="${provider_mode}"
data-provider="${provider_name}"
type="checkbox"
/>
<span class="checkbox-label">${canonical_name}</span>
`;
return out;
});
checkboxes.forEach((e) => {
container.appendChild(e);
});
},
handleDeleteButtonPressed: (evt, view) => {
if (evt.target.disabled) return;
const currentUserId = ApiClient.getCurrentUserId();
if (!currentUserId) return;
const delete_requests = [...view.querySelectorAll(".sso-link-checkbox")]
.filter((checkbox_link) => {
const canonical_name = checkbox_link.getAttribute("data-id");
const provider_name = checkbox_link.getAttribute("data-provider");
const provider_mode = checkbox_link.getAttribute("data-mode");
if (![canonical_name, provider_name, provider_mode].every((e) => e)) {
return false;
}
if (!checkbox_link.checked) {
return false;
}
return true;
})
.map((checked_link) => {
const canonical_name = checked_link.getAttribute("data-id");
const provider_name = checked_link.getAttribute("data-provider");
const provider_mode = checked_link.getAttribute("data-mode");
return ApiClient.fetch({
type: "DELETE",
url: ApiClient.getUrl(
`sso/${provider_mode}/link/${provider_name}/${currentUserId}/${canonical_name}`
),
});
});
Promise.all(delete_requests).then((values) => {
console.log({ message: "Delete requests handled", values });
window.location.reload();
});
},
};
export default function (view) {
ssoConfigLinking.loadProviders(view);
view.querySelector("#enable-delete").addEventListener("change", (e) => {
view.querySelector("#btn-delete-selected-links").disabled =
!e.target.checked;
});
view
.querySelector("#btn-delete-selected-links")
.addEventListener("click", (e) =>
ssoConfigLinking.handleDeleteButtonPressed(e, view)
);
}
+62
View File
@@ -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;
}
+16 -3
View File
@@ -3,22 +3,35 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.SSO_Auth</RootNamespace> <RootNamespace>Jellyfin.Plugin.SSO_Auth</RootNamespace>
<AssemblyVersion>2.0.1.0</AssemblyVersion> <AssemblyVersion>3.3.0.0</AssemblyVersion>
<FileVersion>2.0.1.0</FileVersion> <FileVersion>3.3.0.0</FileVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors> <TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<None Remove="Config\configPage.html" /> <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\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>
<ItemGroup> <ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="IdentityModel.OidcClient" Version="5.0.0" /> <PackageReference Include="IdentityModel.OidcClient" Version="5.0.0" />
<PackageReference Include="Jellyfin.Controller" Version="10.*-*" /> <PackageReference Include="Jellyfin.Controller" Version="10.*-*" />
<PackageReference Include="Jellyfin.Model" 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="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="6.0.0" /> <PackageReference Include="System.Security.Cryptography.Xml" Version="6.0.0" />
</ItemGroup> </ItemGroup>
+89 -4
View File
@@ -8,26 +8,111 @@ using MediaBrowser.Model.Serialization;
namespace Jellyfin.Plugin.SSO_Auth; 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) public SSOPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer) : base(applicationPaths, xmlSerializer)
{ {
Instance = this; Instance = this;
} }
/// <summary>
/// Gets the instance of the SSO plugin.
/// </summary>
public static SSOPlugin Instance { get; private set; } public static SSOPlugin Instance { get; private set; }
/// <summary>
/// Gets the name of the SSO plugin.
/// </summary>
public override string Name => "SSO-Auth"; 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"); 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() public IEnumerable<PluginPageInfo> GetPages()
{ {
yield return new PluginPageInfo return new[]
{ {
Name = Name, new PluginPageInfo
EmbeddedResourcePath = $"{GetType().Namespace}.Config.configPage.html" {
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"
},
}; };
} }
} }
+99
View File
@@ -18,33 +18,61 @@ using System.Xml;
namespace Jellyfin.Plugin.SSO_Auth; namespace Jellyfin.Plugin.SSO_Auth;
/// <summary>
/// Represents a SAML response.
/// </summary>
public class Response public class Response
{ {
private readonly X509Certificate2 _certificate; private readonly X509Certificate2 _certificate;
private XmlDocument _xmlDoc; private XmlDocument _xmlDoc;
private XmlNamespaceManager _xmlNameSpaceManager; // we need this one to run our XPath queries on the SAML XML 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) public Response(string certificateStr, string responseString)
: this(Convert.FromBase64String(certificateStr), 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) public Response(byte[] certificateBytes, string responseString) : this(certificateBytes)
{ {
LoadXmlFromBase64(responseString); 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)) 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) public Response(byte[] certificateBytes)
{ {
_certificate = new X509Certificate2(certificateBytes); _certificate = new X509Certificate2(certificateBytes);
} }
/// <summary>
/// Gets the SAML response's XML data.
/// </summary>
public string Xml => _xmlDoc.OuterXml; 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) public void LoadXml(string xml)
{ {
_xmlDoc = new XmlDocument(); _xmlDoc = new XmlDocument();
@@ -55,11 +83,19 @@ public class Response
_xmlNameSpaceManager = GetNamespaceManager(); // lets construct a "manager" for XPath queries _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) public void LoadXmlFromBase64(string response)
{ {
LoadXml(Encoding.UTF8.GetString(Convert.FromBase64String(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() public bool IsValid()
{ {
var nodeList = _xmlDoc.SelectNodes("//ds:Signature", _xmlNameSpaceManager); var nodeList = _xmlDoc.SelectNodes("//ds:Signature", _xmlNameSpaceManager);
@@ -118,17 +154,29 @@ public class Response
return DateTime.UtcNow > expirationDate.ToUniversalTime(); 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() public string GetNameID()
{ {
var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:Subject/saml:NameID", _xmlNameSpaceManager); var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:Subject/saml:NameID", _xmlNameSpaceManager);
return node.InnerText; return node.InnerText;
} }
/// <summary>
/// Gets the UPN attribute from the XML response.
/// </summary>
/// <returns>The UPN attribute.</returns>
public virtual string GetUpn() public virtual string GetUpn()
{ {
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"); 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() public virtual string GetEmail()
{ {
return GetCustomAttribute("User.email") return GetCustomAttribute("User.email")
@@ -138,6 +186,10 @@ public class Response
?? GetCustomAttribute("mail"); ?? GetCustomAttribute("mail");
} }
/// <summary>
/// Gets the First Name attribute from the XML response.
/// </summary>
/// <returns>The First Name attribute.</returns>
public virtual string GetFirstName() public virtual string GetFirstName()
{ {
return GetCustomAttribute("first_name") return GetCustomAttribute("first_name")
@@ -148,6 +200,10 @@ public class Response
?? GetCustomAttribute("givenName"); ?? GetCustomAttribute("givenName");
} }
/// <summary>
/// Gets the Last Name attribute from the XML response.
/// </summary>
/// <returns>The Last Name attribute.</returns>
public virtual string GetLastName() public virtual string GetLastName()
{ {
return GetCustomAttribute("last_name") return GetCustomAttribute("last_name")
@@ -158,18 +214,30 @@ public class Response
?? GetCustomAttribute("sn"); ?? GetCustomAttribute("sn");
} }
/// <summary>
/// Gets the department attribute from the XML response.
/// </summary>
/// <returns>The department attribute.</returns>
public virtual string GetDepartment() public virtual string GetDepartment()
{ {
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department") return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department")
?? GetCustomAttribute("department"); ?? GetCustomAttribute("department");
} }
/// <summary>
/// Gets the phone attribute from the XML response.
/// </summary>
/// <returns>The phone attribute.</returns>
public virtual string GetPhone() public virtual string GetPhone()
{ {
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/homephone") return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/homephone")
?? GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/telephonenumber"); ?? 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() public virtual string GetCompany()
{ {
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/companyname") return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/companyname")
@@ -177,18 +245,32 @@ public class Response
?? GetCustomAttribute("User.CompanyName"); ?? GetCustomAttribute("User.CompanyName");
} }
/// <summary>
/// Gets the location attribute from the XML response.
/// </summary>
/// <returns>The location attribute.</returns>
public virtual string GetLocation() public virtual string GetLocation()
{ {
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/location") return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/location")
?? GetCustomAttribute("physicalDeliveryOfficeName"); ?? 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) public string GetCustomAttribute(string attr)
{ {
var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager); var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager);
return node?.InnerText; 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) public List<string> GetCustomAttributes(string attr)
{ {
var node = _xmlDoc.SelectNodes("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager); 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); output.Add(item?.InnerText);
} }
return output; return output;
} }
@@ -213,6 +296,9 @@ public class Response
} }
} }
/// <summary>
/// Represents a SAML request.
/// </summary>
public class AuthRequest public class AuthRequest
{ {
private readonly string _id; private readonly string _id;
@@ -221,6 +307,11 @@ public class AuthRequest
private readonly string _issuer; private readonly string _issuer;
private readonly string _assertionConsumerServiceUrl; 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) public AuthRequest(string issuer, string assertionConsumerServiceUrl)
{ {
_id = "_" + Guid.NewGuid().ToString(); _id = "_" + Guid.NewGuid().ToString();
@@ -230,6 +321,9 @@ public class AuthRequest
_assertionConsumerServiceUrl = assertionConsumerServiceUrl; _assertionConsumerServiceUrl = assertionConsumerServiceUrl;
} }
/// <summary>
/// The formatting of the AuthRequest.
/// </summary>
public enum AuthRequestFormat public enum AuthRequestFormat
{ {
/// <summary> /// <summary>
@@ -238,6 +332,11 @@ public class AuthRequest
Base64 = 1 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) public string GetRequest(AuthRequestFormat format)
{ {
using var sw = new StringWriter(); using var sw = new StringWriter();
+141
View File
@@ -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();
}
}
}
+153
View File
@@ -0,0 +1,153 @@
import jellyfinApiclient from "./jellyfin-apiClient.esm.min.js";
window.jellyfinApiclient = jellyfinApiclient;
console.log(jellyfinApiclient);
// https://github.com/jellyfin/jellyfin-web/blob/9067b0e397cc8b38635d661ce86ddd83194f3202/src/scripts/clientUtils.js#L19-L76
export async function serverAddress({ basePath = "/web" }) {
const apiClient = window.ApiClient;
if (apiClient) {
return Promise.resolve(apiClient.serverAddress());
}
const urls = [];
const getViewUrl = (basePath) => {
let url;
const index = window.location.href
.toLowerCase()
.lastIndexOf(basePath.toLowerCase());
if (index != -1) {
url = window.location.href.substring(0, index);
} else {
// Return nothing, let another method handle it
url = undefined;
}
return url;
};
if (urls.length === 0) {
// Otherwise use computed base URL
let url;
url = getViewUrl(basePath) ?? getViewUrl("/web") ?? window.location.origin;
// Don't use bundled app URL (file:) as server URL
if (url.startsWith("file:")) {
return Promise.resolve();
}
urls.push(url);
}
console.debug("URL candidates:", urls);
const promises = urls.map((url) => {
return fetch(`${url}/System/Info/Public`)
.then((resp) => {
return {
url: url,
response: resp,
};
})
.catch(() => {
return Promise.resolve();
});
});
return Promise.all(promises)
.then((responses) => {
responses = responses.filter((obj) => obj && obj.response.ok);
return Promise.all(
responses.map((obj) => {
return {
url: obj.url,
config: obj.response.json(),
};
})
);
})
.then((configs) => {
const selection =
configs.find((obj) => !obj.config.StartupWizardCompleted) || configs[0];
return Promise.resolve(selection?.url);
})
.catch((error) => {
console.log(error);
return Promise.resolve();
});
}
// TODO: Refactor duplicated code
// ! Duplicated at
// https://github.com/9p4/jellyfin-plugin-sso/blob/38558d762a13422862240af4060bdd1bb1618d57/SSO-Auth/WebResponse.cs#L363-L401
function getDeviceName() {
return "DUMMY";
}
function getDeviceId() {
return localStorage.getItem("_deviceId2");
}
const sleep = (milliseconds) => {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
};
async function awaitLocalStorage() {
while (
localStorage.getItem("_deviceId2") == null ||
localStorage.getItem("jellyfin_credentials") == null ||
JSON.parse(localStorage.getItem("jellyfin_credentials"))["Servers"][0][
"Id"
] == null
) {
// If localStorage isn't initialized yet, try again.
await sleep(100);
}
}
await awaitLocalStorage();
// Fetch credentials
var credentials = new jellyfinApiclient.Credentials();
var server = await serverAddress({ basePath: "/SSOViews" });
console.log({ server: server });
var deviceId = getDeviceId();
var appName = "SSO-Auth";
var appVersion = "0.0.0.9000";
var capabilities = {};
const current_server = credentials
.credentials()
.Servers.find((e) => e.LocalAddress == server || e.ManualAddress == server);
var localApiClient = new jellyfinApiclient.ApiClient(
server,
appName,
appVersion,
getDeviceName(),
deviceId
);
localApiClient.setAuthenticationInfo(
current_server.AccessToken,
current_server.UserId
);
var connections = new jellyfinApiclient.ConnectionManager(
credentials,
appName,
appVersion,
getDeviceName(),
deviceId,
capabilities
);
connections.addApiClient(localApiClient);
window.ApiClient = localApiClient;
export default localApiClient;
+480
View File
@@ -0,0 +1,480 @@
/* Material icons polyfills */
.material-icons {
height: 1em;
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: inherit;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "liga";
}
.material-icons.home {
content: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjRweCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik0xMCAyMHYtNmg0djZoNXYtOGgzTDEyIDMgMiAxMmgzdjh6Ii8+PC9zdmc+");
}
.material-icons.add {
content: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjRweCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik0xOSAxM2gtNnY2aC0ydi02SDV2LTJoNlY1aDJ2Nmg2djJ6Ii8+PC9zdmc+");
}
.material-icons.delete {
content: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjRweCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik02IDE5YzAgMS4xLjkgMiAyIDJoOGMxLjEgMCAyLS45IDItMlY3SDZ2MTJ6TTE5IDRoLTMuNWwtMS0xaC01bC0xIDFINXYyaDE0VjR6Ii8+PC9zdmc+");
}
/*
theme.css
*/
.button-delete {
background: rgb(247, 0, 0);
color: rgba(255, 255, 255, 0.87);
}
.button-delete:disabled {
background: rgb(105, 0, 0);
color: rgba(127, 127, 127, 0.87);
cursor: not-allowed;
pointer-events: none;
}
/*
Emby Button
*/
.emby-button {
position: relative;
display: inline-flex;
align-items: center;
box-sizing: border-box;
margin: 0.3em;
text-align: center;
font-size: inherit;
font-family: inherit;
color: inherit;
/* These are getting an outline in opera tv browsers, which run chrome 30 */
outline: none !important;
outline-width: 0;
-moz-user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
user-select: none;
cursor: pointer;
z-index: 0;
padding: 0.9em 1em;
vertical-align: middle;
border: 0;
border-radius: 0.2em;
font-weight: 600;
/* Disable webkit tap highlighting */
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
text-decoration: none;
/* Not crazy about this but it normalizes heights between anchors and buttons */
line-height: 1.35;
transform-origin: center;
transition: 0.2s;
}
.emby-button.show-focus:focus {
transform: scale(1.2);
z-index: 1;
}
.emby-button::-moz-focus-inner {
border: 0;
}
.button-flat {
background: transparent;
}
.button-link {
background: transparent;
cursor: pointer;
margin: 0;
padding: 0;
vertical-align: initial;
}
.button-link:hover {
text-decoration: underline;
}
.emby-button > .material-icons {
/* For non-fab buttons that have icons */
font-size: 1.36em;
}
.button-link > .material-icons {
font-size: 1em;
}
.fab {
display: inline-flex;
border-radius: 50%;
padding: 0.6em;
box-sizing: border-box;
align-items: center;
justify-content: center;
text-align: center;
}
.emby-button.block {
display: block;
align-items: center;
justify-content: center;
margin: 0.25em 0;
width: 100%;
}
.paper-icon-button-light {
position: relative;
display: inline-flex;
align-items: center;
box-sizing: border-box;
margin: 0 0.29em;
background: transparent;
text-align: center;
font-size: inherit;
font-family: inherit;
color: inherit;
-moz-user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
user-select: none;
cursor: pointer;
z-index: 0;
min-width: initial;
min-height: initial;
width: auto;
height: auto;
padding: 0.556em;
vertical-align: middle;
border: 0;
/* These are getting an outline in opera tv browsers, which run chrome 30 */
outline: none !important;
overflow: hidden;
border-radius: 50%;
/* Disable webkit tap highlighting */
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
justify-content: center;
transform-origin: center;
transition: 0.2s;
}
.paper-icon-button-light.show-focus:focus {
transform: scale(1.3);
z-index: 1;
}
.paper-icon-button-light::-moz-focus-inner {
border: 0;
}
.paper-icon-button-light:disabled {
opacity: 0.3;
cursor: default;
}
.paper-icon-button-light > .material-icons {
font-size: 1.66956521739130434em;
/* Make sure its on top of the ripple */
position: relative;
z-index: 1;
vertical-align: middle;
}
.paper-icon-button-light > div {
max-height: 100%;
transform: scale(1.8);
position: relative;
z-index: 1;
vertical-align: middle;
display: inline;
margin: 0 auto;
}
.emby-button-foreground {
position: relative;
z-index: 1;
}
.btnFilterWithBubble {
position: relative;
}
.filterButtonBubble {
color: #fff;
position: absolute;
top: 0;
right: 0;
width: 1.6em;
height: 1.6em;
z-index: 100000000;
display: flex;
align-items: center;
justify-content: center;
font-size: 82%;
border-radius: 100em;
box-shadow: 0 4px 5px 0 rgba(0, 0, 0, 0.14), 0 1px 10px 0 rgba(0, 0, 0, 0.12),
0 2px 4px -1px rgba(0, 0, 0, 0.2);
background: #03a9f4;
font-weight: bold;
}
/* fonts.scss */
html {
font-family: "Noto Sans", "Noto Sans HK", "Noto Sans JP", "Noto Sans KR",
"Noto Sans SC", "Noto Sans TC", sans-serif;
text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
html[lang|="ja"] {
font-family: "Noto Sans", "Noto Sans JP", "Noto Sans HK", "Noto Sans KR",
"Noto Sans SC", "Noto Sans TC", sans-serif;
}
html[lang|="ko"] {
font-family: "Noto Sans", "Noto Sans KR", "Noto Sans HK", "Noto Sans JP",
"Noto Sans SC", "Noto Sans TC", sans-serif;
}
html[lang|="zh-CN"] {
font-family: "Noto Sans", "Noto Sans SC", "Noto Sans HK", "Noto Sans JP",
"Noto Sans KR", "Noto Sans TC", sans-serif;
}
html[lang|="zh-TW"] {
font-family: "Noto Sans", "Noto Sans TC", "Noto Sans HK", "Noto Sans JP",
"Noto Sans KR", "Noto Sans SC", sans-serif;
}
html[lang|="zh-HK"] {
font-family: "Noto Sans", "Noto Sans HK", "Noto Sans JP", "Noto Sans KR",
"Noto Sans SC", "Noto Sans TC", sans-serif;
}
.layout-tv {
/* Per WebOS and Tizen guidelines, fonts must be 20px minimum.
This takes the 16px baseline and multiplies it by 1.25 to get 20px. */
font-size: 125%;
}
.layout-mobile {
font-size: 90%;
}
/* site.scss */
html {
line-height: 1.35;
}
body {
overflow-x: hidden;
background-color: transparent !important;
-webkit-font-smoothing: antialiased;
}
.clipForScreenReader {
clip: rect(1px, 1px, 1px, 1px);
clip-path: inset(50%);
height: 1px;
width: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
}
.material-icons {
/* Fix font ligatures on older WebOS versions */
font-feature-settings: "liga";
}
.backgroundContainer {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
contain: strict;
}
.layout-mobile,
.layout-tv {
-webkit-touch-callout: none;
user-select: none;
}
.mainAnimatedPage {
contain: style size !important;
}
.pageContainer {
overflow-x: visible !important;
}
.bodyWithPopupOpen {
overflow-y: hidden !important;
}
div[data-role="page"] {
outline: 0;
}
.pageTitle {
margin-top: 0;
font-family: inherit;
}
.fieldDescription {
padding-left: 0.15em;
font-weight: 400;
white-space: normal !important;
}
.fieldDescription + .fieldDescription {
margin-top: 0.3em;
}
.content-primary,
.padded-bottom-page,
.page,
.pageWithAbsoluteTabs .pageTabContent {
/* provides room for the music controls */
padding-bottom: 5em !important;
}
.readOnlyContent {
@media all and (min-width: 50em) {
max-width: 54em;
}
}
form {
@media all and (min-width: 50em) {
max-width: 54em;
}
}
.headerHelpButton {
margin-left: 1.25em !important;
padding-bottom: 0.4em !important;
padding-top: 0.4em !important;
}
.mediaInfoContent {
margin-left: auto;
margin-right: auto;
width: 85%;
}
.headroom {
will-change: transform;
transition: transform 200ms linear;
}
.drawerContent {
/* make sure the bottom of the drawer is visible when music is playing */
padding-bottom: 4em;
}
.force-scroll {
overflow-y: scroll;
}
.hide-scroll {
overflow-y: hidden;
}
.w-100 {
width: 100%;
}
.margin-auto-x {
margin-left: auto;
margin-right: auto;
}
.margin-auto-y {
margin-top: auto;
margin-bottom: auto;
}
/* Fix checkboxes */
/* Customize the label (the container) */
*/ .checkbox-wrapper {
position: relative;
}
.checkbox-wrapper [type="checkbox"] {
/*display: none;*/
position: absolute;
top: 0px;
left: 0px;
height: 20px;
width: 20px;
-webkit-appearance: none;
}
.checkbox-label {
display: flex;
position: relative;
font-size: 20px;
font-weight: 400;
align-items: center;
justify-content: flex-start;
margin-bottom: 20px;
}
.checkbox-label:before,
.checkbox-label:after {
pointer-events: none;
}
.checkbox-label:before {
display: flex;
content: " ";
height: 20px;
width: 20px;
border: 0.14em solid white;
border-radius: 0.14em;
/* background: #fff; */
margin-right: 10px;
}
.checkbox-label:after {
position: absolute;
top: 0;
left: 0;
display: flex;
content: " ";
height: 20px;
width: 20px;
border: 0.14em solid white;
border-radius: 0.14em;
background: none;
}
.checkbox-wrapper input[type="checkbox"]:checked + .checkbox-label:after {
background-color: #2196f3;
content: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjRweCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik05IDE2LjJMNC44IDEybC0xLjQgMS40TDkgMTkgMjEgN2wtMS40LTEuNEw5IDE2LjJ6Ii8+PC9zdmc+");
}
File diff suppressed because one or more lines are too long
+73 -54
View File
@@ -1,9 +1,19 @@
namespace Jellyfin.Plugin.SSO_Auth; namespace Jellyfin.Plugin.SSO_Auth;
/// <summary>
/// A helper class to return HTML for the client's auth flow.
/// </summary>
public static class WebResponse public static class WebResponse
{ {
/// <summary>
/// The shared HTML between all of the responses.
/// </summary>
public static readonly string Base = @"<!DOCTYPE html> public static readonly string Base = @"<!DOCTYPE html>
<html><head></head><body><script> <html><head></head><body>
<p>Logging in...</p>
<noscript>Please enable Javascript to complete the login</noscript>
<script>
function isTv() { function isTv() {
// This is going to be really difficult to get right // This is going to be really difficult to get right
const userAgent = navigator.userAgent.toLowerCase(); const userAgent = navigator.userAgent.toLowerCase();
@@ -389,22 +399,77 @@ function getDeviceName() {
return deviceName; return deviceName;
} }
const sleep = (milliseconds) => {
return new Promise(resolve => setTimeout(resolve, milliseconds))
}
"; ";
public static string OIDGenerator(string data, string provider, string baseUrl) /// <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 + @" 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() { async function main() {
var data = '" + data + @"'; var data = '" + data + @"';
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);
}
var deviceId = localStorage.getItem(""_deviceId2""); var deviceId = localStorage.getItem(""_deviceId2"");
var appName = ""Jellyfin Web""; var appName = ""Jellyfin Web"";
var appVersion = ""10.8.0""; var appVersion = ""10.8.0"";
var deviceName = getDeviceName(); var deviceName = getDeviceName();
var provider = '" + provider + @"';
var request = {'deviceID': deviceId, 'appName': appName, 'appVersion': appVersion, deviceName: 'deviceName', data: data, provider: '" + provider + @"'}; var request = {deviceId, appName, appVersion, deviceName, data};
var url = '" + baseUrl + @"/sso/OID/Auth'; if (" + $"{isLinking}".ToLower() + @") await link(request);
var url = '" + baseUrl + "/sso/" + mode + "/Auth/" + provider + @"';
let response = await new Promise(resolve => { let response = await new Promise(resolve => {
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
@@ -428,60 +493,14 @@ async function main() {
jfCreds['Servers'][0]['UserId'] = responseJson['User']['Id']; jfCreds['Servers'][0]['UserId'] = responseJson['User']['Id'];
localStorage.setItem('jellyfin_credentials', JSON.stringify(jfCreds)); localStorage.setItem('jellyfin_credentials', JSON.stringify(jfCreds));
localStorage.setItem('enableAutoLogin', 'true'); localStorage.setItem('enableAutoLogin', 'true');
window.location.replace('/'); window.location.replace('" + baseUrl + @"');
} }
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
main(); main();
}); });
</script></body></html>"; // https://stackoverflow.com/a/25435165
} </script><iframe class='docs-texteventtarget-iframe' sandbox='allow-same-origin allow-forms allow-scripts' src='" + baseUrl + "' style='position: absolute;width:0;height:0;border:0;'></iframe></body></html>";
public static string SamlGenerator(string xml, string provider, string baseUrl)
{
return Base + @"
async function main() {
var xml = '" + xml + @"';
var deviceId = localStorage.getItem(""_deviceId2"");
var appName = ""Jellyfin Web"";
var appVersion = ""10.8.0"";
var deviceName = getDeviceName();
var provider = '" + provider + @"';
var request = {'deviceID': deviceId, 'appName': appName, 'appVersion': appVersion, deviceName: 'deviceName', data: xml, provider: '" + provider + @"'};
var url = '" + baseUrl + @"/sso/SAML/Auth';
let response = await new Promise(resolve => {
var xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Accept', 'application/json');
xhr.onload = function(e) {
resolve(xhr.response);
};
xhr.onerror = function () {
resolve(undefined);
};
xhr.send(JSON.stringify(request));
})
var responseJson = JSON.parse(response);
var userId = 'user-' + responseJson['User']['Id'] + '-' + responseJson['User']['ServerId'];
responseJson['User']['EnableAutoLogin'] = true;
localStorage.setItem(userId, JSON.stringify(responseJson['User']));
var jfCreds = JSON.parse(localStorage.getItem('jellyfin_credentials'));
jfCreds['Servers'][0]['AccessToken'] = responseJson['AccessToken'];
jfCreds['Servers'][0]['UserId'] = responseJson['User']['Id'];
localStorage.setItem('jellyfin_credentials', JSON.stringify(jfCreds));
localStorage.setItem('enableAutoLogin', 'true');
window.location.replace('/');
}
document.addEventListener('DOMContentLoaded', function () {
main();
});
</script></body></html>";
} }
} }
+7 -1
View File
@@ -1,7 +1,7 @@
name: "SSO Authentication" name: "SSO Authentication"
guid: "505ce9d1-d916-42fa-86ca-673ef241d7df" guid: "505ce9d1-d916-42fa-86ca-673ef241d7df"
imageUrl: "https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/main/img/logo.png" imageUrl: "https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/main/img/logo.png"
version: "2.0.1.0" version: "3.4.0.0"
targetAbi: "10.8.0.0" targetAbi: "10.8.0.0"
framework: "net6.0" framework: "net6.0"
owner: "9p4" owner: "9p4"
@@ -15,6 +15,12 @@ artifacts:
- "IdentityModel.OidcClient.dll" - "IdentityModel.OidcClient.dll"
- "IdentityModel.dll" - "IdentityModel.dll"
changelog: | changelog: |
3.4.0.0: Add user self-service for linking existing accounts + managing existing links. Allow IDP accounts to be linked to jellyfin accounts with a different display-name.
3.3.0.0: Add fallback authentication provider. Add OpenID admin page.
3.2.0.0: Switch to hashmaps (BREAKING) for performance. Dump expected permissions in logs on error.
3.1.0.1: Fix redirect bug in WebResponse (#7)
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
2.0.1.0: Fix improper artifact loading 2.0.1.0: Fix improper artifact loading
2.0.0.0: Add RBAC and Google support 2.0.0.0: Add RBAC and Google support
1.0.0.0: Initial Release 1.0.0.0: Initial Release
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

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