├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md └── workflows │ ├── build.yml │ ├── dotnet.yml │ ├── prettier.yml │ ├── publish-nightly.yml │ └── publish.yml ├── .gitignore ├── .prettierignore ├── CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── SECURITY.md ├── SSO-Auth.sln ├── SSO-Auth ├── Api │ ├── RequestHelpers.cs │ └── SSOController.cs ├── Config │ ├── PluginConfiguration.cs │ ├── config.js │ ├── configPage.html │ ├── linking.html │ ├── linking.js │ └── style.css ├── Lib │ ├── Lib.fsproj │ └── Library.fs ├── SSO-Auth.csproj ├── SSOPlugin.cs ├── Saml.cs ├── SerializableDictionary.cs ├── Views │ ├── SSOViewsController.cs │ ├── apiClient.js │ ├── emby-restyle.css │ └── jellyfin-apiClient.esm.min.js └── WebResponse.cs ├── build.yaml ├── flake.lock ├── flake.nix ├── img ├── authentik-config-01.jpg ├── authentik-config-02.jpg ├── authentik-config-03.jpg ├── authentik-config-04.jpg ├── authentik-config-05.jpg ├── custom-button.png ├── logo.png └── recording-resized.mp4 ├── jellyfin.ruleset └── providers.md /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Configuration** 27 | Add your plugin configuration XML file here formatted as code (with three backticks surrounding the text), or as an upload to a pastebin service. 28 | 29 | **Versions (please complete the following information):** 30 | 31 | - OS: [e.g. Linux] 32 | - Browser: [e.g. chrome, safari] 33 | - Jellyfin Version: [e.g. 10.8 Alpha 4] 34 | - Plugin Version: [e.g. 2.0.1.0 or a Git tag] 35 | 36 | **Additional context** 37 | Add any other context about the problem here. Was the plugin built from source? 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Question 3 | url: https://github.com/9p4/jellyfin-plugin-sso/discussions 4 | about: Please ask and answer questions here. 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_call: 3 | inputs: 4 | dotnet-version: 5 | required: false 6 | default: "8.0.x" 7 | description: "The .NET version to setup for the build" 8 | type: string 9 | dotnet-target: 10 | required: false 11 | default: "net8.0" 12 | description: "The .NET target to set for JPRM" 13 | type: string 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout Repository 20 | uses: actions/checkout@v2 21 | 22 | - name: Setup .NET 23 | uses: actions/setup-dotnet@v1 24 | with: 25 | dotnet-version: "${{ inputs.dotnet-version }}" 26 | 27 | - name: Build Jellyfin Plugin 28 | uses: oddstr13/jellyfin-plugin-repository-manager@9497a0a499416cc572ed2e07a391d9f943a37b4d # v1.1.1 29 | id: jprm 30 | with: 31 | dotnet-target: "${{ inputs.dotnet-target }}" 32 | 33 | - name: Upload Artifact 34 | uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # tag=v3 35 | with: 36 | name: build-artifact 37 | retention-days: 30 38 | if-no-files-found: error 39 | path: ${{ steps.jprm.outputs.artifact }} 40 | -------------------------------------------------------------------------------- /.github/workflows/dotnet.yml: -------------------------------------------------------------------------------- 1 | name: .NET 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Setup .NET 17 | uses: actions/setup-dotnet@v1 18 | with: 19 | dotnet-version: 8.0.x 20 | - name: Restore dependencies 21 | run: dotnet restore 22 | - name: Build 23 | run: dotnet build --no-restore --warnaserror 24 | - name: Test 25 | run: dotnet test --no-build --verbosity normal 26 | -------------------------------------------------------------------------------- /.github/workflows/prettier.yml: -------------------------------------------------------------------------------- 1 | name: Prettier Lint 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | 10 | jobs: 11 | prettier: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | with: 17 | # Make sure the actual branch is checked out when running on pull requests 18 | ref: ${{ github.head_ref }} 19 | - name: Prettify code 20 | uses: creyD/prettier_action@v4.3 21 | with: 22 | dry: True 23 | prettier_options: '--check **/*.{js,html,md,css,scss}' 24 | -------------------------------------------------------------------------------- /.github/workflows/publish-nightly.yml: -------------------------------------------------------------------------------- 1 | name: Publish Nightly 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Setup .NET 16 | uses: actions/setup-dotnet@v1 17 | with: 18 | dotnet-version: 8.0.x 19 | - name: Restore dependencies 20 | run: dotnet restore 21 | - name: Build Dotnet 22 | run: dotnet build --no-restore --warnaserror 23 | - name: "Flag as nightly in build.yaml" 24 | uses: fjogeleit/yaml-update-action@v0.10.0 25 | with: 26 | valueFile: 'build.yaml' 27 | propertyPath: 'version' 28 | value: "0.0.0.9000" 29 | commitChange: false 30 | updateFile: true 31 | - name: "JPRM: Build" 32 | id: jrpm 33 | uses: oddstr13/jellyfin-plugin-repository-manager@9497a0a499416cc572ed2e07a391d9f943a37b4d # v1.1.1 34 | with: 35 | version: "0.0.0.9000" 36 | verbosity: debug 37 | path: . 38 | dotnet-target: "net8.0" 39 | output: _dist 40 | - name: Prepare GitHub Release assets 41 | run: |- 42 | pushd _dist 43 | for file in ./*.zip; do 44 | md5sum ${file#./} >> ${file%.*}.md5 45 | sha256sum ${file#./} >> ${file%.*}.sha256 46 | done 47 | ls -l 48 | popd 49 | - name: Publish output artifacts 50 | id: publish-assets 51 | uses: softprops/action-gh-release@50195ba7f6f93d1ac97ba8332a178e008ad176aa 52 | with: 53 | prerelease: false 54 | fail_on_unmatched_files: true 55 | tag_name: nightly 56 | files: | 57 | _dist/* 58 | build.yaml 59 | body: | 60 | Nightly build 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | - name: Publish Plugin Manifest 64 | uses: Kevinjil/jellyfin-plugin-repo-action@a7832ecc44c6b1a45d531970f6647b8682b005b8 65 | with: 66 | ignorePrereleases: true 67 | githubToken: ${{ secrets.GITHUB_TOKEN }} 68 | repository: ${{ github.repository }} 69 | pagesBranch: manifest-release 70 | pagesFile: manifest.json 71 | 72 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | 3 | on: 4 | release: 5 | types: 6 | - released 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | uses: ./.github/workflows/build.yml 12 | with: 13 | dotnet-version: "8.0.*" 14 | dotnet-target: "net8.0" 15 | upload: 16 | runs-on: ubuntu-latest 17 | needs: 18 | - build 19 | steps: 20 | - name: Download Artifact 21 | uses: actions/download-artifact@v2.1.0 22 | with: 23 | name: build-artifact 24 | - name: Prepare GitHub Release assets 25 | run: |- 26 | for file in ./*; do 27 | md5sum ${file#./} >> ${file%.*}.md5 28 | sha256sum ${file#./} >> ${file%.*}.sha256 29 | done 30 | ls -l 31 | - name: Publish output artifacts 32 | id: publish-assets 33 | uses: softprops/action-gh-release@50195ba7f6f93d1ac97ba8332a178e008ad176aa 34 | with: 35 | prerelease: false 36 | fail_on_unmatched_files: true 37 | tag_name: ${{ github.event.release.tag_name }} 38 | files: ./* 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | generate: 42 | runs-on: ubuntu-latest 43 | needs: 44 | - upload 45 | steps: 46 | - name: Publish Plugin Manifest 47 | uses: Kevinjil/jellyfin-plugin-repo-action@a7832ecc44c6b1a45d531970f6647b8682b005b8 48 | with: 49 | ignorePrereleases: true 50 | githubToken: ${{ secrets.GITHUB_TOKEN }} 51 | repository: ${{ github.repository }} 52 | pagesBranch: manifest-release 53 | pagesFile: manifest.json 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # Tye 66 | .tye/ 67 | 68 | # ASP.NET Scaffolding 69 | ScaffoldingReadMe.txt 70 | 71 | # StyleCop 72 | StyleCopReport.xml 73 | 74 | # Files built by Visual Studio 75 | *_i.c 76 | *_p.c 77 | *_h.h 78 | *.ilk 79 | *.meta 80 | *.obj 81 | *.iobj 82 | *.pch 83 | *.pdb 84 | *.ipdb 85 | *.pgc 86 | *.pgd 87 | *.rsp 88 | *.sbr 89 | *.tlb 90 | *.tli 91 | *.tlh 92 | *.tmp 93 | *.tmp_proj 94 | *_wpftmp.csproj 95 | *.log 96 | *.vspscc 97 | *.vssscc 98 | .builds 99 | *.pidb 100 | *.svclog 101 | *.scc 102 | 103 | # Chutzpah Test files 104 | _Chutzpah* 105 | 106 | # Visual C++ cache files 107 | ipch/ 108 | *.aps 109 | *.ncb 110 | *.opendb 111 | *.opensdf 112 | *.sdf 113 | *.cachefile 114 | *.VC.db 115 | *.VC.VC.opendb 116 | 117 | # Visual Studio profiler 118 | *.psess 119 | *.vsp 120 | *.vspx 121 | *.sap 122 | 123 | # Visual Studio Trace Files 124 | *.e2e 125 | 126 | # TFS 2012 Local Workspace 127 | $tf/ 128 | 129 | # Guidance Automation Toolkit 130 | *.gpState 131 | 132 | # ReSharper is a .NET coding add-in 133 | _ReSharper*/ 134 | *.[Rr]e[Ss]harper 135 | *.DotSettings.user 136 | 137 | # TeamCity is a build add-in 138 | _TeamCity* 139 | 140 | # DotCover is a Code Coverage Tool 141 | *.dotCover 142 | 143 | # AxoCover is a Code Coverage Tool 144 | .axoCover/* 145 | !.axoCover/settings.json 146 | 147 | # Coverlet is a free, cross platform Code Coverage Tool 148 | coverage*.json 149 | coverage*.xml 150 | coverage*.info 151 | 152 | # Visual Studio code coverage results 153 | *.coverage 154 | *.coveragexml 155 | 156 | # NCrunch 157 | _NCrunch_* 158 | .*crunch*.local.xml 159 | nCrunchTemp_* 160 | 161 | # MightyMoose 162 | *.mm.* 163 | AutoTest.Net/ 164 | 165 | # Web workbench (sass) 166 | .sass-cache/ 167 | 168 | # Installshield output folder 169 | [Ee]xpress/ 170 | 171 | # DocProject is a documentation generator add-in 172 | DocProject/buildhelp/ 173 | DocProject/Help/*.HxT 174 | DocProject/Help/*.HxC 175 | DocProject/Help/*.hhc 176 | DocProject/Help/*.hhk 177 | DocProject/Help/*.hhp 178 | DocProject/Help/Html2 179 | DocProject/Help/html 180 | 181 | # Click-Once directory 182 | publish/ 183 | 184 | # Publish Web Output 185 | *.[Pp]ublish.xml 186 | *.azurePubxml 187 | # Note: Comment the next line if you want to checkin your web deploy settings, 188 | # but database connection strings (with potential passwords) will be unencrypted 189 | *.pubxml 190 | *.publishproj 191 | 192 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 193 | # checkin your Azure Web App publish settings, but sensitive information contained 194 | # in these scripts will be unencrypted 195 | PublishScripts/ 196 | 197 | # NuGet Packages 198 | *.nupkg 199 | # NuGet Symbol Packages 200 | *.snupkg 201 | # The packages folder can be ignored because of Package Restore 202 | **/[Pp]ackages/* 203 | # except build/, which is used as an MSBuild target. 204 | !**/[Pp]ackages/build/ 205 | # Uncomment if necessary however generally it will be regenerated when needed 206 | #!**/[Pp]ackages/repositories.config 207 | # NuGet v3's project.json files produces more ignorable files 208 | *.nuget.props 209 | *.nuget.targets 210 | 211 | # Microsoft Azure Build Output 212 | csx/ 213 | *.build.csdef 214 | 215 | # Microsoft Azure Emulator 216 | ecf/ 217 | rcf/ 218 | 219 | # Windows Store app package directories and files 220 | AppPackages/ 221 | BundleArtifacts/ 222 | Package.StoreAssociation.xml 223 | _pkginfo.txt 224 | *.appx 225 | *.appxbundle 226 | *.appxupload 227 | 228 | # Visual Studio cache files 229 | # files ending in .cache can be ignored 230 | *.[Cc]ache 231 | # but keep track of directories ending in .cache 232 | !?*.[Cc]ache/ 233 | 234 | # Others 235 | ClientBin/ 236 | ~$* 237 | *~ 238 | *.dbmdl 239 | *.dbproj.schemaview 240 | *.jfm 241 | *.pfx 242 | *.publishsettings 243 | orleans.codegen.cs 244 | 245 | # Including strong name files can present a security risk 246 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 247 | #*.snk 248 | 249 | # Since there are multiple workflows, uncomment next line to ignore bower_components 250 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 251 | #bower_components/ 252 | 253 | # RIA/Silverlight projects 254 | Generated_Code/ 255 | 256 | # Backup & report files from converting an old project file 257 | # to a newer Visual Studio version. Backup files are not needed, 258 | # because we have git ;-) 259 | _UpgradeReport_Files/ 260 | Backup*/ 261 | UpgradeLog*.XML 262 | UpgradeLog*.htm 263 | ServiceFabricBackup/ 264 | *.rptproj.bak 265 | 266 | # SQL Server files 267 | *.mdf 268 | *.ldf 269 | *.ndf 270 | 271 | # Business Intelligence projects 272 | *.rdl.data 273 | *.bim.layout 274 | *.bim_*.settings 275 | *.rptproj.rsuser 276 | *- [Bb]ackup.rdl 277 | *- [Bb]ackup ([0-9]).rdl 278 | *- [Bb]ackup ([0-9][0-9]).rdl 279 | 280 | # Microsoft Fakes 281 | FakesAssemblies/ 282 | 283 | # GhostDoc plugin setting file 284 | *.GhostDoc.xml 285 | 286 | # Node.js Tools for Visual Studio 287 | .ntvs_analysis.dat 288 | node_modules/ 289 | 290 | # Visual Studio 6 build log 291 | *.plg 292 | 293 | # Visual Studio 6 workspace options file 294 | *.opt 295 | 296 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 297 | *.vbw 298 | 299 | # Visual Studio LightSwitch build output 300 | **/*.HTMLClient/GeneratedArtifacts 301 | **/*.DesktopClient/GeneratedArtifacts 302 | **/*.DesktopClient/ModelManifest.xml 303 | **/*.Server/GeneratedArtifacts 304 | **/*.Server/ModelManifest.xml 305 | _Pvt_Extensions 306 | 307 | # Paket dependency manager 308 | .paket/paket.exe 309 | paket-files/ 310 | 311 | # FAKE - F# Make 312 | .fake/ 313 | 314 | # CodeRush personal settings 315 | .cr/personal 316 | 317 | # Python Tools for Visual Studio (PTVS) 318 | __pycache__/ 319 | *.pyc 320 | 321 | # Cake - Uncomment if you are using it 322 | # tools/** 323 | # !tools/packages.config 324 | 325 | # Tabs Studio 326 | *.tss 327 | 328 | # Telerik's JustMock configuration file 329 | *.jmconfig 330 | 331 | # BizTalk build output 332 | *.btp.cs 333 | *.btm.cs 334 | *.odx.cs 335 | *.xsd.cs 336 | 337 | # OpenCover UI analysis results 338 | OpenCover/ 339 | 340 | # Azure Stream Analytics local run output 341 | ASALocalRun/ 342 | 343 | # MSBuild Binary and Structured Log 344 | *.binlog 345 | 346 | # NVidia Nsight GPU debugger configuration file 347 | *.nvuser 348 | 349 | # MFractors (Xamarin productivity tool) working folder 350 | .mfractor/ 351 | 352 | # Local History for Visual Studio 353 | .localhistory/ 354 | 355 | # BeatPulse healthcheck temp database 356 | healthchecksdb 357 | 358 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 359 | MigrationBackup/ 360 | 361 | # Ionide (cross platform F# VS Code tools) working folder 362 | .ionide/ 363 | 364 | # Fody - auto-generated XML schema 365 | FodyWeavers.xsd 366 | 367 | ## 368 | ## Visual studio for Mac 369 | ## 370 | 371 | 372 | # globs 373 | Makefile.in 374 | *.userprefs 375 | *.usertasks 376 | config.make 377 | config.status 378 | aclocal.m4 379 | install-sh 380 | autom4te.cache/ 381 | *.tar.gz 382 | tarballs/ 383 | test-results/ 384 | 385 | # Mac bundle stuff 386 | *.dmg 387 | *.app 388 | 389 | # content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore 390 | # General 391 | .DS_Store 392 | .AppleDouble 393 | .LSOverride 394 | 395 | # Icon must end with two \r 396 | Icon 397 | 398 | 399 | # Thumbnails 400 | ._* 401 | 402 | # Files that might appear in the root of a volume 403 | .DocumentRevisions-V100 404 | .fseventsd 405 | .Spotlight-V100 406 | .TemporaryItems 407 | .Trashes 408 | .VolumeIcon.icns 409 | .com.apple.timemachine.donotpresent 410 | 411 | # Directories potentially created on remote AFP share 412 | .AppleDB 413 | .AppleDesktop 414 | Network Trash Folder 415 | Temporary Items 416 | .apdisk 417 | 418 | # content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore 419 | # Windows thumbnail cache files 420 | Thumbs.db 421 | ehthumbs.db 422 | ehthumbs_vista.db 423 | 424 | # Dump file 425 | *.stackdump 426 | 427 | # Folder config file 428 | [Dd]esktop.ini 429 | 430 | # Recycle Bin used on file shares 431 | $RECYCLE.BIN/ 432 | 433 | # Windows Installer files 434 | *.cab 435 | *.msi 436 | *.msix 437 | *.msm 438 | *.msp 439 | 440 | # Windows shortcuts 441 | *.lnk 442 | 443 | # JetBrains Rider 444 | .idea/ 445 | *.sln.iml 446 | 447 | ## 448 | ## Visual Studio Code 449 | ## 450 | .vscode/* 451 | .vscode 452 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.min.js 2 | -------------------------------------------------------------------------------- /CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [contact@ersei.net](mailto:contact@ersei.net). All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Contributing to Jellyfin SSO Plugin 4 | 5 | First off, thanks for taking the time to contribute! ❤️ 6 | 7 | All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 8 | 9 | > And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: 10 | > 11 | > - Star the project 12 | > - Tweet about it 13 | > - Refer this project in your project's readme 14 | > - Mention the project at local meetups and tell your friends/colleagues 15 | 16 | 17 | 18 | ## Table of Contents 19 | 20 | - [I Have a Question](#i-have-a-question) 21 | - [I Want To Contribute](#i-want-to-contribute) 22 | - [Reporting Bugs](#reporting-bugs) 23 | - [Suggesting Enhancements](#suggesting-enhancements) 24 | - [Your First Code Contribution](#your-first-code-contribution) 25 | - [Improving The Documentation](#improving-the-documentation) 26 | - [Styleguides](#styleguides) 27 | - [Commit Messages](#commit-messages) 28 | - [Join The Project Team](#join-the-project-team) 29 | 30 | ## I Have a Question 31 | 32 | > If you want to ask a question, we assume that you have read the available [Documentation](https://github.com/9p4/jellyfin-plugin-sso/blob/main/README.md). 33 | 34 | Before you ask a question, it is best to search for existing [Issues](https://github.com/9p4/jellyfin-plugin-sso/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. 35 | 36 | If you then still feel the need to ask a question and need clarification, we recommend the following: 37 | 38 | - Open an [Issue](https://github.com/9p4/jellyfin-plugin-sso/issues/new). 39 | - Provide as much context as you can about what you're running into. 40 | - Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. 41 | 42 | We will then take care of the issue as soon as possible. 43 | 44 | 58 | 59 | ## I Want To Contribute 60 | 61 | > ### Legal Notice 62 | > 63 | > When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. 64 | 65 | ### Reporting Bugs 66 | 67 | 68 | 69 | #### Before Submitting a Bug Report 70 | 71 | A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. 72 | 73 | - Make sure that you are using the latest version. 74 | - Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://github.com/9p4/jellyfin-plugin-sso/blob/main/README.md). If you are looking for support, you might want to check [this section](#i-have-a-question)). 75 | - To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/9p4/jellyfin-plugin-ssoissues?q=label%3Abug). 76 | - Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue. 77 | - Collect information about the bug: 78 | - Stack trace (Traceback) 79 | - OS, Platform and Version (Windows, Linux, macOS, x86, ARM) 80 | - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant. 81 | - Possibly your input and the output 82 | - Can you reliably reproduce the issue? And can you also reproduce it with older versions? 83 | 84 | 85 | 86 | #### How Do I Submit a Good Bug Report? 87 | 88 | > You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to . 89 | 90 | 91 | 92 | We use GitHub issues to track bugs and errors. If you run into an issue with the project: 93 | 94 | - Open an [Issue](https://github.com/9p4/jellyfin-plugin-sso/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) 95 | - Explain the behavior you would expect and the actual behavior. 96 | - Please provide as much context as possible and describe the _reproduction steps_ that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. 97 | - Provide the information you collected in the previous section. 98 | 99 | Once it's filed: 100 | 101 | - The project team will label the issue accordingly. 102 | - A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced. 103 | - If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution). 104 | 105 | 106 | 107 | ### Suggesting Enhancements 108 | 109 | This section guides you through submitting an enhancement suggestion for Jellyfin SSO Plugin, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. 110 | 111 | 112 | 113 | #### Before Submitting an Enhancement 114 | 115 | - Make sure that you are using the latest version. 116 | - Read the [documentation](https://github.com/9p4/jellyfin-plugin-sso/blob/main/README.md) carefully and find out if the functionality is already covered, maybe by an individual configuration. 117 | - Perform a [search](https://github.com/9p4/jellyfin-plugin-sso/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. 118 | - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library. 119 | 120 | 121 | 122 | #### How Do I Submit a Good Enhancement Suggestion? 123 | 124 | Enhancement suggestions are tracked as [GitHub issues](https://github.com/9p4/jellyfin-plugin-sso/issues). 125 | 126 | - Use a **clear and descriptive title** for the issue to identify the suggestion. 127 | - Provide a **step-by-step description of the suggested enhancement** in as many details as possible. 128 | - **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. 129 | - You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. 130 | - **Explain why this enhancement would be useful** to most Jellyfin SSO Plugin users. You may also want to point out the other projects that solved it better and which could serve as inspiration. 131 | 132 | 133 | 134 | ### Your First Code Contribution 135 | 136 | 140 | 141 | The project is built with .NET 6. Download it from [here](https://dotnet.microsoft.com/en-us/download). 142 | 143 | Any code editor or IDE with .NET support will work out of the box with this program. 144 | 145 | (Some) editors: 146 | 147 | - [VSCode](https://code.visualstudio.com/docs/languages/dotnet) 148 | - [N/Vim](https://github.com/OmniSharp/Omnisharp-vim) 149 | 150 | ### Improving The Documentation 151 | 152 | 156 | 157 | We are always open to better docs! The main place documentation could be improved is the [providers](https://github.com/9p4/jellyfin-plugin-sso/blob/main/providers.md) documentation. This file keeps track of configurations that are known to work with common SSO providers. 158 | 159 | ## Styleguides 160 | 161 | ### Commit Messages 162 | 163 | We use [commitlint](https://commitlint.js.org) for linting commit messages. 164 | 165 | ### C# 166 | 167 | We format all C# code according to the .NET formatter. Run `dotnet build .` and fix any warnings that come up. 168 | 169 | ### HTML/CSS/JS/Markdown 170 | 171 | We use [Prettier](https://prettier.io) to format these files. 172 | 173 | 176 | 177 | 178 | 179 | ## Attribution 180 | 181 | This guide is based on the **contributing-gen**. [Make your own](https://github.com/bttger/contributing-gen)! 182 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Jellyfin SSO Plugin

2 | 3 |

4 | 5 | Logo 6 |
7 |
8 | 9 | GPL 3.0 License 10 | 11 | 12 | GitHub Actions Build Status 13 | 14 | 15 | Current Release 16 | 17 | 18 | Release RSS Feed 19 | 20 | 21 | Main Commits RSS Feed 22 | 23 |

24 | 25 | This plugin allows users to sign in through an SSO provider (such as Google, Microsoft, or your own provider). This enables one-click signin. 26 | 27 | https://user-images.githubusercontent.com/17993169/149681516-f93b43f5-fa5c-4c1f-a909-e5414878a864.mp4 28 | 29 | Existing users may link new SSO accounts, or remove existing links using self-service at `/SSOViews/linking`. 30 | 31 | ## Current State: 32 | 33 | This is 100% alpha software! PRs are welcome to improve the code. 34 | 35 | ~~There is NO admin configuration! You must use the API to configure the program!~~ Added by [strazto](https://github.com/strazto) in PR [#18](https://github.com/9p4/jellyfin-plugin-sso/pull/18) and [#27](https://github.com/9p4/jellyfin-plugin-sso/pull/27). 36 | 37 | **[This is for Jellyfin >=10.8](https://github.com/9p4/jellyfin-plugin-sso/issues/3) and only on the Web UI or clients supporting [Quick Connect](https://jellyfin.org/docs/general/server/quick-connect)** 38 | 39 | **This README reflects the branch it is currently on! Switch tags to view version-specific documentation!** 40 | 41 | ## Tested Providers 42 | 43 | [Find provider specific documentation in providers.md](providers.md) 44 | 45 | - Authelia 46 | - authentik 47 | - Keycloak 48 | - OIDC & SAML 49 | - Google OpenID: Works, but usernames are all numeric 50 | 51 | ## Supported Protocols 52 | 53 | - [OpenID](https://openid.net/what-is-openid/) 54 | - [SAML](https://www.cloudflare.com/learning/access-management/what-is-saml/) 55 | 56 | ## Security 57 | 58 | This is my first time writing C# so please take all of the code written here with a grain of salt. This program should be reasonably secure since it validates all information passed from the client with either a certificate or a secret internal state. 59 | 60 | ## Installing 61 | 62 | 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. 63 | 64 | Then, install the plugin from the plugin catalog! 65 | 66 | See [Contributing](#contributing) for instructions on how to build from source. 67 | 68 | ### (Fallback) Legacy package repo (Versions <= 3.3.0) 69 | 70 | We have transitioned to a release system that automates distribution, packaging & hosting. 71 | This system is new, and if something goes wrong, you can try using the old package repository as a fallback. 72 | 73 | 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. 74 | 75 | ### Installing cutting edge/nightly builds 76 | 77 | 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. 78 | 79 | 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`. 80 | 81 | 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. 82 | 83 | ## Roadmap 84 | 85 | - [x] Admin page 86 | - [ ] Automated tests 87 | - [x] Add role/claims support 88 | - [x] Use canonical usernames instead of preferred usernames 89 | - [x] Add user self-service 90 | - [ ] Finalize RBAC access for all user properties 91 | 92 | ## Examples 93 | 94 | **Note that you should add both "/r/" and "/redirect/" paths to your SSO provider's configuration!** 95 | 96 | ### Creating A Login Button On The Main Page 97 | 98 | In the Jellyfin administration UI, under "General", there is a "Branding" section. In that section, add the following code in the "Login disclaimer" block (replacing `PROVIDER_NAME` and the domain): 99 | 100 | ```html 101 |
102 | 105 |
106 | ``` 107 | 108 | Then, add the following code in the "Custom CSS code" section: 109 | 110 | ```css 111 | a.raised.emby-button { 112 | padding: 0.9em 1em; 113 | color: inherit !important; 114 | } 115 | 116 | .disclaimerContainer { 117 | display: block; 118 | } 119 | ``` 120 | 121 | ![screenshot of the configuration page with the same code](img/custom-button.png) 122 | 123 | For more information, refer to [issue #16](https://github.com/9p4/jellyfin-plugin-sso/issues/16). 124 | 125 | ### SAML 126 | 127 | Example for adding a SAML configuration with the API using [curl](https://curl.se/): 128 | 129 | `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"` 130 | 131 | Make sure that the JSON is the same as the configuration you would like. 132 | 133 | The SAML provider must have the following configuration (I am using Keycloak, and I cannot speak for whatever you will see): 134 | 135 | - Sign Documents on 136 | - Sign Assertions off 137 | - Client Signature Required off 138 | - Redirect URI: [https://myjellyfin.example.com/sso/SAML/post/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/start/PROVIDER_NAME) 139 | - Base URL: [https://myjellyfin.example.com](https://myjellyfin.example.com) 140 | - Master SAML processing URL: [https://myjellyfin.example.com/sso/SAML/start/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/start/PROVIDER_NAME) 141 | 142 | Make sure that `clientid` is replaced with the actual client ID and `PROVIDER_NAME` is replaced with the chosen provider name! 143 | 144 | ### OpenID 145 | 146 | Example for adding an OpenID configuration with the API using [curl](https://curl.se/) 147 | 148 | `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"` 149 | 150 | The OpenID provider must have the following configuration (again, I am using Keycloak) 151 | 152 | - Access Type: Confidential 153 | - Standard Flow Enabled 154 | - Redirect URI: [https://myjellyfin.example.com/sso/OID/redirect/PROVIDER_NAME](https://myjellyfin.example.com/sso/OID/redirect/PROVIDER_NAME) 155 | - Base URL: [https://myjellyfin.example.com](https://myjellyfin.example.com) 156 | 157 | Make sure that `clientid` is replaced with the actual client ID and `PROVIDER_NAME` is replaced with the chosen provider name! 158 | 159 | ## API Endpoints 160 | 161 | The API is all done from a base URL of `/sso/` 162 | 163 | ### SAML 164 | 165 | #### Flow 166 | 167 | - POST `SAML/start/PROVIDER_NAME`: This is the SAML POST endpoint. It accepts a form response from the SAML provider and returns HTML and JavaScript for the client to login with a given provider name. 168 | - GET `SAML/start/PROVIDER_NAME`: This is the SAML initiator: it will begin the authorization flow for SAML with a given provider name. 169 | - 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: 170 | - `deviceId`: string. Device ID. 171 | - `deviceName`: string. Device name. 172 | - `appName`: string. App name. 173 | - `appVersion`: string. App version. 174 | - `data`: string. The signed SAML XML request. Used to verify a request. 175 | 176 | #### Configuration 177 | 178 | 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"` 179 | 180 | - 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: 181 | - `samlEndpoint`: string. The SAML endpoint. 182 | - `samlClientId`: string. The SAML client ID. 183 | - `samlCertificate`: string. The base64 encoded SAML certificate. 184 | - `enabled`: boolean. Determines if the provider is enabled or not. 185 | - `enableAuthorization`: boolean: Determines if the plugin sets permissions for the user. If false, the user will start with no permissions and an administrator will add permissions. If disabled, then the permissions of users will not be modified and the Jellyfin defaults will be used instead. 186 | - `enableAllFolders`: boolean. Determines if the client logging in is allowed access to all folders. 187 | - `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. 188 | - `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. 189 | - `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). 190 | - `enableFolderRoles`: boolean. Determines if role-based folder access should be used. 191 | - `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. 192 | - `enableLiveTvRoles`: boolean. Determines if role-based Live TV access should be used. 193 | - `liveTvRoles`: array of strings. If `enableLiveTvRoles` is enabled, then the user's roles will be checked against these. If the user is granted permission, then the user will be able to view Live TV. 194 | - `liveTvManagementRoles`: array of strings. If `enableLiveTvRoles` is enabled, then the user's roles will be checked against these. If the user is granted permission, then the user will be able to manage Live TV. 195 | - `enableLiveTv`: boolean. Whether to allow Live TV by default. This applies even if `enableLiveTvRoles` is enabled. 196 | - `enableLiveTvManagement`: boolean. Whether to allow Live TV management by default. This applies even if `enableLiveTvRoles` is enabled. 197 | - `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. 198 | - `schemeOverride`: string. Sets the scheme for URLs used. Can be useful if the plugin refuses to use HTTPS URLs. 199 | - GET `SAML/Del/PROVIDER_NAME`: This removes a configuration for SAML for a given provider name. 200 | - GET `SAML/Get`: Lists the configurations currently available. 201 | 202 | ### OpenID 203 | 204 | #### Flow 205 | 206 | - GET `OID/redirect/PROVIDER_NAME`: This is the OpenID callback path. This will return HTML and JavaScript for the client to login with a given provider name. 207 | - GET `OID/start/PROVIDER_NAME`: This is the OpenID initiator: it will begin the authorization flow for OpenID with a given provider name. 208 | - 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: 209 | - `deviceId`: string. Device ID. 210 | - `deviceName`: string. Device name. 211 | - `appName`: string. App name. 212 | - `appVersion`: string. App version. 213 | - `data`: string. The OpenID state. Used to verify a request. 214 | 215 | #### Configuration 216 | 217 | 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"` 218 | 219 | - 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: 220 | - `oidEndpoint`: string. The OpenID endpoint. Must have a `.well-known` path available. 221 | - `oidClientId`: string. The OpenID client ID. 222 | - `oidSecret`: string. The OpenID secret. 223 | - `enabled`: boolean. Determines if the provider is enabled or not. 224 | - `enableAuthorization`: boolean: Determines if the plugin sets permissions for the user. If false, the user will start with no permissions and an administrator will add permissions. If disabled, then the permissions of users will not be modified and the Jellyfin defaults will be used instead. 225 | - `enableAllFolders`: boolean. Determines if the client logging in is allowed access to all folders. 226 | - `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. 227 | - `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). 228 | - `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). 229 | - `enableFolderRoles`: boolean. Determines if role-based folder access should be used. 230 | - `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. 231 | - `enableLiveTvRoles`: boolean. Determines if role-based Live TV access should be used. 232 | - `liveTvRoles`: array of strings. If `enableLiveTvRoles` is enabled, then the user's roles will be checked against these. If the user is granted permission, then the user will be able to view Live TV. 233 | - `liveTvManagementRoles`: array of strings. If `enableLiveTvRoles` is enabled, then the user's roles will be checked against these. If the user is granted permission, then the user will be able to manage Live TV. 234 | - `enableLiveTv`: boolean. Whether to allow Live TV by default. This applies even if `enableLiveTvRoles` is enabled. 235 | - `enableLiveTvManagement`: boolean. Whether to allow Live TV management by default. This applies even if `enableLiveTvRoles` is enabled. 236 | - `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. 237 | - `oidScopes` : array of strings. Each contains an additional scope name to include in the OIDC request. 238 | - 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. 239 | - Leave empty to only request the default scopes. 240 | - `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. 241 | - `defaultUsernameClaim`: string. The provider will use the claim to create the users' usernames. If not set, it fallbacks to `preferred_username`. 242 | - `avatarUrlFormat`: string. The URL format for the users avatars. OIDC claims can be used by using the `@{claim_type}` syntax. If not set, the avatars won't change. 243 | - `disableHttps`: boolean. Determines whether the OpenID discovery endpoint requires HTTPS. 244 | - `doNotValidateEndpoints`: boolean. Determines whether the OpenID discovery process will validate endpoints. This may be required for Google. 245 | - `doNotValidateIssuerName`: boolean. Determines whether the OpenID discovery process will validate the OpenID issuer name. 246 | - `schemeOverride`: string. Sets the scheme for URLs used. Can be useful if the plugin refuses to use HTTPS URLs. 247 | - GET `OID/Del/PROVIDER_NAME`: This removes a configuration for OpenID for a given provider name. 248 | - GET `OID/Get`: Lists the configurations currently available. 249 | - GET `OID/States`: Lists currently active OpenID flows in progress. 250 | 251 | ### Misc 252 | 253 | - 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` 254 | 255 | ## Limitations 256 | 257 | Logging in with an SSO account that has the same username as an existing Jellyfin account will override the permissions for the user. Use caution when overriding the administrator account! 258 | 259 | ~~There is no GUI to sign in. You have to make it yourself! The buttons should redirect to something like this: [https://myjellyfin.example.com/sso/SAML/start/clientid](https://myjellyfin.example.com/sso/SAML/start/clientid) replacing `clientid` with the provider client ID and `SAML` with the auth scheme (either `SAML` or `OID`).~~ 260 | 261 | ~~Furthermore, there is no functional admin page (yet). PRs for this are welcome. In the meantime, you have to interact with the API to add or remove configurations.~~ Added by [strazto](https://github.com/strazto) in PR [#18](https://github.com/9p4/jellyfin-plugin-sso/pull/18) and [#27](https://github.com/9p4/jellyfin-plugin-sso/pull/27). 262 | 263 | There is also no logout callback. Logging out of Jellyfin will log you out of Jellyfin only, instead of the SSO provider as well. 264 | 265 | ~~This only supports Jellyfin on its own domain (for now). This is because I'm using string concatenation for generating some URLs. A PR is welcome to patch this.~~ Fixed in [PR #1](https://github.com/9p4/jellyfin-plugin-sso/pull/1). 266 | 267 | **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). 268 | 269 | # Contributing 270 | 271 | ## Dependencies 272 | 273 | This project uses Nix flakes to manage development dependencies. Run `nix develop` to use the same toolchain versions. 274 | 275 | ## Building 276 | 277 | 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`. 278 | 279 | ### VSCode Workflow 280 | 281 | An example `.vscode` configuration may be found at [strazto/jellyfin-plugin-sso-vscode](https://github.com/strazto/jellyfin-plugin-sso-vscode). 282 | 283 | From the root of this repo, you may clone that to `.vscode` 284 | 285 | ```bash 286 | # From repo root 287 | 288 | git clone https://github.com/strazto/jellyfin-plugin-sso-vscode .vscode 289 | ``` 290 | 291 | ## Releasing 292 | 293 | This plugin uses [JPRM](https://github.com/oddstr13/jellyfin-plugin-repository-manager) to build the plugin. Refer to the documentation there to install JPRM. 294 | 295 | Build the zipped plugin with `jprm --verbosity=debug plugin build .`. 296 | 297 | ### CI Releases 298 | 299 | Anything merged to the main branch will be built and published by our CI system. 300 | 301 | Anything tagged/released as a formal Github release will also be built and published by our CI system. 302 | 303 | If you wish to use releases from your own fork, refer to 304 | [Installing](#installing), however, you will need to change the url to the 305 | manifest file, `https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/manifest-release/manifest.json` 306 | so that it refers to your fork. 307 | 308 | ## Credits and Thanks 309 | 310 | Much thanks to the [Jellyfin LDAP plugin](https://github.com/jellyfin/jellyfin-plugin-ldapauth) for offering a base for me to start on my plugin. 311 | 312 | I use the [AspNet SAML](https://github.com/jitbit/AspNetSaml/) library for the SAML side of things (patched to work with Base64 on non-Windows machines). 313 | 314 | I use the [Duende IdentityModel OIDC Client](https://github.com/DuendeSoftware/foss) library for the OpenID side of things. 315 | 316 | Thanks to these projects, without which I would have been pulling my hair out implementing these protocols from scratch. 317 | 318 | ## Something funny about the origins of this plugin 319 | 320 | 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. 321 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | Please email all security vulnerabilities and issues found to the email "contact at ersei dot net". If using LLMs/AI to find the issues, first verify the issue exists manually. Please do not publicly disclose security vulnerabilities until after a stable release for the fix has been released for 30 days. 2 | 3 | The latest released version is the only supported version. 4 | -------------------------------------------------------------------------------- /SSO-Auth.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO-Auth", "SSO-Auth\SSO-Auth.csproj", "{C30A5CFB-B27E-4E83-9E96-1E0362B36748}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(SolutionProperties) = preSolution 14 | HideSolutionNode = FALSE 15 | EndGlobalSection 16 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 17 | {C30A5CFB-B27E-4E83-9E96-1E0362B36748}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {C30A5CFB-B27E-4E83-9E96-1E0362B36748}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {C30A5CFB-B27E-4E83-9E96-1E0362B36748}.Release|Any CPU.ActiveCfg = Release|Any CPU 20 | {C30A5CFB-B27E-4E83-9E96-1E0362B36748}.Release|Any CPU.Build.0 = Release|Any CPU 21 | EndGlobalSection 22 | EndGlobal 23 | -------------------------------------------------------------------------------- /SSO-Auth/Api/RequestHelpers.cs: -------------------------------------------------------------------------------- 1 | // The following code is a derivative work of the code from the Jellyfin project, 2 | // which is licensed GPLv2. This code therefore is also licensed under the terms 3 | // of the GNU Public License, verison 2. 4 | // https://github.com/jellyfin/jellyfin/blob/a60cb280a3d31ba19ffb3a94cf83ef300a7473b7/Jellyfin.Api/Helpers/RequestHelpers.cs#L63-L77 5 | 6 | // Use of this relatively small snippet complies with fair use 7 | // See https://www.gnu.org/licenses/gpl-faq.en.html#SourceCodeInDocumentation 8 | // These helpers were not published within a Nuget package, so it was neccessary to re-implement. 9 | 10 | using System; 11 | using System.Threading.Tasks; 12 | using Jellyfin.Data.Enums; 13 | using MediaBrowser.Controller.Net; 14 | using Microsoft.AspNetCore.Http; 15 | 16 | namespace Jellyfin.Plugin.SSO_Auth.Helpers; 17 | 18 | /// 19 | /// Request Extensions. 20 | /// 21 | public static class RequestHelpers 22 | { 23 | /// 24 | /// Checks if the user can update an entry. 25 | /// 26 | /// Instance of the interface. 27 | /// The . 28 | /// The user id. 29 | /// Whether to restrict the user preferences. 30 | /// A whether the user can update the entry. 31 | internal static async Task AssertCanUpdateUser(IAuthorizationContext authContext, HttpRequest requestContext, Guid userId, bool restrictUserPreferences) 32 | { 33 | var auth = await authContext.GetAuthorizationInfo(requestContext).ConfigureAwait(false); 34 | 35 | var authenticatedUser = auth.User; 36 | 37 | // If they're going to update the record of another user, they must be an administrator 38 | if ((!userId.Equals(auth.UserId) && !authenticatedUser.HasPermission(PermissionKind.IsAdministrator)) 39 | || (restrictUserPreferences && !authenticatedUser.EnableUserPreferenceAccess)) 40 | { 41 | return false; 42 | } 43 | 44 | return true; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /SSO-Auth/Config/PluginConfiguration.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Xml.Serialization; 4 | 5 | namespace Jellyfin.Plugin.SSO_Auth.Config; 6 | 7 | /// 8 | /// Plugin Configuration. 9 | /// 10 | public class PluginConfiguration : MediaBrowser.Model.Plugins.BasePluginConfiguration 11 | { 12 | /// 13 | /// Initializes a new instance of the class. 14 | /// 15 | public PluginConfiguration() 16 | { 17 | SamlConfigs = new SerializableDictionary(); 18 | OidConfigs = new SerializableDictionary(); 19 | } 20 | 21 | /// 22 | /// Gets or sets the SAML configurations available. 23 | /// 24 | [XmlElement("SamlConfigs")] 25 | public SerializableDictionary SamlConfigs { get; set; } 26 | 27 | /// 28 | /// Gets or sets the OpenID configurations available. 29 | /// 30 | [XmlElement("OidConfigs")] 31 | public SerializableDictionary OidConfigs { get; set; } 32 | } 33 | 34 | /// 35 | /// The configuration required for a SAML flow. 36 | /// 37 | [XmlRoot("PluginConfiguration")] 38 | public class SamlConfig 39 | { 40 | private SerializableDictionary _canonicalLinks; 41 | 42 | /// 43 | /// Gets or sets the SAML information endpoint. 44 | /// 45 | public string SamlEndpoint { get; set; } 46 | 47 | /// 48 | /// Gets or sets the SAML provider's client ID. 49 | /// 50 | public string SamlClientId { get; set; } 51 | 52 | /// 53 | /// Gets or sets the SAML public key. 54 | /// 55 | public string SamlCertificate { get; set; } 56 | 57 | /// 58 | /// Gets or sets a value indicating whether the provider is enabled. 59 | /// 60 | public bool Enabled { get; set; } 61 | 62 | /// 63 | /// Gets or sets a value indicating whether RBAC is enabled. 64 | /// 65 | public bool EnableAuthorization { get; set; } 66 | 67 | /// 68 | /// Gets or sets a value indicating whether all folders are allowed by default. 69 | /// 70 | public bool EnableAllFolders { get; set; } 71 | 72 | /// 73 | /// Gets or sets what folders should users have access to by default. 74 | /// 75 | public string[] EnabledFolders { get; set; } 76 | 77 | /// 78 | /// Gets or sets the roles that are checked to determine whether the user is an administrator. 79 | /// 80 | public string[] AdminRoles { get; set; } 81 | 82 | /// 83 | /// Gets or sets what roles are checked to determine whether the user is allowed to use Jellyfin. 84 | /// 85 | public string[] Roles { get; set; } 86 | 87 | /// 88 | /// Gets or sets a value indicating whether RBAC is used to manage folder access. 89 | /// 90 | public bool EnableFolderRoles { get; set; } 91 | 92 | /// 93 | /// Gets or sets a value indicating whether RBAC is used to manage Live TV access. 94 | /// 95 | public bool EnableLiveTvRoles { get; set; } 96 | 97 | /// 98 | /// Gets or sets a value indicating whether Live TV is enabled by default. 99 | /// 100 | public bool EnableLiveTv { get; set; } 101 | 102 | /// 103 | /// Gets or sets a value indicating whether Live TV is allowed to be managed by default. 104 | /// 105 | public bool EnableLiveTvManagement { get; set; } 106 | 107 | /// 108 | /// Gets or sets the roles that are checked to determine whether the user is allowed to view Live TV. 109 | /// 110 | public string[] LiveTvRoles { get; set; } 111 | 112 | /// 113 | /// Gets or sets the roles that are checked to determine whether the user is allowed to manage Live TV. 114 | /// 115 | public string[] LiveTvManagementRoles { get; set; } 116 | 117 | /// 118 | /// Gets or sets which folders map to what roles in RBAC. 119 | /// 120 | [XmlArray("FolderRoleMappings")] 121 | [XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMappings")] 122 | public List FolderRoleMapping { get; set; } 123 | 124 | /// 125 | /// Gets or sets the default provider the user after logging in with SSO. 126 | /// 127 | public string DefaultProvider { get; set; } 128 | 129 | /// 130 | /// Gets or sets the redirect scheme override. 131 | /// 132 | public string SchemeOverride { get; set; } 133 | 134 | /// 135 | /// Gets or sets the redirect port override. 136 | /// 137 | public int? PortOverride { get; set; } 138 | 139 | /// 140 | /// Gets or sets a value indicating whether the new, more descriptive paths are to be used. 141 | /// 142 | public bool NewPath { get; set; } 143 | 144 | /// 145 | /// Gets or sets a mapping of canonical names from the provider to jellyfin user ids. 146 | /// 147 | [XmlElement("CanonicalLinks")] 148 | public SerializableDictionary CanonicalLinks 149 | { 150 | get 151 | { 152 | if (_canonicalLinks == null) 153 | { 154 | return new SerializableDictionary(); 155 | } 156 | 157 | return _canonicalLinks; 158 | } 159 | set => _canonicalLinks = value; 160 | } 161 | } 162 | 163 | /// 164 | /// The configuration required for a OpenID flow. 165 | /// 166 | [XmlRoot("PluginConfiguration")] 167 | public class OidConfig 168 | { 169 | private SerializableDictionary _canonicalLinks; 170 | 171 | /// 172 | /// Gets or sets the OpenID well-known information endpoint. 173 | /// 174 | public string OidEndpoint { get; set; } 175 | 176 | /// 177 | /// Gets or sets OpenID client ID. 178 | /// 179 | public string OidClientId { get; set; } 180 | 181 | /// 182 | /// Gets or sets OpenID shared secret. 183 | /// 184 | public string OidSecret { get; set; } 185 | 186 | /// 187 | /// Gets or sets a value indicating whether the provider is enabled. 188 | /// 189 | public bool Enabled { get; set; } 190 | 191 | /// 192 | /// Gets or sets a value indicating whether RBAC is enabled. 193 | /// 194 | public bool EnableAuthorization { get; set; } 195 | 196 | /// 197 | /// Gets or sets a value indicating whether all folders are allowed by default. 198 | /// 199 | public bool EnableAllFolders { get; set; } 200 | 201 | /// 202 | /// Gets or sets what folders should users have access to by default. 203 | /// 204 | public string[] EnabledFolders { get; set; } 205 | 206 | /// 207 | /// Gets or sets the roles that are checked to determine whether the user is an administrator. 208 | /// 209 | public string[] AdminRoles { get; set; } 210 | 211 | /// 212 | /// Gets or sets what roles are checked to determine whether the user is allowed to use Jellyfin. 213 | /// 214 | public string[] Roles { get; set; } 215 | 216 | /// 217 | /// Gets or sets a value indicating whether RBAC is used to manage folder access. 218 | /// 219 | public bool EnableFolderRoles { get; set; } 220 | 221 | /// 222 | /// Gets or sets a value indicating whether RBAC is used to manage Live TV access. 223 | /// 224 | public bool EnableLiveTvRoles { get; set; } 225 | 226 | /// 227 | /// Gets or sets a value indicating whether Live TV is enabled by default. 228 | /// 229 | public bool EnableLiveTv { get; set; } 230 | 231 | /// 232 | /// Gets or sets a value indicating whether Live TV is allowed to be managed by default. 233 | /// 234 | public bool EnableLiveTvManagement { get; set; } 235 | 236 | /// 237 | /// Gets or sets the roles that are checked to determine whether the user is allowed to view Live TV. 238 | /// 239 | public string[] LiveTvRoles { get; set; } 240 | 241 | /// 242 | /// Gets or sets the roles that are checked to determine whether the user is allowed to manage Live TV. 243 | /// 244 | public string[] LiveTvManagementRoles { get; set; } 245 | 246 | /// 247 | /// Gets or sets which folders map to what roles in RBAC. 248 | /// 249 | [XmlArray("FolderRoleMappings")] 250 | [XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMappings")] 251 | public List FolderRoleMapping { get; set; } 252 | 253 | /// 254 | /// Gets or sets the claim to check roles against. Separated by "."s. 255 | /// 256 | public string RoleClaim { get; set; } 257 | 258 | /// 259 | /// Gets or Sets additional Scopes to request access to in the authorization request. 260 | /// 261 | public string[] OidScopes { get; set; } 262 | 263 | /// 264 | /// Gets or sets the default provider the user after logging in with SSO. 265 | /// 266 | public string DefaultProvider { get; set; } 267 | 268 | /// 269 | /// Gets or sets the redirect scheme override. 270 | /// 271 | public string SchemeOverride { get; set; } 272 | 273 | /// 274 | /// Gets or sets the redirect port override. 275 | /// 276 | public int? PortOverride { get; set; } 277 | 278 | /// 279 | /// Gets or sets a value indicating whether the new, more descriptive paths are to be used. 280 | /// 281 | public bool NewPath { get; set; } 282 | 283 | /// 284 | /// Gets or sets a mapping of canonical names from the provider to jellyfin user ids. 285 | /// 286 | [XmlElement("CanonicalLinks")] 287 | public SerializableDictionary CanonicalLinks 288 | { 289 | get 290 | { 291 | if (_canonicalLinks == null) 292 | { 293 | return new SerializableDictionary(); 294 | } 295 | 296 | return _canonicalLinks; 297 | } 298 | set => _canonicalLinks = value; 299 | } 300 | 301 | /// 302 | /// Gets or sets the default username claim when creating new accounts. 303 | /// 304 | public string DefaultUsernameClaim { get; set; } 305 | 306 | /// 307 | /// Gets or sets the URL format of the new user avatar. 308 | /// 309 | public string AvatarUrlFormat { get; set; } 310 | 311 | /// 312 | /// Gets or sets a value indicating whether HTTPS in the discovery endpoint is required. 313 | /// 314 | public bool DisableHttps { get; set; } 315 | 316 | /// 317 | /// Gets or sets a value indicating whether pushed authorization is required. 318 | /// 319 | public bool DisablePushedAuthorization { get; set; } 320 | 321 | /// 322 | /// Gets or sets a value indicating whether the OpenID endpoints are validated. 323 | /// 324 | public bool DoNotValidateEndpoints { get; set; } 325 | 326 | /// 327 | /// Gets or sets a value indicating whether the OpenID issuer name is validated. 328 | /// 329 | public bool DoNotValidateIssuerName { get; set; } 330 | } 331 | 332 | /// 333 | /// The OpenID client ID. 334 | /// 335 | public class FolderRoleMap 336 | { 337 | /// 338 | /// Gets or sets the role of the mapping. 339 | /// 340 | public string Role { get; set; } 341 | 342 | /// 343 | /// Gets or sets the folders that are allowed from the given role. 344 | /// 345 | public List Folders { get; set; } 346 | } 347 | -------------------------------------------------------------------------------- /SSO-Auth/Config/config.js: -------------------------------------------------------------------------------- 1 | const ssoConfigurationPage = { 2 | pluginUniqueId: "505ce9d1-d916-42fa-86ca-673ef241d7df", 3 | loadConfiguration: (page) => { 4 | ApiClient.getPluginConfiguration(ssoConfigurationPage.pluginUniqueId).then( 5 | (config) => { 6 | ssoConfigurationPage.populateProviders(page, config.OidConfigs); 7 | }, 8 | ); 9 | 10 | const folder_container = page.querySelector("#EnabledFolders"); 11 | ssoConfigurationPage.populateFolders(folder_container); 12 | }, 13 | populateProviders: (page, providers) => { 14 | // Clear providers in case there are out of date ones 15 | page 16 | .querySelector("#selectProvider") 17 | .querySelectorAll("option") 18 | .forEach((option) => { 19 | option.remove(); 20 | }); 21 | 22 | // Add providers as options for the selector 23 | 24 | Object.keys(providers).forEach((provider_name) => { 25 | var choice = new Option(provider_name, provider_name); 26 | 27 | page.querySelector("#selectProvider").appendChild(choice); 28 | }); 29 | }, 30 | populateEnabledFolders: (folder_list, container) => { 31 | container.querySelectorAll(".folder-checkbox").forEach((e) => { 32 | e.checked = folder_list.includes(e.getAttribute("data-id")); 33 | }); 34 | }, 35 | serializeEnabledFolders: (container) => { 36 | return [...container.querySelectorAll(".folder-checkbox")] 37 | .filter((e) => e.checked) 38 | .map((e) => { 39 | return e.getAttribute("data-id"); 40 | }); 41 | }, 42 | populateFolders: (container) => { 43 | return ApiClient.getJSON( 44 | ApiClient.getUrl("Library/MediaFolders", { 45 | IsHidden: false, 46 | }), 47 | ).then((folders) => { 48 | ssoConfigurationPage._populateFolders(container, folders); 49 | }); 50 | }, 51 | /* 52 | container: html element 53 | folders.Items: array of objects, with .Id & .Name 54 | */ 55 | _populateFolders: (container, folders) => { 56 | container 57 | .querySelectorAll(".emby-checkbox-label") 58 | .forEach((e) => e.remove()); 59 | 60 | const checkboxes = folders.Items.map((folder) => { 61 | var out = document.createElement("label"); 62 | 63 | out.innerHTML = ` 64 | 70 | ${folder.Name} 71 | `; 72 | 73 | return out; 74 | }); 75 | 76 | checkboxes.forEach((e) => { 77 | container.appendChild(e); 78 | }); 79 | }, 80 | 81 | populateRoleMappings: (folder_role_mappings, container) => { 82 | container 83 | .querySelectorAll(".sso-role-mapping-container") 84 | .forEach((e) => e.remove()); 85 | 86 | const mapping_elements = folder_role_mappings.map((mapping) => { 87 | var elem = document.createElement("div"); 88 | 89 | elem.classList.add("sso-role-mapping-container"); 90 | elem.innerHTML = ` 91 | 94 |
95 | 101 | 108 |
109 |
112 | `; 113 | 114 | var checklist = elem.querySelector(".sso-folder-list"); 115 | const enabled_folders = mapping["Folders"]; 116 | 117 | ssoConfigurationPage 118 | .populateFolders(checklist) 119 | .then(() => 120 | ssoConfigurationPage.populateEnabledFolders( 121 | enabled_folders, 122 | checklist, 123 | ), 124 | ); 125 | 126 | elem.querySelector(".sso-role-mapping-name").value = mapping["Role"]; 127 | elem 128 | .querySelector(".sso-remove-role-mapping") 129 | .addEventListener( 130 | "click", 131 | ssoConfigurationPage.handleRoleMappingRemove, 132 | ); 133 | 134 | return elem; 135 | }); 136 | 137 | mapping_elements.forEach((e) => container.appendChild(e)); 138 | }, 139 | serializeRoleMappings: (container) => { 140 | var out = []; 141 | const roles = [ 142 | ...container.querySelectorAll(".sso-role-mapping-container"), 143 | ].forEach((elem) => { 144 | const role = elem.querySelector(".sso-role-mapping-name").value; 145 | const checklist = elem.querySelector(".sso-folder-list"); 146 | 147 | out.push({ 148 | Role: role, 149 | Folders: ssoConfigurationPage.serializeEnabledFolders(checklist), 150 | }); 151 | }); 152 | 153 | return out; 154 | }, 155 | handleRoleMappingRemove: (evt) => { 156 | const targeted_mapping = evt.target.closest(".sso-role-mapping-container"); 157 | targeted_mapping.remove(); 158 | }, 159 | listArgumentsByType: (page) => { 160 | const json_class = ".sso-json"; 161 | const toggle_class = ".sso-toggle"; 162 | const text_class = ".sso-text"; 163 | const text_list_class = ".sso-line-list"; 164 | 165 | const folder_list_fields = ["EnabledFolders"]; 166 | const role_map_fields = ["FolderRoleMapping"]; 167 | 168 | const oidc_form = page.querySelector("#sso-new-oidc-provider"); 169 | 170 | const text_fields = [...oidc_form.querySelectorAll(text_class)].map( 171 | (e) => e.id, 172 | ); 173 | 174 | const json_fields = [...oidc_form.querySelectorAll(json_class)].map( 175 | (e) => e.id, 176 | ); 177 | 178 | const text_list_fields = [ 179 | ...oidc_form.querySelectorAll(text_list_class), 180 | ].map((e) => e.id); 181 | 182 | const check_fields = [...oidc_form.querySelectorAll(toggle_class)].map( 183 | (e) => e.id, 184 | ); 185 | 186 | const output = { 187 | json_fields, 188 | text_list_fields, 189 | text_fields, 190 | check_fields, 191 | folder_list_fields, 192 | role_map_fields, 193 | }; 194 | 195 | return output; 196 | }, 197 | fillTextList: (text_list, element) => { 198 | // text_list is an array of strings 199 | // element is an input element 200 | const val = text_list.join("\r\n"); 201 | element.value = val; 202 | }, 203 | parseTextList: (element) => { 204 | // Return the parsed text list 205 | var out = element.value 206 | .split("\n") 207 | .map((e) => e.trim()) 208 | .filter((e) => e); 209 | return out; 210 | }, 211 | loadProvider: (page, provider_name) => { 212 | ApiClient.getPluginConfiguration(ssoConfigurationPage.pluginUniqueId).then( 213 | (config) => { 214 | var provider = config.OidConfigs[provider_name] || {}; 215 | 216 | const form_elements = ssoConfigurationPage.listArgumentsByType(page); 217 | 218 | page.querySelector("#OidProviderName").value = provider_name; 219 | 220 | form_elements.text_fields.forEach((id) => { 221 | if (provider[id]) page.querySelector("#" + id).value = provider[id]; 222 | }); 223 | 224 | form_elements.json_fields.forEach((id) => { 225 | if (provider[id]) 226 | page.querySelector("#" + id).value = JSON.stringify(provider[id]); 227 | }); 228 | 229 | form_elements.text_list_fields.forEach((id) => { 230 | if (provider[id]) 231 | ssoConfigurationPage.fillTextList( 232 | provider[id], 233 | page.querySelector("#" + id), 234 | ); 235 | }); 236 | 237 | form_elements.folder_list_fields.forEach((id) => { 238 | if (provider[id]) { 239 | ssoConfigurationPage.populateEnabledFolders( 240 | provider[id], 241 | page.querySelector(`#${id}`), 242 | ); 243 | } 244 | }); 245 | 246 | form_elements.check_fields.forEach((id) => { 247 | if (provider[id]) page.querySelector("#" + id).checked = provider[id]; 248 | }); 249 | 250 | form_elements.role_map_fields.forEach((id) => { 251 | const elem = page.querySelector(`#${id}`); 252 | if (provider[id]) 253 | ssoConfigurationPage.populateRoleMappings(provider[id], elem); 254 | }); 255 | }, 256 | ); 257 | }, 258 | deleteProvider: (page, provider_name) => { 259 | if ( 260 | !window.confirm( 261 | `Are you sure you want to delete the provider ${provider_name}?`, 262 | ) 263 | ) { 264 | return; 265 | } 266 | return new Promise((resolve) => { 267 | ApiClient.getPluginConfiguration( 268 | ssoConfigurationPage.pluginUniqueId, 269 | ).then((config) => { 270 | if (!config.OidConfigs.hasOwnProperty(provider_name)) { 271 | resolve(); 272 | return; 273 | } 274 | 275 | delete config.OidConfigs[provider_name]; 276 | ApiClient.updatePluginConfiguration( 277 | ssoConfigurationPage.pluginUniqueId, 278 | config, 279 | ).then(function (result) { 280 | Dashboard.processPluginConfigurationUpdateResult(result); 281 | ssoConfigurationPage.loadConfiguration(page); 282 | 283 | Dashboard.alert("Provider removed"); 284 | 285 | resolve(); 286 | }); 287 | }); 288 | }); 289 | }, 290 | saveProvider: (page, provider_name) => { 291 | return new Promise((resolve) => { 292 | const form_elements = ssoConfigurationPage.listArgumentsByType(page); 293 | 294 | ApiClient.getPluginConfiguration( 295 | ssoConfigurationPage.pluginUniqueId, 296 | ).then((config) => { 297 | var current_config = {}; 298 | if (config.OidConfigs.hasOwnProperty(provider_name)) { 299 | current_config = config.OidConfigs[provider_name]; 300 | } 301 | 302 | form_elements.text_fields.forEach((id) => { 303 | const value = page.querySelector("#" + id).value; 304 | if (value) { 305 | current_config[id] = page.querySelector("#" + id).value; 306 | } else { 307 | current_config[id] = null; 308 | } 309 | }); 310 | 311 | form_elements.json_fields.forEach((id) => { 312 | const value = page.querySelector("#" + id).value; 313 | if (value) { 314 | current_config[id] = JSON.parse(value); 315 | } else { 316 | current_config[id] = null; 317 | } 318 | }); 319 | 320 | form_elements.check_fields.forEach((id) => { 321 | current_config[id] = page.querySelector("#" + id).checked; 322 | }); 323 | 324 | form_elements.text_list_fields.forEach((id) => { 325 | current_config[id] = ssoConfigurationPage.parseTextList( 326 | page.querySelector("#" + id), 327 | ); 328 | }); 329 | 330 | form_elements.folder_list_fields.forEach((id) => { 331 | const elem = page.querySelector(`#${id}`); 332 | current_config[id] = 333 | ssoConfigurationPage.serializeEnabledFolders(elem); 334 | }); 335 | 336 | form_elements.role_map_fields.forEach((id) => { 337 | const elem = page.querySelector(`#${id}`); 338 | current_config[id] = ssoConfigurationPage.serializeRoleMappings(elem); 339 | }); 340 | 341 | config.OidConfigs[provider_name] = current_config; 342 | 343 | ApiClient.updatePluginConfiguration( 344 | ssoConfigurationPage.pluginUniqueId, 345 | config, 346 | ).then(function (result) { 347 | Dashboard.processPluginConfigurationUpdateResult(result); 348 | ssoConfigurationPage.loadConfiguration(page); 349 | ssoConfigurationPage.loadProvider(page, provider_name); 350 | 351 | page.querySelector("#selectProvider").value = provider_name; 352 | Dashboard.alert("Settings saved."); 353 | resolve(); 354 | }); 355 | }); 356 | }); 357 | }, 358 | addTextAreaStyle: (view) => { 359 | var style = document.createElement("link"); 360 | style.rel = "stylesheet"; 361 | style.href = 362 | ApiClient.getUrl("web/configurationpage") + "?name=SSO-Auth.css"; 363 | view.appendChild(style); 364 | }, 365 | }; 366 | 367 | export default function (view) { 368 | ssoConfigurationPage.addTextAreaStyle(view); 369 | ssoConfigurationPage.loadConfiguration(view); 370 | 371 | ssoConfigurationPage.listArgumentsByType(view); 372 | 373 | view.querySelector("#SaveProvider").addEventListener("click", (e) => { 374 | const target_provider = view.querySelector("#OidProviderName").value; 375 | 376 | ssoConfigurationPage.saveProvider(view, target_provider); 377 | 378 | e.preventDefault(); 379 | return false; 380 | }); 381 | 382 | view.querySelector("#LoadProvider").addEventListener("click", (e) => { 383 | const target_provider = view.querySelector("#selectProvider").value; 384 | 385 | ssoConfigurationPage.loadProvider(view, target_provider); 386 | 387 | e.preventDefault(); 388 | return false; 389 | }); 390 | 391 | view.querySelector("#DeleteProvider").addEventListener("click", (e) => { 392 | const target_provider = view.querySelector("#selectProvider").value; 393 | 394 | ssoConfigurationPage.deleteProvider(view, target_provider); 395 | 396 | e.preventDefault(); 397 | return false; 398 | }); 399 | 400 | view.querySelector("#AddRoleMapping").addEventListener("click", (e) => { 401 | const container = view.querySelector("#FolderRoleMapping"); 402 | const current_mappings = 403 | ssoConfigurationPage.serializeRoleMappings(container); 404 | current_mappings.push({ Role: "", Folders: [] }); 405 | console.log(current_mappings); 406 | ssoConfigurationPage.populateRoleMappings(current_mappings, container); 407 | }); 408 | 409 | view.querySelector("#sso-self-service-link").href = 410 | ApiClient.getUrl("/SSOViews/linking"); 411 | } 412 | -------------------------------------------------------------------------------- /SSO-Auth/Config/configPage.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | SSO 5 | 6 | 7 |
13 |
14 |
15 |
16 |

SSO Settings:

17 | ${Help} 24 |
25 |

26 | Note: 27 | Making changes to this configuration requires a restart of Jellyfin. 28 |
29 | This plug-in is in early development, not all configuration options 30 | have been implented in the UI, for example, SAML provider 31 | configuration has not been implemented. 32 |
33 | See the 34 | help page 40 | and 41 | roadmap 46 | 47 | for more information. 48 |
49 | To allow users to manage their own SSO accounts, including linking 50 | SSO providers, and removing existing links, they need to visit 51 | the self service page .
57 | You can use 58 | custom menu links 63 | 64 | to accomplish this. 65 |

66 | 67 |
68 |
73 |
74 |
75 | 78 | 84 |
85 |
0
86 | 90 |
91 |
92 | 93 | 101 | 102 | 110 |
111 |
112 |
113 | 114 |
115 |
121 |
122 |
123 | 128 | 135 |
136 | The name used by Jellyfin to identify the OpenID provider. 137 |
138 | If an OpenID provider with a matching name does not exist, a 139 | new provider with this name will be created. 140 |
141 | If an OpenID provider with a matching name already exists, 142 | the settings for that provider will be updated. 143 |
144 |
145 |
146 | 151 | 158 |
159 | The OpenID endpoint. Must have a .well-known path available. 160 |
161 |
162 | 163 |
164 | 169 | 176 |
177 | The OpenID client ID, for this media server instance. This 178 | is configured on the OIDC provider to uniquely identify 179 | this Jellyfin instance. 180 |
181 |
182 |
183 | 186 | 193 |
194 | The OpenID client secret. Randomly generated & shared. 195 |
196 |
197 | 198 |
201 | 211 |
212 | 213 |
216 | 226 |
227 | Determines if the plugin sets permissions for the user. 228 |
229 | If false, the user will start with no permissions and an 230 | administrator will add permissions. 231 |
232 | The permissions of existing users will not be rewritten on 233 | subsequent logins. 234 |
235 |
236 | 237 |
240 | 250 |
251 | If enabled, all libraries will be accessible to any user 252 | that logs in through this provider. 253 |
254 |
255 |
256 | 261 |
265 |
266 | Determines which libraries will be accessible to a user that 267 | logs in through this provider. 268 |
269 | If "Enable All Folders" is checked, then 270 | this has no effect. 271 |
272 |
273 | 274 |
275 | 278 | 284 |
285 | A list of roles, one role per-line to look for in the OpenID 286 | response. 287 |
288 | If a user has any of these roles, then the user is 289 | authenticated. This validates the OpenID response against 290 | the claim set in "RoleClaim". 291 |
292 | Leave blank to disable role checking. 293 |
294 |
295 | 296 |
297 | 300 | 306 |
307 | A list of roles, one role per-line to look for in the OpenID 308 | response. 309 |
310 | Like "Roles", but having any of the roles 311 | confers admin privilege. 312 |
313 | If unset will not grant admin privileges. 314 |
315 |
316 | 317 |
320 | 330 |
331 | Determines if user roles should be used to control library 332 | access. 333 |
334 |
335 | 336 |
337 | 342 | 351 |
352 |
353 | Map roles (given by "Role Claim") to lists 354 | of libraries. If a user has a given role, they will have 355 | access to the corresponding libraries. If 356 | "Enable Role-Based Folder Access" is 357 | disabled, has no effect. 358 |
359 |
360 | 361 |
364 | 374 |
375 | Determines whether the roles will be used to grant Live TV 376 | privileges. 377 |
378 |
379 | 380 |
381 | 386 | 392 |
393 | A list of roles, one role per-line to look for in the OpenID 394 | response. 395 |
396 | Like "Roles", but having any of the roles 397 | confers Live TV privileges. 398 |
399 |
400 | 401 |
402 | 407 | 413 |
414 | A list of roles, one role per-line to look for in the OpenID 415 | response. 416 |
417 | Like "Roles", but having any of the roles 418 | confers Live TV administration privileges. 419 |
420 |
421 | 422 |
425 | 435 |
436 | Determines whether the user can view Live TV by default. 437 |
438 | This value is still used if Live TV RBAC is 439 | enabled! 440 |
441 |
442 | 443 |
446 | 456 |
457 | Determines whether the user can manage Live TV by default. 458 |
459 | This value is still used if Live TV RBAC is 460 | enabled! 461 |
462 |
463 | 464 |
465 | 468 | 475 |
476 | This is the value in the OpenID response to check for roles. 477 | The first element is the claim type, the subsequent values 478 | are to parse the JSON of the claim value. Use a 479 | "\." to denote a literal ".". This expects a 480 | list of strings from the OIDC server. 481 |
482 | For Keycloak, it is realm_access.roles by 483 | default. 484 |
485 | For Authelia, it is groups 486 |
487 |
488 | 489 |
490 | 493 | 500 |
501 | Specify additional scopes to include in the OIDC request. 502 |
503 | One scope per line, each line should contain a scope name to 504 | include in the OIDC request. 505 |
506 | For some OIDC providers (For example, 507 | authelia), additional scopes may be required in order to validate 513 | group membership in role claim. 514 |
515 | Leave blank to only request the default scopes. 516 |
517 |
518 | 519 |
520 | 525 | 531 |
532 | The set provider then gets assigned to the user after they 533 | have logged in. If it is not set, nothing is changed. With 534 | this, a user can login with SSO but is still able to log in 535 | via other providers later.
A common option is 536 | Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider 539 | for the default provider. 540 |
541 |
542 | 543 |
544 | 549 | 555 |
556 | The default username claim to use from OpenID by default. If 557 | it is not set, it defaults to 558 | preferred_username. 559 |
560 |
561 | 562 |
563 | 568 | 574 |
575 | The url of the avatar with sso variable format: example : 576 | https://example.com/@{user_id}.png 577 |
578 |
579 | 580 |
581 | 591 |
592 |
593 | 594 |
595 | 605 |
606 |
607 | 608 |
611 | 621 |
622 | May be required for Google OpenID 623 |
624 |
625 |
626 | 636 |
637 | 638 |
639 | 642 | 648 |
649 | If the plugin is redirecting to an insecure URL, set this to 650 | "https" 651 |
652 |
653 | 654 |
655 | 658 | 664 |
665 | If the plugin is redirecting to an incorrect port, set this 666 | to the appropiate port 667 |
668 |
669 | 670 | 678 |
679 |
680 |
681 |
682 |
683 |
684 | 685 | 686 | -------------------------------------------------------------------------------- /SSO-Auth/Config/linking.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 23 | SSO Linking 24 | 25 | 26 |
32 |
33 |
34 |
35 | 39 |

SSO Linking:

40 | ${Help} 47 |
48 |

49 | Use the 50 | 53 | button to create a new link for the given provider. 54 |
55 | You may remove existing links by selecting them and pressing the 56 | "Delete" button. 57 |

58 |

59 | See the 60 | help page 66 | and 67 | roadmap 72 | 73 | for more information. 74 |

75 | 79 |

Link a provider

80 |
81 |

SAML

82 |
83 |

OID

84 |
85 |
86 | 95 |
96 |
97 |
98 | 99 | 100 | -------------------------------------------------------------------------------- /SSO-Auth/Config/linking.js: -------------------------------------------------------------------------------- 1 | const ssoConfigLinking = { 2 | pluginUniqueId: "505ce9d1-d916-42fa-86ca-673ef241d7df", 3 | loadProviders: (view) => { 4 | const provider_list_id = "sso-provider-list"; 5 | const provider_list_saml_id = `${provider_list_id}-saml`; 6 | const provider_list_oid_id = `${provider_list_id}-oid`; 7 | 8 | const provider_list_saml = view.querySelector(`#${provider_list_saml_id}`); 9 | const provider_list_oid = view.querySelector(`#${provider_list_oid_id}`); 10 | provider_list_saml.innerHTML = ""; 11 | provider_list_oid.innerHTML = ""; 12 | 13 | fetch(new Request(ApiClient.getUrl("sso/OID/GetNames"))).then((resp) => { 14 | resp.json().then((config_names) => { 15 | ssoConfigLinking.loadProviderList( 16 | provider_list_oid, 17 | config_names, 18 | "oid", 19 | ); 20 | }); 21 | }); 22 | fetch(new Request(ApiClient.getUrl("sso/SAML/GetNames"))).then((resp) => { 23 | resp.json().then((config_names) => { 24 | ssoConfigLinking.loadProviderList( 25 | provider_list_saml, 26 | config_names, 27 | "saml", 28 | ); 29 | }); 30 | }); 31 | }, 32 | loadProviderList: (container, providers, provider_mode) => { 33 | providers.forEach((provider_name) => { 34 | var provider_config = document.createElement("div"); 35 | provider_config.classList.add("sso-provider-links-container"); 36 | provider_config.setAttribute("data-id", provider_name); 37 | 38 | provider_config.innerHTML = ` 39 | 43 | 46 | 47 | 48 | 52 | `; 53 | var add_provider = provider_config.querySelector( 54 | ".sso-provider-add-link", 55 | ); 56 | 57 | //const provider_name_css = ssoConfigLinking.safeCSSId(provider_name); 58 | //provider_link.id = "sso-provider-" + provider_name_css; 59 | //provider_link.classList.add("sso-provider-" + provider_name_css); 60 | add_provider.classList.add("sso-provider"); 61 | 62 | add_provider.href = ApiClient.getUrl( 63 | `/SSO/${provider_mode}/p/${provider_name}?isLinking=true`, 64 | ); 65 | 66 | container.appendChild(provider_config); 67 | }); 68 | 69 | const currentUserId = ApiClient.getCurrentUserId(); 70 | 71 | if (currentUserId) { 72 | ApiClient.fetch( 73 | { 74 | type: "GET", 75 | url: ApiClient.getUrl(`sso/${provider_mode}/links/${currentUserId}`), 76 | }, 77 | true, 78 | ).then((resp) => { 79 | resp.json().then((provider_map) => { 80 | console.log({ provider_map, currentUserId }); 81 | 82 | Object.keys(provider_map).forEach((provider_name) => { 83 | const provider_container = container.querySelector( 84 | `.sso-provider-existing-links-container[data-provider="${provider_name}"]`, 85 | ); 86 | ssoConfigLinking.populateExistingLinks( 87 | provider_container, 88 | provider_mode, 89 | provider_name, 90 | provider_map[provider_name], 91 | ); 92 | }); 93 | }); 94 | }); 95 | } 96 | }, 97 | 98 | populateExistingLinks: ( 99 | container, 100 | provider_mode, 101 | provider_name, 102 | canonical_names, 103 | ) => { 104 | container 105 | .querySelectorAll(".sso-provider-link-checkbox-wrapper") 106 | .forEach((e) => e.remove()); 107 | 108 | const checkboxes = canonical_names.map((canonical_name) => { 109 | var out = document.createElement("label"); 110 | out.classList.add("sso-provider-link-checkbox-wrapper"); 111 | out.classList.add("checkbox-wrapper"); 112 | out.innerHTML = ` 113 | 121 | ${canonical_name} 122 | `; 123 | return out; 124 | }); 125 | 126 | checkboxes.forEach((e) => { 127 | container.appendChild(e); 128 | }); 129 | }, 130 | 131 | handleDeleteButtonPressed: (evt, view) => { 132 | if (evt.target.disabled) return; 133 | 134 | const currentUserId = ApiClient.getCurrentUserId(); 135 | if (!currentUserId) return; 136 | 137 | const delete_requests = [...view.querySelectorAll(".sso-link-checkbox")] 138 | .filter((checkbox_link) => { 139 | const canonical_name = checkbox_link.getAttribute("data-id"); 140 | const provider_name = checkbox_link.getAttribute("data-provider"); 141 | const provider_mode = checkbox_link.getAttribute("data-mode"); 142 | 143 | if (![canonical_name, provider_name, provider_mode].every((e) => e)) { 144 | return false; 145 | } 146 | 147 | if (!checkbox_link.checked) { 148 | return false; 149 | } 150 | 151 | return true; 152 | }) 153 | .map((checked_link) => { 154 | const canonical_name = checked_link.getAttribute("data-id"); 155 | const provider_name = checked_link.getAttribute("data-provider"); 156 | const provider_mode = checked_link.getAttribute("data-mode"); 157 | 158 | return ApiClient.fetch({ 159 | type: "DELETE", 160 | url: ApiClient.getUrl( 161 | `sso/${provider_mode}/link/${provider_name}/${currentUserId}/${canonical_name}`, 162 | ), 163 | }); 164 | }); 165 | 166 | Promise.all(delete_requests).then((values) => { 167 | console.log({ message: "Delete requests handled", values }); 168 | window.location.reload(); 169 | }); 170 | }, 171 | }; 172 | 173 | export default function (view) { 174 | ssoConfigLinking.loadProviders(view); 175 | 176 | view.querySelector("#enable-delete").addEventListener("change", (e) => { 177 | view.querySelector("#btn-delete-selected-links").disabled = 178 | !e.target.checked; 179 | }); 180 | 181 | view 182 | .querySelector("#btn-delete-selected-links") 183 | .addEventListener("click", (e) => 184 | ssoConfigLinking.handleDeleteButtonPressed(e, view), 185 | ); 186 | } 187 | -------------------------------------------------------------------------------- /SSO-Auth/Config/style.css: -------------------------------------------------------------------------------- 1 | .emby-textarea { 2 | display: block; 3 | margin: 0; 4 | margin-bottom: 0 !important; 5 | 6 | /* Remove select styling */ 7 | 8 | /* Font size must the 16px or larger to prevent iOS page zoom on focus */ 9 | font-size: inherit; 10 | 11 | /* General select styles: change as needed */ 12 | font-family: inherit; 13 | font-weight: inherit; 14 | color: inherit; 15 | padding: 0.35em 0.25em; 16 | 17 | /* Prevent padding from causing width overflow */ 18 | box-sizing: border-box; 19 | outline: none !important; 20 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 21 | width: 100%; 22 | } 23 | 24 | .emby-textarea::-moz-focus-inner { 25 | border: 0; 26 | } 27 | 28 | .textareaLabel { 29 | display: inline-block; 30 | transition: all 0.2s ease-out; 31 | margin-bottom: 0.25em; 32 | } 33 | 34 | .emby-textarea + .fieldDescription { 35 | margin-top: 0.25em; 36 | } 37 | 38 | .sso-role-mapping-container, 39 | .sso-bordered-list { 40 | /* 41 | border-color: #101010; 42 | border-color: #383838; 43 | */ 44 | border-color: rgba(255, 255, 255, 0.135); 45 | 46 | padding-top: 0.5em; 47 | border-style: solid; 48 | margin-top: 0.25em; 49 | } 50 | .sso-role-mapping-container + .sso-role-mapping-container, 51 | .sso-bordered-list + .sso-bordered-list { 52 | margin-top: 1em; 53 | } 54 | 55 | .sso-role-mapping-container .sso-folder-list { 56 | padding-left: 1em; 57 | padding-bottom: 0.25em; 58 | } 59 | 60 | .sso-role-mapping-input-label { 61 | padding-left: 0.5em; 62 | } 63 | -------------------------------------------------------------------------------- /SSO-Auth/Lib/Lib.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | SSO_Auth 6 | true 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /SSO-Auth/Lib/Library.fs: -------------------------------------------------------------------------------- 1 | namespace SSO_Auth.Lib 2 | 3 | module Say = 4 | let hello name = 5 | printfn "Hello %s" name 6 | -------------------------------------------------------------------------------- /SSO-Auth/SSO-Auth.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | Jellyfin.Plugin.SSO_Auth 6 | 3.5.2.4 7 | 3.5.3.0 8 | true 9 | false 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | ../jellyfin.ruleset 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /SSO-Auth/SSOPlugin.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Jellyfin.Plugin.SSO_Auth.Config; 4 | using MediaBrowser.Common.Configuration; 5 | using MediaBrowser.Common.Plugins; 6 | using MediaBrowser.Model.Plugins; 7 | using MediaBrowser.Model.Serialization; 8 | 9 | namespace Jellyfin.Plugin.SSO_Auth; 10 | 11 | /// 12 | /// The SSO plugin class. 13 | /// 14 | public class SSOPlugin : BasePlugin, IPlugin, IHasWebPages 15 | { 16 | /// 17 | /// Initializes a new instance of the class. 18 | /// 19 | /// Internal Jellyfin interface for the ApplicationPath. 20 | /// Internal Jellyfin interface for the XML information. 21 | public SSOPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) 22 | : base(applicationPaths, xmlSerializer) 23 | { 24 | Instance = this; 25 | } 26 | 27 | /// 28 | /// Gets the instance of the SSO plugin. 29 | /// 30 | public static SSOPlugin Instance { get; private set; } 31 | 32 | /// 33 | /// Gets the name of the SSO plugin. 34 | /// 35 | public override string Name => "SSO-Auth"; 36 | 37 | /// 38 | /// Gets the GUID of the SSO plugin. 39 | /// 40 | public override Guid Id => Guid.Parse("505ce9d1-d916-42fa-86ca-673ef241d7df"); 41 | 42 | /// 43 | /// Returns the available internal web pages of this plugin. 44 | /// 45 | /// A list of internal webpages in this application. 46 | public IEnumerable GetPages() 47 | { 48 | return new[] 49 | { 50 | new PluginPageInfo 51 | { 52 | Name = Name, 53 | EmbeddedResourcePath = $"{GetType().Namespace}.Config.configPage.html" 54 | }, 55 | new PluginPageInfo 56 | { 57 | Name = Name + ".js", 58 | EmbeddedResourcePath = $"{GetType().Namespace}.Config.config.js" 59 | }, 60 | new PluginPageInfo 61 | { 62 | Name = Name + ".css", 63 | EmbeddedResourcePath = $"{GetType().Namespace}.Config.style.css" 64 | }, 65 | new PluginPageInfo 66 | { 67 | Name = Name + "-linking", 68 | EmbeddedResourcePath = $"{GetType().Namespace}.Config.linking.html" 69 | }, 70 | new PluginPageInfo 71 | { 72 | Name = Name + "-linking.js", 73 | EmbeddedResourcePath = $"{GetType().Namespace}.Config.linking.js" 74 | }, 75 | }; 76 | } 77 | 78 | /// 79 | /// Returns the available user views for this plugin. 80 | /// 81 | /// A list of user views for this plugin. 82 | public IEnumerable GetViews() 83 | { 84 | return new[] 85 | { 86 | new PluginPageInfo 87 | { 88 | Name = "style.css", 89 | EmbeddedResourcePath = $"{GetType().Namespace}.Config.style.css" 90 | }, 91 | new PluginPageInfo 92 | { 93 | Name = "linking", 94 | EmbeddedResourcePath = $"{GetType().Namespace}.Config.linking.html" 95 | }, 96 | new PluginPageInfo 97 | { 98 | Name = "linking.js", 99 | EmbeddedResourcePath = $"{GetType().Namespace}.Config.linking.js" 100 | }, 101 | new PluginPageInfo 102 | { 103 | Name = "ApiClient.js", 104 | EmbeddedResourcePath = $"{GetType().Namespace}.Views.apiClient.js" 105 | }, 106 | new PluginPageInfo 107 | { 108 | Name = "emby-restyle.css", 109 | EmbeddedResourcePath = $"{GetType().Namespace}.Views.emby-restyle.css" 110 | }, 111 | new PluginPageInfo 112 | { 113 | Name = "jellyfin-apiClient.esm.min.js", 114 | EmbeddedResourcePath = $"{GetType().Namespace}.Views.jellyfin-apiClient.esm.min.js" 115 | }, 116 | }; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /SSO-Auth/Saml.cs: -------------------------------------------------------------------------------- 1 | /* 2 | Was Jitbit's simple SAML 2.0 component for ASP.NET 3 | https://github.com/jitbit/AspNetSaml/ 4 | (c) Jitbit LP, 2016 5 | Use this freely under the Apache license (see https://choosealicense.com/licenses/apache-2.0/) 6 | version 1.2.3 7 | */ 8 | 9 | using System; 10 | using System.Collections.Generic; 11 | using System.IO; 12 | using System.IO.Compression; 13 | using System.Security.Cryptography.X509Certificates; 14 | using System.Security.Cryptography.Xml; 15 | using System.Text; 16 | using System.Web; 17 | using System.Xml; 18 | 19 | namespace Jellyfin.Plugin.SSO_Auth; 20 | 21 | /// 22 | /// Represents a SAML response. 23 | /// 24 | public class Response 25 | { 26 | private readonly X509Certificate2 _certificate; 27 | private XmlDocument _xmlDoc; 28 | private XmlNamespaceManager _xmlNameSpaceManager; // we need this one to run our XPath queries on the SAML XML 29 | 30 | /// 31 | /// Initializes a new instance of the class. 32 | /// 33 | /// The certificate formatted as a Base64 string. 34 | /// The SAML response formatted as a string. 35 | public Response(string certificateStr, string responseString) 36 | : this(Convert.FromBase64String(certificateStr), responseString) 37 | { 38 | } 39 | 40 | /// 41 | /// Initializes a new instance of the class. 42 | /// 43 | /// The certificate formatted as an array of bytes. 44 | /// The SAML response formatted as a string. 45 | public Response(byte[] certificateBytes, string responseString) : this(certificateBytes) 46 | { 47 | LoadXmlFromBase64(responseString); 48 | } 49 | 50 | /// 51 | /// Initializes a new instance of the class. 52 | /// 53 | /// The certificate formatted as a Base64 string. 54 | public Response(string certificateStr) : this(Convert.FromBase64String(certificateStr)) 55 | { 56 | } 57 | 58 | /// 59 | /// Initializes a new instance of the class. 60 | /// 61 | /// The certificate formatted as an array of bytes. 62 | public Response(byte[] certificateBytes) 63 | { 64 | _certificate = new X509Certificate2(certificateBytes); 65 | } 66 | 67 | /// 68 | /// Gets the SAML response's XML data. 69 | /// 70 | public string Xml => _xmlDoc.OuterXml; 71 | 72 | /// 73 | /// Loads XML from the parameter into the instance's XML data. 74 | /// 75 | /// The XML string to put into the class. 76 | public void LoadXml(string xml) 77 | { 78 | _xmlDoc = new XmlDocument(); 79 | _xmlDoc.PreserveWhitespace = true; 80 | _xmlDoc.XmlResolver = null; 81 | _xmlDoc.LoadXml(xml); 82 | 83 | _xmlNameSpaceManager = GetNamespaceManager(); // lets construct a "manager" for XPath queries 84 | } 85 | 86 | /// 87 | /// Loads Base64 encoded XML from the parameter into the instance's XML data. 88 | /// 89 | /// The Base64 encoded XML string to put into the class. 90 | public void LoadXmlFromBase64(string response) 91 | { 92 | LoadXml(Encoding.UTF8.GetString(Convert.FromBase64String(response))); 93 | } 94 | 95 | /// 96 | /// Checks whether the XML response is valid by verifying the signature. 97 | /// 98 | /// Whether the XML response is valid. 99 | public bool IsValid() 100 | { 101 | var nodeList = _xmlDoc.SelectNodes("//ds:Signature", _xmlNameSpaceManager); 102 | 103 | var signedXml = new SignedXml(_xmlDoc); 104 | 105 | if (nodeList.Count == 0) 106 | { 107 | return false; 108 | } 109 | 110 | signedXml.LoadXml((XmlElement)nodeList[0]); 111 | return ValidateSignatureReference(signedXml) && signedXml.CheckSignature(_certificate, true) && !IsExpired(); 112 | } 113 | 114 | // an XML signature can "cover" not the whole document, but only a part of it 115 | // .NET's built in "CheckSignature" does not cover this case, it will validate to true. 116 | // We should check the signature reference, so it "references" the id of the root document element! If not - it's a hack 117 | private bool ValidateSignatureReference(SignedXml signedXml) 118 | { 119 | if (signedXml.SignedInfo.References.Count != 1) // no ref at all 120 | { 121 | return false; 122 | } 123 | 124 | var reference = (Reference)signedXml.SignedInfo.References[0]; 125 | var id = reference.Uri.Substring(1); 126 | 127 | var idElement = signedXml.GetIdElement(_xmlDoc, id); 128 | 129 | if (idElement == _xmlDoc.DocumentElement) 130 | { 131 | return true; 132 | } 133 | else // sometimes its not the "root" doc-element that is being signed, but the "assertion" element 134 | { 135 | var assertionNode = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion", _xmlNameSpaceManager) as XmlElement; 136 | if (assertionNode != idElement) 137 | { 138 | return false; 139 | } 140 | } 141 | 142 | return true; 143 | } 144 | 145 | private bool IsExpired() 146 | { 147 | var expirationDate = DateTime.MaxValue; 148 | var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:Subject/saml:SubjectConfirmation/saml:SubjectConfirmationData", _xmlNameSpaceManager); 149 | if (node != null && node.Attributes["NotOnOrAfter"] != null) 150 | { 151 | DateTime.TryParse(node.Attributes["NotOnOrAfter"].Value, out expirationDate); 152 | } 153 | 154 | return DateTime.UtcNow > expirationDate.ToUniversalTime(); 155 | } 156 | 157 | /// 158 | /// Gets the name ID attribute from the XML response. 159 | /// 160 | /// The name ID attribute. 161 | public string GetNameID() 162 | { 163 | var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:Subject/saml:NameID", _xmlNameSpaceManager); 164 | return node.InnerText; 165 | } 166 | 167 | /// 168 | /// Gets the UPN attribute from the XML response. 169 | /// 170 | /// The UPN attribute. 171 | public virtual string GetUpn() 172 | { 173 | return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"); 174 | } 175 | 176 | /// 177 | /// Gets the email attribute from the XML response. 178 | /// 179 | /// The email attribute. 180 | public virtual string GetEmail() 181 | { 182 | return GetCustomAttribute("User.email") 183 | // some providers (for example Azure AD) put last name into an attribute named "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" 184 | ?? GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress") 185 | // some providers put last name into an attribute named "mail" 186 | ?? GetCustomAttribute("mail"); 187 | } 188 | 189 | /// 190 | /// Gets the First Name attribute from the XML response. 191 | /// 192 | /// The First Name attribute. 193 | public virtual string GetFirstName() 194 | { 195 | return GetCustomAttribute("first_name") 196 | // some providers (for example Azure AD) put last name into an attribute named "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname" 197 | ?? GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname") 198 | ?? GetCustomAttribute("User.FirstName") 199 | // some providers put last name into an attribute named "givenName" 200 | ?? GetCustomAttribute("givenName"); 201 | } 202 | 203 | /// 204 | /// Gets the Last Name attribute from the XML response. 205 | /// 206 | /// The Last Name attribute. 207 | public virtual string GetLastName() 208 | { 209 | return GetCustomAttribute("last_name") 210 | // some providers (for example Azure AD) put last name into an attribute named "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname" 211 | ?? GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname") 212 | ?? GetCustomAttribute("User.LastName") 213 | // some providers put last name into an attribute named "sn" 214 | ?? GetCustomAttribute("sn"); 215 | } 216 | 217 | /// 218 | /// Gets the department attribute from the XML response. 219 | /// 220 | /// The department attribute. 221 | public virtual string GetDepartment() 222 | { 223 | return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department") 224 | ?? GetCustomAttribute("department"); 225 | } 226 | 227 | /// 228 | /// Gets the phone attribute from the XML response. 229 | /// 230 | /// The phone attribute. 231 | public virtual string GetPhone() 232 | { 233 | return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/homephone") 234 | ?? GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/telephonenumber"); 235 | } 236 | 237 | /// 238 | /// Gets the company attribute from the XML response. 239 | /// 240 | /// The company attribute. 241 | public virtual string GetCompany() 242 | { 243 | return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/companyname") 244 | ?? GetCustomAttribute("organization") 245 | ?? GetCustomAttribute("User.CompanyName"); 246 | } 247 | 248 | /// 249 | /// Gets the location attribute from the XML response. 250 | /// 251 | /// The location attribute. 252 | public virtual string GetLocation() 253 | { 254 | return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/location") 255 | ?? GetCustomAttribute("physicalDeliveryOfficeName"); 256 | } 257 | 258 | /// 259 | /// Gets the first custom attribute from the XML response. 260 | /// 261 | /// The custom attribute to query. 262 | /// The custom attribute. 263 | public string GetCustomAttribute(string attr) 264 | { 265 | var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager); 266 | return node?.InnerText; 267 | } 268 | 269 | /// 270 | /// Gets the values for a custom attribute from the XML response. 271 | /// 272 | /// The custom attribute to query. 273 | /// The custom attributes. 274 | public List GetCustomAttributes(string attr) 275 | { 276 | var node = _xmlDoc.SelectNodes("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager); 277 | List output = new List(); 278 | foreach (XmlNode item in node) 279 | { 280 | output.Add(item?.InnerText); 281 | } 282 | 283 | return output; 284 | } 285 | 286 | // returns namespace manager, we need one b/c MS says so... Otherwise XPath doesnt work in an XML doc with namespaces 287 | // see https://stackoverflow.com/questions/7178111/why-is-xmlnamespacemanager-necessary 288 | private XmlNamespaceManager GetNamespaceManager() 289 | { 290 | var manager = new XmlNamespaceManager(_xmlDoc.NameTable); 291 | manager.AddNamespace("ds", SignedXml.XmlDsigNamespaceUrl); 292 | manager.AddNamespace("saml", "urn:oasis:names:tc:SAML:2.0:assertion"); 293 | manager.AddNamespace("samlp", "urn:oasis:names:tc:SAML:2.0:protocol"); 294 | 295 | return manager; 296 | } 297 | } 298 | 299 | /// 300 | /// Represents a SAML request. 301 | /// 302 | public class AuthRequest 303 | { 304 | private readonly string _id; 305 | private readonly string _issueInstant; 306 | 307 | private readonly string _issuer; 308 | private readonly string _assertionConsumerServiceUrl; 309 | 310 | /// 311 | /// Initializes a new instance of the class.. 312 | /// 313 | /// The issuer of the SAML request. 314 | /// The SAML assertion URL. 315 | public AuthRequest(string issuer, string assertionConsumerServiceUrl) 316 | { 317 | _id = "_" + Guid.NewGuid().ToString(); 318 | _issueInstant = DateTime.Now.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture); 319 | 320 | _issuer = issuer; 321 | _assertionConsumerServiceUrl = assertionConsumerServiceUrl; 322 | } 323 | 324 | /// 325 | /// The formatting of the AuthRequest. 326 | /// 327 | public enum AuthRequestFormat 328 | { 329 | /// 330 | /// Base64 request. 331 | /// 332 | Base64 = 1 333 | } 334 | 335 | /// 336 | /// Gets the SAML request. 337 | /// 338 | /// The format the request should be returned in. 339 | /// The request as a string, either Base64 or not, depending on the format parameter. 340 | public string GetRequest(AuthRequestFormat format) 341 | { 342 | using var sw = new StringWriter(); 343 | var xws = new XmlWriterSettings(); 344 | xws.OmitXmlDeclaration = true; 345 | 346 | using (var xw = XmlWriter.Create(sw, xws)) 347 | { 348 | xw.WriteStartElement("samlp", "AuthnRequest", "urn:oasis:names:tc:SAML:2.0:protocol"); 349 | xw.WriteAttributeString("ID", _id); 350 | xw.WriteAttributeString("Version", "2.0"); 351 | xw.WriteAttributeString("IssueInstant", _issueInstant); 352 | xw.WriteAttributeString("ProtocolBinding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"); 353 | xw.WriteAttributeString("AssertionConsumerServiceURL", _assertionConsumerServiceUrl); 354 | 355 | xw.WriteStartElement("saml", "Issuer", "urn:oasis:names:tc:SAML:2.0:assertion"); 356 | xw.WriteString(_issuer); 357 | xw.WriteEndElement(); 358 | 359 | xw.WriteStartElement("samlp", "NameIDPolicy", "urn:oasis:names:tc:SAML:2.0:protocol"); 360 | xw.WriteAttributeString("Format", "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"); 361 | xw.WriteAttributeString("AllowCreate", "true"); 362 | xw.WriteEndElement(); 363 | 364 | /* 365 | xw.WriteStartElement("samlp", "RequestedAuthnContext", "urn:oasis:names:tc:SAML:2.0:protocol"); 366 | xw.WriteAttributeString("Comparison", "exact"); 367 | xw.WriteStartElement("saml", "AuthnContextClassRef", "urn:oasis:names:tc:SAML:2.0:assertion"); 368 | xw.WriteString("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"); 369 | xw.WriteEndElement(); 370 | xw.WriteEndElement(); 371 | */ 372 | 373 | xw.WriteEndElement(); 374 | } 375 | 376 | if (format == AuthRequestFormat.Base64) 377 | { 378 | // byte[] toEncodeAsBytes = System.Text.ASCIIEncoding.ASCII.GetBytes(sw.ToString()); 379 | // return System.Convert.ToBase64String(toEncodeAsBytes); 380 | 381 | // https://stackoverflow.com/questions/25120025/acs75005-the-request-is-not-a-valid-saml2-protocol-message-is-showing-always%3C/a%3E 382 | var memoryStream = new MemoryStream(); 383 | var writer = new StreamWriter(new DeflateStream(memoryStream, CompressionMode.Compress, true), new UTF8Encoding(false)); 384 | writer.Write(sw.ToString()); 385 | writer.Close(); 386 | var result = Convert.ToBase64String(memoryStream.GetBuffer(), 0, (int)memoryStream.Length, Base64FormattingOptions.None); 387 | return result; 388 | } 389 | 390 | return null; 391 | } 392 | 393 | /// 394 | /// Gets the the URL you should redirect your users to (i.e. your SAML-provider login URL with the Base64-ed request in the querystring. 395 | /// 396 | /// The SAML endpoint. 397 | /// The relay state. 398 | /// The redirect url. 399 | public string GetRedirectUrl(string samlEndpoint, string relayState = null) 400 | { 401 | var queryStringSeparator = samlEndpoint.Contains('?') ? "&" : "?"; 402 | 403 | var url = samlEndpoint + queryStringSeparator + "SAMLRequest=" + HttpUtility.UrlEncode(GetRequest(AuthRequestFormat.Base64)); 404 | 405 | if (!string.IsNullOrEmpty(relayState)) 406 | { 407 | url += "&RelayState=" + HttpUtility.UrlEncode(relayState); 408 | } 409 | 410 | return url; 411 | } 412 | } 413 | -------------------------------------------------------------------------------- /SSO-Auth/SerializableDictionary.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Xml.Serialization; 3 | 4 | /// 5 | /// 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. 6 | /// Also see https://weblogs.asp.net/pwelter34/444961 for additional information. 7 | /// 8 | /// Type of the dictionary key. 9 | /// Type of the dictionary value. 10 | [XmlRoot("dictionary")] 11 | public class SerializableDictionary 12 | : Dictionary, IXmlSerializable 13 | { 14 | /// 15 | /// Initializes a new instance of the class. 16 | /// 17 | public SerializableDictionary() 18 | { 19 | // Empty 20 | } 21 | 22 | /// 23 | /// Initializes a new instance of the class. 24 | /// 25 | /// Dictionary to convert from. 26 | public SerializableDictionary(IDictionary dictionary) : base(dictionary) 27 | { 28 | // Empty 29 | } 30 | 31 | /// 32 | /// Initializes a new instance of the class. 33 | /// 34 | /// Dictionary to convert from. 35 | /// Comparer for the dictionary. 36 | public SerializableDictionary(IDictionary dictionary, IEqualityComparer comparer) : base(dictionary, comparer) 37 | { 38 | // Empty 39 | } 40 | 41 | /// 42 | /// Initializes a new instance of the class. 43 | /// 44 | /// Comparer for the dictionary. 45 | public SerializableDictionary(IEqualityComparer comparer) : base(comparer) 46 | { 47 | // Empty 48 | } 49 | 50 | /// 51 | /// Initializes a new instance of the class. 52 | /// 53 | /// Capacity of the dictionary. 54 | public SerializableDictionary(int capacity) : base(capacity) 55 | { 56 | // Empty 57 | } 58 | 59 | /// 60 | /// Initializes a new instance of the class. 61 | /// 62 | /// Capacity of the dictionary. 63 | /// Comparer for the dictionary. 64 | public SerializableDictionary(int capacity, IEqualityComparer comparer) : base(capacity, comparer) 65 | { 66 | // Empty 67 | } 68 | 69 | /// 70 | /// Gets the schema of the XML object. 71 | /// 72 | /// Nothing. 73 | public System.Xml.Schema.XmlSchema GetSchema() 74 | { 75 | return null; 76 | } 77 | 78 | /// 79 | /// Reads XML and changes this object to be an instance of that data. 80 | /// 81 | /// The XML reader to read from. 82 | public void ReadXml(System.Xml.XmlReader reader) 83 | { 84 | XmlSerializer keySerializer = new XmlSerializer(typeof(TKey)); 85 | XmlSerializer valueSerializer = new XmlSerializer(typeof(TValue)); 86 | 87 | bool wasEmpty = reader.IsEmptyElement; 88 | reader.Read(); 89 | 90 | if (wasEmpty) 91 | { 92 | return; 93 | } 94 | 95 | while (reader.NodeType != System.Xml.XmlNodeType.EndElement) 96 | { 97 | reader.ReadStartElement("item"); 98 | 99 | reader.ReadStartElement("key"); 100 | TKey key = (TKey)keySerializer.Deserialize(reader); 101 | reader.ReadEndElement(); 102 | 103 | reader.ReadStartElement("value"); 104 | TValue value = (TValue)valueSerializer.Deserialize(reader); 105 | reader.ReadEndElement(); 106 | 107 | this.Add(key, value); 108 | 109 | reader.ReadEndElement(); 110 | reader.MoveToContent(); 111 | } 112 | 113 | reader.ReadEndElement(); 114 | } 115 | 116 | /// 117 | /// Writes XML to the XML writer from this object. 118 | /// 119 | /// An instance of the XmlWriter class. 120 | public void WriteXml(System.Xml.XmlWriter writer) 121 | { 122 | XmlSerializer keySerializer = new XmlSerializer(typeof(TKey)); 123 | XmlSerializer valueSerializer = new XmlSerializer(typeof(TValue)); 124 | 125 | foreach (TKey key in this.Keys) 126 | { 127 | writer.WriteStartElement("item"); 128 | 129 | writer.WriteStartElement("key"); 130 | keySerializer.Serialize(writer, key); 131 | writer.WriteEndElement(); 132 | 133 | writer.WriteStartElement("value"); 134 | TValue value = this[key]; 135 | valueSerializer.Serialize(writer, value); 136 | writer.WriteEndElement(); 137 | 138 | writer.WriteEndElement(); 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /SSO-Auth/Views/SSOViewsController.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.IO; 3 | using System.Linq; 4 | using MediaBrowser.Controller.Library; 5 | using MediaBrowser.Controller.Net; 6 | using MediaBrowser.Controller.Session; 7 | using MediaBrowser.Model; 8 | using MediaBrowser.Model.Plugins; 9 | using Microsoft.AspNetCore.Mvc; 10 | using Microsoft.AspNetCore.Routing; 11 | using Microsoft.Extensions.Logging; 12 | 13 | namespace Jellyfin.Plugin.SSO_Auth.Views; 14 | 15 | /// 16 | /// The sso views controller. 17 | /// 18 | [ApiController] 19 | [Route("[controller]")] 20 | public class SSOViewsController : ControllerBase 21 | { 22 | private readonly IUserManager _userManager; 23 | private readonly ISessionManager _sessionManager; 24 | private readonly IAuthorizationContext _authContext; 25 | private readonly ILogger _logger; 26 | 27 | /// 28 | /// Initializes a new instance of the class. 29 | /// 30 | /// Instance of the interface. 31 | /// Instance of the interface. 32 | /// Instance of the interface. 33 | /// Instance of the interface. 34 | public SSOViewsController(ILogger logger, ISessionManager sessionManager, IUserManager userManager, IAuthorizationContext authContext) 35 | { 36 | _sessionManager = sessionManager; 37 | _userManager = userManager; 38 | _authContext = authContext; 39 | _logger = logger; 40 | _logger.LogInformation("SSO Views Controller initialized"); 41 | } 42 | 43 | private ActionResult ServeView(string viewName) 44 | { 45 | IEnumerable pages = null; 46 | if (SSOPlugin.Instance == null) 47 | { 48 | return BadRequest("No plugin instance found"); 49 | } 50 | 51 | pages = SSOPlugin.Instance.GetViews(); 52 | 53 | if (pages == null) 54 | { 55 | return NotFound("Pages is null or empty"); 56 | } 57 | 58 | var view = pages.FirstOrDefault(pageInfo => pageInfo.Name == viewName, null); 59 | 60 | if (view == null) 61 | { 62 | return NotFound("No matching view found"); 63 | } 64 | #nullable enable 65 | Stream? stream = SSOPlugin.Instance.GetType().Assembly.GetManifestResourceStream(view.EmbeddedResourcePath); 66 | 67 | if (stream == null) 68 | { 69 | _logger.LogError("Failed to get resource {Resource}", view.EmbeddedResourcePath); 70 | return NotFound(); 71 | } 72 | #nullable disable 73 | return File(stream, MimeTypes.GetMimeType(view.EmbeddedResourcePath)); 74 | } 75 | 76 | /// 77 | /// Gets a html view. 78 | /// 79 | /// The name of the view / asset to fetch. 80 | /// The html view with the specified name. 81 | [HttpGet("{viewName}")] 82 | public ActionResult GetView([FromRoute] string viewName) 83 | { 84 | return ServeView(viewName); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /SSO-Auth/Views/apiClient.js: -------------------------------------------------------------------------------- 1 | import jellyfinApiclient from "./jellyfin-apiClient.esm.min.js"; 2 | window.jellyfinApiclient = jellyfinApiclient; 3 | console.log(jellyfinApiclient); 4 | 5 | // https://github.com/jellyfin/jellyfin-web/blob/9067b0e397cc8b38635d661ce86ddd83194f3202/src/scripts/clientUtils.js#L19-L76 6 | export async function serverAddress({ basePath = "/web" }) { 7 | const apiClient = window.ApiClient; 8 | 9 | if (apiClient) { 10 | return Promise.resolve(apiClient.serverAddress()); 11 | } 12 | 13 | const urls = []; 14 | 15 | const getViewUrl = (basePath) => { 16 | let url; 17 | const index = window.location.href 18 | .toLowerCase() 19 | .lastIndexOf(basePath.toLowerCase()); 20 | 21 | if (index != -1) { 22 | url = window.location.href.substring(0, index); 23 | } else { 24 | // Return nothing, let another method handle it 25 | url = undefined; 26 | } 27 | 28 | return url; 29 | }; 30 | 31 | if (urls.length === 0) { 32 | // Otherwise use computed base URL 33 | let url; 34 | 35 | url = getViewUrl(basePath) ?? getViewUrl("/web") ?? window.location.origin; 36 | 37 | // Don't use bundled app URL (file:) as server URL 38 | if (url.startsWith("file:")) { 39 | return Promise.resolve(); 40 | } 41 | 42 | urls.push(url); 43 | } 44 | 45 | console.debug("URL candidates:", urls); 46 | 47 | const promises = urls.map((url) => { 48 | return fetch(`${url}/System/Info/Public`) 49 | .then((resp) => { 50 | return { 51 | url: url, 52 | response: resp, 53 | }; 54 | }) 55 | .catch(() => { 56 | return Promise.resolve(); 57 | }); 58 | }); 59 | 60 | return Promise.all(promises) 61 | .then((responses) => { 62 | responses = responses.filter((obj) => obj && obj.response.ok); 63 | return Promise.all( 64 | responses.map((obj) => { 65 | return { 66 | url: obj.url, 67 | config: obj.response.json(), 68 | }; 69 | }), 70 | ); 71 | }) 72 | .then((configs) => { 73 | const selection = 74 | configs.find((obj) => !obj.config.StartupWizardCompleted) || configs[0]; 75 | return Promise.resolve(selection?.url); 76 | }) 77 | .catch((error) => { 78 | console.log(error); 79 | return Promise.resolve(); 80 | }); 81 | } 82 | 83 | // TODO: Refactor duplicated code 84 | // ! Duplicated at 85 | // https://github.com/9p4/jellyfin-plugin-sso/blob/38558d762a13422862240af4060bdd1bb1618d57/SSO-Auth/WebResponse.cs#L363-L401 86 | function getDeviceName() { 87 | return "DUMMY"; 88 | } 89 | 90 | function getDeviceId() { 91 | return localStorage.getItem("_deviceId2"); 92 | } 93 | 94 | const sleep = (milliseconds) => { 95 | return new Promise((resolve) => setTimeout(resolve, milliseconds)); 96 | }; 97 | 98 | async function awaitLocalStorage() { 99 | while ( 100 | localStorage.getItem("_deviceId2") == null || 101 | localStorage.getItem("jellyfin_credentials") == null || 102 | JSON.parse(localStorage.getItem("jellyfin_credentials"))["Servers"][0][ 103 | "Id" 104 | ] == null 105 | ) { 106 | // If localStorage isn't initialized yet, try again. 107 | await sleep(100); 108 | } 109 | } 110 | 111 | await awaitLocalStorage(); 112 | 113 | // Fetch credentials 114 | 115 | var credentials = new jellyfinApiclient.Credentials(); 116 | 117 | var server = await serverAddress({ basePath: "/SSOViews" }); 118 | console.log({ server: server }); 119 | var deviceId = getDeviceId(); 120 | var appName = "SSO-Auth"; 121 | var appVersion = "0.0.0.9000"; 122 | var capabilities = {}; 123 | 124 | const current_server = credentials 125 | .credentials() 126 | .Servers.find((e) => e.LocalAddress == server || e.ManualAddress == server); 127 | 128 | var localApiClient = new jellyfinApiclient.ApiClient( 129 | server, 130 | appName, 131 | appVersion, 132 | getDeviceName(), 133 | deviceId, 134 | ); 135 | localApiClient.setAuthenticationInfo( 136 | current_server.AccessToken, 137 | current_server.UserId, 138 | ); 139 | 140 | var connections = new jellyfinApiclient.ConnectionManager( 141 | credentials, 142 | appName, 143 | appVersion, 144 | getDeviceName(), 145 | deviceId, 146 | capabilities, 147 | ); 148 | 149 | connections.addApiClient(localApiClient); 150 | 151 | window.ApiClient = localApiClient; 152 | 153 | export default localApiClient; 154 | -------------------------------------------------------------------------------- /SSO-Auth/Views/emby-restyle.css: -------------------------------------------------------------------------------- 1 | /* Material icons polyfills */ 2 | 3 | .material-icons { 4 | height: 1em; 5 | font-weight: normal; 6 | font-style: normal; 7 | font-size: 24px; 8 | display: inline-block; 9 | line-height: 1; 10 | text-transform: none; 11 | letter-spacing: normal; 12 | word-wrap: normal; 13 | white-space: nowrap; 14 | direction: inherit; 15 | -webkit-font-smoothing: antialiased; 16 | text-rendering: optimizeLegibility; 17 | -moz-osx-font-smoothing: grayscale; 18 | font-feature-settings: "liga"; 19 | } 20 | 21 | .material-icons.home { 22 | content: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjRweCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik0xMCAyMHYtNmg0djZoNXYtOGgzTDEyIDMgMiAxMmgzdjh6Ii8+PC9zdmc+"); 23 | } 24 | 25 | .material-icons.add { 26 | content: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjRweCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik0xOSAxM2gtNnY2aC0ydi02SDV2LTJoNlY1aDJ2Nmg2djJ6Ii8+PC9zdmc+"); 27 | } 28 | 29 | .material-icons.delete { 30 | content: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjRweCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik02IDE5YzAgMS4xLjkgMiAyIDJoOGMxLjEgMCAyLS45IDItMlY3SDZ2MTJ6TTE5IDRoLTMuNWwtMS0xaC01bC0xIDFINXYyaDE0VjR6Ii8+PC9zdmc+"); 31 | } 32 | 33 | /* 34 | theme.css 35 | */ 36 | .button-delete { 37 | background: rgb(247, 0, 0); 38 | color: rgba(255, 255, 255, 0.87); 39 | } 40 | 41 | .button-delete:disabled { 42 | background: rgb(105, 0, 0); 43 | color: rgba(127, 127, 127, 0.87); 44 | cursor: not-allowed; 45 | pointer-events: none; 46 | } 47 | 48 | /* 49 | Emby Button 50 | */ 51 | 52 | .emby-button { 53 | position: relative; 54 | display: inline-flex; 55 | align-items: center; 56 | box-sizing: border-box; 57 | margin: 0.3em; 58 | text-align: center; 59 | font-size: inherit; 60 | font-family: inherit; 61 | color: inherit; 62 | 63 | /* These are getting an outline in opera tv browsers, which run chrome 30 */ 64 | outline: none !important; 65 | outline-width: 0; 66 | -moz-user-select: none; 67 | -ms-user-select: none; 68 | -webkit-user-select: none; 69 | user-select: none; 70 | cursor: pointer; 71 | z-index: 0; 72 | padding: 0.9em 1em; 73 | vertical-align: middle; 74 | border: 0; 75 | border-radius: 0.2em; 76 | font-weight: 600; 77 | 78 | /* Disable webkit tap highlighting */ 79 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 80 | text-decoration: none; 81 | 82 | /* Not crazy about this but it normalizes heights between anchors and buttons */ 83 | line-height: 1.35; 84 | transform-origin: center; 85 | transition: 0.2s; 86 | } 87 | 88 | .emby-button.show-focus:focus { 89 | transform: scale(1.2); 90 | z-index: 1; 91 | } 92 | 93 | .emby-button::-moz-focus-inner { 94 | border: 0; 95 | } 96 | 97 | .button-flat { 98 | background: transparent; 99 | } 100 | 101 | .button-link { 102 | background: transparent; 103 | cursor: pointer; 104 | margin: 0; 105 | padding: 0; 106 | vertical-align: initial; 107 | } 108 | 109 | .button-link:hover { 110 | text-decoration: underline; 111 | } 112 | 113 | .emby-button > .material-icons { 114 | /* For non-fab buttons that have icons */ 115 | font-size: 1.36em; 116 | } 117 | 118 | .button-link > .material-icons { 119 | font-size: 1em; 120 | } 121 | 122 | .fab { 123 | display: inline-flex; 124 | border-radius: 50%; 125 | padding: 0.6em; 126 | box-sizing: border-box; 127 | align-items: center; 128 | justify-content: center; 129 | text-align: center; 130 | } 131 | 132 | .emby-button.block { 133 | display: block; 134 | align-items: center; 135 | justify-content: center; 136 | margin: 0.25em 0; 137 | width: 100%; 138 | } 139 | 140 | .paper-icon-button-light { 141 | position: relative; 142 | display: inline-flex; 143 | align-items: center; 144 | box-sizing: border-box; 145 | margin: 0 0.29em; 146 | background: transparent; 147 | text-align: center; 148 | font-size: inherit; 149 | font-family: inherit; 150 | color: inherit; 151 | -moz-user-select: none; 152 | -ms-user-select: none; 153 | -webkit-user-select: none; 154 | user-select: none; 155 | cursor: pointer; 156 | z-index: 0; 157 | min-width: initial; 158 | min-height: initial; 159 | width: auto; 160 | height: auto; 161 | padding: 0.556em; 162 | vertical-align: middle; 163 | border: 0; 164 | 165 | /* These are getting an outline in opera tv browsers, which run chrome 30 */ 166 | outline: none !important; 167 | overflow: hidden; 168 | border-radius: 50%; 169 | 170 | /* Disable webkit tap highlighting */ 171 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 172 | justify-content: center; 173 | transform-origin: center; 174 | transition: 0.2s; 175 | } 176 | 177 | .paper-icon-button-light.show-focus:focus { 178 | transform: scale(1.3); 179 | z-index: 1; 180 | } 181 | 182 | .paper-icon-button-light::-moz-focus-inner { 183 | border: 0; 184 | } 185 | 186 | .paper-icon-button-light:disabled { 187 | opacity: 0.3; 188 | cursor: default; 189 | } 190 | 191 | .paper-icon-button-light > .material-icons { 192 | font-size: 1.66956521739130434em; 193 | 194 | /* Make sure its on top of the ripple */ 195 | position: relative; 196 | z-index: 1; 197 | vertical-align: middle; 198 | } 199 | 200 | .paper-icon-button-light > div { 201 | max-height: 100%; 202 | transform: scale(1.8); 203 | position: relative; 204 | z-index: 1; 205 | vertical-align: middle; 206 | display: inline; 207 | margin: 0 auto; 208 | } 209 | 210 | .emby-button-foreground { 211 | position: relative; 212 | z-index: 1; 213 | } 214 | 215 | .btnFilterWithBubble { 216 | position: relative; 217 | } 218 | 219 | .filterButtonBubble { 220 | color: #fff; 221 | position: absolute; 222 | top: 0; 223 | right: 0; 224 | width: 1.6em; 225 | height: 1.6em; 226 | z-index: 100000000; 227 | display: flex; 228 | align-items: center; 229 | justify-content: center; 230 | font-size: 82%; 231 | border-radius: 100em; 232 | box-shadow: 233 | 0 4px 5px 0 rgba(0, 0, 0, 0.14), 234 | 0 1px 10px 0 rgba(0, 0, 0, 0.12), 235 | 0 2px 4px -1px rgba(0, 0, 0, 0.2); 236 | background: #03a9f4; 237 | font-weight: bold; 238 | } 239 | 240 | /* fonts.scss */ 241 | 242 | html { 243 | font-family: 244 | "Noto Sans", "Noto Sans HK", "Noto Sans JP", "Noto Sans KR", "Noto Sans SC", 245 | "Noto Sans TC", sans-serif; 246 | text-size-adjust: 100%; 247 | -webkit-font-smoothing: antialiased; 248 | text-rendering: optimizeLegibility; 249 | } 250 | 251 | html[lang|="ja"] { 252 | font-family: 253 | "Noto Sans", "Noto Sans JP", "Noto Sans HK", "Noto Sans KR", "Noto Sans SC", 254 | "Noto Sans TC", sans-serif; 255 | } 256 | 257 | html[lang|="ko"] { 258 | font-family: 259 | "Noto Sans", "Noto Sans KR", "Noto Sans HK", "Noto Sans JP", "Noto Sans SC", 260 | "Noto Sans TC", sans-serif; 261 | } 262 | 263 | html[lang|="zh-CN"] { 264 | font-family: 265 | "Noto Sans", "Noto Sans SC", "Noto Sans HK", "Noto Sans JP", "Noto Sans KR", 266 | "Noto Sans TC", sans-serif; 267 | } 268 | 269 | html[lang|="zh-TW"] { 270 | font-family: 271 | "Noto Sans", "Noto Sans TC", "Noto Sans HK", "Noto Sans JP", "Noto Sans KR", 272 | "Noto Sans SC", sans-serif; 273 | } 274 | 275 | html[lang|="zh-HK"] { 276 | font-family: 277 | "Noto Sans", "Noto Sans HK", "Noto Sans JP", "Noto Sans KR", "Noto Sans SC", 278 | "Noto Sans TC", sans-serif; 279 | } 280 | 281 | .layout-tv { 282 | /* Per WebOS and Tizen guidelines, fonts must be 20px minimum. 283 | This takes the 16px baseline and multiplies it by 1.25 to get 20px. */ 284 | font-size: 125%; 285 | } 286 | 287 | .layout-mobile { 288 | font-size: 90%; 289 | } 290 | 291 | /* site.scss */ 292 | 293 | html { 294 | line-height: 1.35; 295 | } 296 | 297 | body { 298 | overflow-x: hidden; 299 | background-color: transparent !important; 300 | -webkit-font-smoothing: antialiased; 301 | } 302 | 303 | .clipForScreenReader { 304 | clip: rect(1px, 1px, 1px, 1px); 305 | clip-path: inset(50%); 306 | height: 1px; 307 | width: 1px; 308 | margin: -1px; 309 | overflow: hidden; 310 | padding: 0; 311 | position: absolute; 312 | } 313 | 314 | .material-icons { 315 | /* Fix font ligatures on older WebOS versions */ 316 | font-feature-settings: "liga"; 317 | } 318 | 319 | .backgroundContainer { 320 | position: fixed; 321 | top: 0; 322 | left: 0; 323 | right: 0; 324 | bottom: 0; 325 | contain: strict; 326 | } 327 | 328 | .layout-mobile, 329 | .layout-tv { 330 | -webkit-touch-callout: none; 331 | user-select: none; 332 | } 333 | 334 | .mainAnimatedPage { 335 | contain: style size !important; 336 | } 337 | 338 | .pageContainer { 339 | overflow-x: visible !important; 340 | } 341 | 342 | .bodyWithPopupOpen { 343 | overflow-y: hidden !important; 344 | } 345 | 346 | div[data-role="page"] { 347 | outline: 0; 348 | } 349 | 350 | .pageTitle { 351 | margin-top: 0; 352 | font-family: inherit; 353 | } 354 | 355 | .fieldDescription { 356 | padding-left: 0.15em; 357 | font-weight: 400; 358 | white-space: normal !important; 359 | } 360 | 361 | .fieldDescription + .fieldDescription { 362 | margin-top: 0.3em; 363 | } 364 | 365 | .content-primary, 366 | .padded-bottom-page, 367 | .page, 368 | .pageWithAbsoluteTabs .pageTabContent { 369 | /* provides room for the music controls */ 370 | padding-bottom: 5em !important; 371 | } 372 | 373 | .readOnlyContent { 374 | @media all and (min-width: 50em) { 375 | max-width: 54em; 376 | } 377 | } 378 | 379 | form { 380 | @media all and (min-width: 50em) { 381 | max-width: 54em; 382 | } 383 | } 384 | 385 | .headerHelpButton { 386 | margin-left: 1.25em !important; 387 | padding-bottom: 0.4em !important; 388 | padding-top: 0.4em !important; 389 | } 390 | 391 | .mediaInfoContent { 392 | margin-left: auto; 393 | margin-right: auto; 394 | width: 85%; 395 | } 396 | 397 | .headroom { 398 | will-change: transform; 399 | transition: transform 200ms linear; 400 | } 401 | 402 | .drawerContent { 403 | /* make sure the bottom of the drawer is visible when music is playing */ 404 | padding-bottom: 4em; 405 | } 406 | 407 | .force-scroll { 408 | overflow-y: scroll; 409 | } 410 | 411 | .hide-scroll { 412 | overflow-y: hidden; 413 | } 414 | 415 | .w-100 { 416 | width: 100%; 417 | } 418 | 419 | .margin-auto-x { 420 | margin-left: auto; 421 | margin-right: auto; 422 | } 423 | 424 | .margin-auto-y { 425 | margin-top: auto; 426 | margin-bottom: auto; 427 | } 428 | 429 | /* Fix checkboxes */ 430 | /* Customize the label (the container) */ 431 | */ .checkbox-wrapper { 432 | position: relative; 433 | } 434 | 435 | .checkbox-wrapper [type="checkbox"] { 436 | /*display: none;*/ 437 | position: absolute; 438 | top: 0px; 439 | left: 0px; 440 | height: 20px; 441 | width: 20px; 442 | -webkit-appearance: none; 443 | } 444 | 445 | .checkbox-label { 446 | display: flex; 447 | position: relative; 448 | font-size: 20px; 449 | font-weight: 400; 450 | align-items: center; 451 | justify-content: flex-start; 452 | margin-bottom: 20px; 453 | } 454 | 455 | .checkbox-label:before, 456 | .checkbox-label:after { 457 | pointer-events: none; 458 | } 459 | 460 | .checkbox-label:before { 461 | display: flex; 462 | content: " "; 463 | height: 20px; 464 | width: 20px; 465 | border: 0.14em solid white; 466 | border-radius: 0.14em; 467 | /* background: #fff; */ 468 | 469 | margin-right: 10px; 470 | } 471 | 472 | .checkbox-label:after { 473 | position: absolute; 474 | top: 0; 475 | left: 0; 476 | display: flex; 477 | content: " "; 478 | height: 20px; 479 | width: 20px; 480 | border: 0.14em solid white; 481 | border-radius: 0.14em; 482 | background: none; 483 | } 484 | 485 | .checkbox-wrapper input[type="checkbox"]:checked + .checkbox-label:after { 486 | background-color: #2196f3; 487 | content: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjRweCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik05IDE2LjJMNC44IDEybC0xLjQgMS40TDkgMTkgMjEgN2wtMS40LTEuNEw5IDE2LjJ6Ii8+PC9zdmc+"); 488 | } 489 | -------------------------------------------------------------------------------- /SSO-Auth/WebResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Jellyfin.Plugin.SSO_Auth; 2 | 3 | /// 4 | /// A helper class to return HTML for the client's auth flow. 5 | /// 6 | public static class WebResponse 7 | { 8 | /// 9 | /// The shared HTML between all of the responses. 10 | /// 11 | public static readonly string Base = @" 12 | 13 |

Logging in...

14 | 15 | "; 496 | } 497 | } 498 | -------------------------------------------------------------------------------- /build.yaml: -------------------------------------------------------------------------------- 1 | name: "SSO Authentication" 2 | guid: "505ce9d1-d916-42fa-86ca-673ef241d7df" 3 | imageUrl: "https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/main/img/logo.png" 4 | version: "3.5.3.0" 5 | targetAbi: "10.9.0.0" 6 | framework: "net8.0" 7 | owner: "9p4" 8 | overview: "Authenticate users against an SSO provider." 9 | description: | 10 | This plugin allows users to sign in through an SSO provider (such as Google, Facebook, or your own provider). This enables one-click signin. 11 | Review documentation at https://github.com/9p4/jellyfin-plugin-sso 12 | category: "Authentication" 13 | artifacts: 14 | - "SSO-Auth.dll" 15 | - "Duende.IdentityModel.OidcClient.dll" 16 | - "Duende.IdentityModel.dll" 17 | changelog: | 18 | 3.5.3.0: Allow for OID-provided avatars, various bugfixes and workarounds 19 | 3.5.2.4: Updates for Jellyfin 10.9 20 | 3.5.2.3: Improve OpenID discovery policy security rules, fix iOS login bugs related to cache 21 | 3.5.2.2: Fix linking page when using new paths 22 | 3.5.2.1: Hotfix for SAML null checks 23 | 3.5.2.0: Allow overriding the scheme used for generating URLs. 24 | 3.5.1.1: Change iframe URL to point to the web UI instead of the root 25 | 3.5.1.0: Improved paths! No more obscure "p" versus "r" URLs! Improve final redirect for automatic authentication. Add more configuration options for OpenID discovery. 26 | 3.5.0.0: Add support for Live TV authentication. Fix various null pointer bugs. 27 | 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. 28 | 3.3.0.0: Add fallback authentication provider. Add OpenID admin page. 29 | 3.2.0.0: Switch to hashmaps (BREAKING) for performance. Dump expected permissions in logs on error. 30 | 3.1.0.1: Fix redirect bug in WebResponse (#7) 31 | 3.1.0.0: Simplify auth flow so loading the web UI is not required 32 | 3.0.0.0: Add more RBAC features and option to unregister user from SSO 33 | 2.0.1.0: Fix improper artifact loading 34 | 2.0.0.0: Add RBAC and Google support 35 | 1.0.0.0: Initial Release 36 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1730768919, 6 | "narHash": "sha256-8AKquNnnSaJRXZxc5YmF/WfmxiHX6MMZZasRP6RRQkE=", 7 | "owner": "nixos", 8 | "repo": "nixpkgs", 9 | "rev": "a04d33c0c3f1a59a2c1cb0c6e34cd24500e5a1dc", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "nixos", 14 | "ref": "nixpkgs-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; }; 3 | 4 | outputs = { self, nixpkgs }: 5 | let pkgs = nixpkgs.legacyPackages.x86_64-linux; 6 | in { 7 | devShell.x86_64-linux = 8 | pkgs.mkShell { buildInputs = [ pkgs.nodePackages.prettier pkgs.dotnet-sdk_8 ]; }; 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /img/authentik-config-01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/efc997c39e88f5a6aa8af19742fdaeff95c71d64/img/authentik-config-01.jpg -------------------------------------------------------------------------------- /img/authentik-config-02.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/efc997c39e88f5a6aa8af19742fdaeff95c71d64/img/authentik-config-02.jpg -------------------------------------------------------------------------------- /img/authentik-config-03.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/efc997c39e88f5a6aa8af19742fdaeff95c71d64/img/authentik-config-03.jpg -------------------------------------------------------------------------------- /img/authentik-config-04.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/efc997c39e88f5a6aa8af19742fdaeff95c71d64/img/authentik-config-04.jpg -------------------------------------------------------------------------------- /img/authentik-config-05.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/efc997c39e88f5a6aa8af19742fdaeff95c71d64/img/authentik-config-05.jpg -------------------------------------------------------------------------------- /img/custom-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/efc997c39e88f5a6aa8af19742fdaeff95c71d64/img/custom-button.png -------------------------------------------------------------------------------- /img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/efc997c39e88f5a6aa8af19742fdaeff95c71d64/img/logo.png -------------------------------------------------------------------------------- /img/recording-resized.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/efc997c39e88f5a6aa8af19742fdaeff95c71d64/img/recording-resized.mp4 -------------------------------------------------------------------------------- /jellyfin.ruleset: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 57 | 58 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /providers.md: -------------------------------------------------------------------------------- 1 | # Provider Specific Configuration 2 | 3 | This plugin has been tested to work against various providers, though not all providers provide support for all of this plugins' features. 4 | 5 | ## TOC / Tested Providers: 6 | 7 | This section is broken into providers that support Role-Based Access Control (RBAC), and those that do not 8 | 9 | ### Providers that support RBAC 10 | 11 | - ✅ [Authelia](#authelia) 12 | - ✅ [authentik](#authentik) 13 | - [✅ Keycloak](#keycloak-oidc) 14 | - Both [OIDC](#keycloak-oidc) & [SAML](#keycloak-saml) 15 | 16 | ### No RBAC Support 17 | 18 | - ✅ Google OIDC 19 | - ❗ Usernames are numeric 20 | - ❗ Requires disabling validating OpenID endpoints 21 | 22 | ## General Options, when RBAC is supported 23 | 24 | For any provider that supports RBAC, we can configure it as we see fit: 25 | 26 | ```yaml 27 | Enabled: true 28 | EnableAuthorization: true 29 | EnableAllFolders: true 30 | EnabledFolders: [] 31 | Roles: ["jellyfin_user"] 32 | AdminRoles: ["jellyfin_admin"] 33 | EnableFolderRoles: false 34 | FolderRoleMapping: [] 35 | ``` 36 | 37 | ## Authelia 38 | 39 | Authelia is simple to configure, and RBAC is straightforward. 40 | 41 | ### Authelia's Config 42 | 43 | Below is the `identity_providers` section of an Authelia config: 44 | 45 | ### Authelia v4.38 and above 46 | 47 | ```yaml 48 | identity_providers: 49 | oidc: 50 | # hmac secret and private key given by env variables 51 | clients: 52 | - client_id: jellyfin 53 | client_name: My media server 54 | # Client secret should be randomly generated 55 | client_secret: 56 | token_endpoint_auth_method: client_secret_post 57 | authorization_policy: one_factor 58 | redirect_uris: 59 | - https://jellyfin.example.com/sso/OID/redirect/authelia 60 | ``` 61 | 62 | ### Authelia v4.37 and below 63 | 64 | ```yaml 65 | identity_providers: 66 | oidc: 67 | # hmac secret and private key given by env variables 68 | clients: 69 | - id: jellyfin 70 | description: My media server 71 | # Client secret should be randomly generated 72 | secret: 73 | authorization_policy: one_factor 74 | redirect_uris: 75 | - https://jellyfin.example.com/sso/OID/redirect/authelia 76 | ``` 77 | 78 | ### Jellyfin's Config 79 | 80 | On Jellyfin's end, we need to configure an Authelia provider as follows: 81 | 82 | In order to test group membership, we need to request Authelia's `groups` OIDC scope, which we will use to check user roles. 83 | 84 | ```yaml 85 | authelia: 86 | OidEndpoint: https://authelia.example.com 87 | OidClientId: jellyfin 88 | OidSecret: 89 | RoleClaim: groups 90 | OidScopes: ["groups"] 91 | ``` 92 | 93 | ## authentik 94 | 95 | To begin with, we must set up an OIDC provider + application in authentik. Refer to the official documentation for detailed instruction. 96 | 97 | ### authentik's Config 98 | 99 | 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. 100 | 101 | To do this, we: 102 | 103 | - create a **Custom Property Mapping** 104 | 105 | ![image](img/authentik-config-01.jpg) 106 | 107 | - Create a **Scope Mapping** 108 | 109 | ![image](img/authentik-config-02.jpg) 110 | 111 | - Assign the following attributes: 112 | 113 | ![image](img/authentik-config-03.jpg) 114 | 115 | ```yaml 116 | # A nice, human readable name 117 | name: Group Membership 118 | # The name of the scope a client must request to get access to a user's groups 119 | Scope Name: groups 120 | # A description of what is being requested to show to a user 121 | Description: See Which Groups you belong to 122 | ``` 123 | 124 | - For the **Expression** field, use the following code: 125 | ```python 126 | return [group.name for group in user.ak_groups.all()] 127 | ``` 128 | 129 | Now we can add this property mapping to authentik's Jellyfin OAuth provider: 130 | 131 | - Navigate to `Applications/providers` 132 | 133 | ![image](img/authentik-config-04.jpg) 134 | 135 | - Edit / Update your Jellyfin OAuth provider 136 | - Verify your **"Redirect URIs/Origins (RegEx)"** follows the format: `https://domain.tld/sso/OID/redirect/Authentik`. 137 | - Under **"Advanced Protocol Settings"**, add the **Group Membership** Scope 138 | 139 | ![image](img/authentik-config-05.jpg) 140 | 141 | ### Jellyfin's Config 142 | 143 | On Jellyfin's end, we need to configure an authentik provider as follows: 144 | 145 | In order to test group membership, we need to request authentik's OIDC scope `groups`, which we will use to check user roles. 146 | 147 | ```yaml 148 | authentik: 149 | OidEndpoint: https://authentik.example.com/application/o/jellyfin 150 | OidClientId: 151 | OidSecret: 152 | RoleClaim: groups 153 | OidScopes: ["groups"] 154 | ``` 155 | 156 | If you recieve the error `Error processing request.` from Jellyfin when attempting to login and the Jellyfin logs show `Error loading discovery document: Endpoint belongs to different authority` try setting `Do not validate endpoints` in the plugin settings. 157 | 158 | ## Keycloak OIDC 159 | 160 | Keycloak in general is a little more complicated than other providers. Ensure that you have a realm created and have some usable users. 161 | 162 | ### Keycloak's Config 163 | 164 | Create a new Keycloak `openid-connect` application. Set the root URL to your Jellyfin URL (ie https://myjellyfin.example.com) 165 | 166 | Ensure that the following configuration options are set: 167 | 168 | - Access Type: Confidential 169 | - Standard Flow Enabled 170 | - Redirect URI: https://myjellyfin.example.com/sso/OID/redirect/PROVIDER_NAME 171 | - Redirect URI (for Android app): org.jellyfin.mobile://login-callback 172 | - Base URL: https://myjellyfin.example.com 173 | 174 | Press the "Save" button at the bottom of the page and open the "Credentials" tab. Note down the secret. 175 | 176 | 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. 177 | 178 | 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). 179 | 180 | ### Jellyfin's Config 181 | 182 | On Jellyfin's side, we need to configure a Keycloak provider as follows: 183 | 184 | ```yaml 185 | keycloak: 186 | OidEndpoint: https://keycloak.example.com/realms/ 187 | OidClientId: 188 | OidSecret: 189 | RoleClaim: 190 | ``` 191 | 192 | ## Keycloak SAML 193 | 194 | 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. 195 | 196 | ### Keycloak's Config 197 | 198 | Create a new Keycloak `saml` application. Set the root URL to your Jellyfin URL (ie https://myjellyfin.example.com) 199 | 200 | Ensure that the following configuration options are set: 201 | 202 | - Sign Documents on 203 | - Sign Assertions off 204 | - Client Signature Required off 205 | - Redirect URI: [https://myjellyfin.example.com/sso/SAML/start/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/start/PROVIDER_NAME) 206 | - Base URL: [https://myjellyfin.example.com](https://myjellyfin.example.com) 207 | - Master SAML processing URL: [https://myjellyfin.example.com/sso/SAML/start/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/start/PROVIDER_NAME) 208 | 209 | Press the "Save" button at the bottom of the page. 210 | 211 | 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. 212 | 213 | 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). 214 | 215 | 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. 216 | 217 | ### Jellyfin's Config 218 | 219 | ```yaml 220 | keycloak: 221 | SamlEndpoint: https://keycloak.example.com/realms//protocol/saml 222 | SamlClientId: 223 | SamlCertificate: 224 | ``` 225 | --------------------------------------------------------------------------------