mirror of
https://github.com/9p4/jellyfin-plugin-sso.git
synced 2026-09-19 13:12:19 +00:00
Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3efd97964 | ||
|
|
9199fce64f | ||
|
|
0e590858c0 | ||
|
|
9b4393981c | ||
|
|
29e902c109 | ||
|
|
b777e3a346 | ||
|
|
5da7e02faf | ||
|
|
07aa74b1d7 | ||
|
|
f98509633b | ||
|
|
f7d066d6b1 | ||
|
|
4600f56863 | ||
|
|
22dd6629eb | ||
|
|
ece0dc31db | ||
|
|
8771f63eb5 | ||
|
|
54ef348009 | ||
|
|
63ea90114c | ||
|
|
3a8e89db82 | ||
|
|
08bf263255 | ||
|
|
b1beef8155 | ||
|
|
062be242ae | ||
|
|
d9458d5933 | ||
|
|
6cb5fc33bf | ||
|
|
fd371006af | ||
|
|
5d28f1ba31 | ||
|
|
c500a5d435 | ||
|
|
2d14342ad7 | ||
|
|
e8276eae87 | ||
|
|
2d459b7945 | ||
|
|
b8cddb3467 | ||
|
|
875fc62728 | ||
|
|
31cd57829b | ||
|
|
8d3b0ea5eb | ||
|
|
7f66f44743 | ||
|
|
0ad890088d | ||
|
|
bceb448c2d | ||
|
|
0d44515442 | ||
|
|
f748bf7ef7 | ||
|
|
b752f6ffaa | ||
|
|
880a5494ec | ||
|
|
495fbcdda4 | ||
|
|
93c5529138 | ||
|
|
653341f3dd | ||
|
|
ff0f6caa3c | ||
|
|
7d1b3fa97d | ||
|
|
4709a65c00 | ||
|
|
1945ae8683 | ||
|
|
fad64fec7c | ||
|
|
1a736d9707 | ||
|
|
7106af19ec | ||
|
|
0eb7cd4f50 | ||
|
|
06ed040381 | ||
|
|
54586640fb | ||
|
|
ee26556ddf | ||
|
|
f19cd74c19 | ||
|
|
8700686e2b | ||
|
|
9c8f6c64c7 | ||
|
|
075b4fb69f | ||
|
|
09bbe3aa47 | ||
|
|
aa135cdc65 | ||
|
|
dae0043192 | ||
|
|
32e009969b | ||
|
|
9cb3057c90 | ||
|
|
d5315a5263 | ||
|
|
e9273ca82c |
@@ -0,0 +1,13 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: a055
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ""
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Configuration**
|
||||
Add your plugin configuration XML file here formatted as code (with three backticks surrounding the text), or as an upload to a pastebin service.
|
||||
|
||||
**Versions (please complete the following information):**
|
||||
|
||||
- OS: [e.g. Linux]
|
||||
- Browser: [e.g. chrome, safari]
|
||||
- Jellyfin Version: [e.g. 10.8 Alpha 4]
|
||||
- Plugin Version: [e.g. 2.0.1.0 or a Git tag]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here. Was the plugin built from source?
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ""
|
||||
labels: enhancement
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,25 @@
|
||||
name: .NET
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v1
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
- name: Build
|
||||
run: dotnet build --no-restore --warnaserror
|
||||
- name: Test
|
||||
run: dotnet test --no-build --verbosity normal
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Prettier Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
|
||||
jobs:
|
||||
prettier:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Prettify code
|
||||
uses: creyD/[email protected]
|
||||
with:
|
||||
dry: True
|
||||
prettier_options: '--check **/*.{js,html,md,css,scss}'
|
||||
@@ -1,6 +1,28 @@
|
||||
# Jellyfin SSO Plugin
|
||||
<h1 align="center">Jellyfin SSO Plugin</h1>
|
||||
|
||||
This plugin allows users to sign in through an SSO provider (such as Google, Facebook, or your own provider). This enables one-click signin.
|
||||
<p align="center">
|
||||
|
||||
<img alt="Logo" src="https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/main/img/logo.png"/>
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="https://github.com/9p4/jellyfin-plugin-sso">
|
||||
<img alt="GPL 3.0 License" src="https://img.shields.io/github/license/9p4/jellyfin-plugin-sso.svg"/>
|
||||
</a>
|
||||
<a href="https://github.com/9p4/jellyfin-plugin-sso/actions/workflows/dotnet.yml">
|
||||
<img alt="GitHub Actions Build Status" src="https://github.com/9p4/jellyfin-plugin-sso/actions/workflows/dotnet.yml/badge.svg"/>
|
||||
</a>
|
||||
<a href="https://github.com/9p4/jellyfin-plugin-sso/releases">
|
||||
<img alt="Current Release" src="https://img.shields.io/github/release/9p4/jellyfin-plugin-sso.svg"/>
|
||||
</a>
|
||||
<a href="https://github.com/9p4/jellyfin-plugin-sso/releases.atom">
|
||||
<img alt="Release RSS Feed" src="https://img.shields.io/badge/rss-releases-ffa500?logo=rss" />
|
||||
</a>
|
||||
<a href="https://github.com/9p4/jellyfin-plugin-sso/commits/main.atom">
|
||||
<img alt="Main Commits RSS Feed" src="https://img.shields.io/badge/rss-commits-ffa500?logo=rss" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
This plugin allows users to sign in through an SSO provider (such as Google, Microsoft, or your own provider). This enables one-click signin.
|
||||
|
||||
https://user-images.githubusercontent.com/17993169/149681516-f93b43f5-fa5c-4c1f-a909-e5414878a864.mp4
|
||||
|
||||
@@ -8,11 +30,11 @@ https://user-images.githubusercontent.com/17993169/149681516-f93b43f5-fa5c-4c1f-
|
||||
|
||||
This is 100% alpha software! PRs are welcome to improve the code.
|
||||
|
||||
There is NO admin configuration! You must use the API to configure the program!
|
||||
~~There is NO admin configuration! You must use the API to configure the program!~~ Added by [matthewstrasiotto](https://github.com/matthewstrasiotto) in PR [#18](https://github.com/9p4/jellyfin-plugin-sso/pull/18) and [#27](https://github.com/9p4/jellyfin-plugin-sso/pull/27).
|
||||
|
||||
**This is for Jellyfin 10.8**
|
||||
**[This is for Jellyfin 10.8](https://github.com/9p4/jellyfin-plugin-sso/issues/3) and only on the Web UI!**
|
||||
|
||||
**This README reflects the __main__ branch! Switch tags to view version-specific documentation!**
|
||||
**This README reflects the branch it is currently on! Switch tags to view version-specific documentation!**
|
||||
|
||||
## Tested Providers
|
||||
|
||||
@@ -30,7 +52,7 @@ This is my first time writing C# so please take all of the code written here wit
|
||||
|
||||
## Installing
|
||||
|
||||
Add the package repo [https://repo.saggis.com/jellyfin/manifest.json](https://repo.saggis.com/jellyfin/manifest.json) to your Jellyfin configuration. Then, install the package!
|
||||
Add the package repo [https://repo.ersei.net/jellyfin/manifest.json](https://repo.ersei.net/jellyfin/manifest.json) to your Jellyfin configuration. Then, install the package!
|
||||
|
||||
## Building
|
||||
|
||||
@@ -44,7 +66,7 @@ Build the zipped plugin with `jprm --verbosity=debug plugin build .`.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Admin page
|
||||
- [x] Admin page
|
||||
- [ ] Automated tests
|
||||
- [x] Add role/claims support
|
||||
- [ ] Use canonical usernames instead of preferred usernames
|
||||
@@ -56,7 +78,7 @@ Build the zipped plugin with `jprm --verbosity=debug plugin build .`.
|
||||
|
||||
Example for adding a SAML configuration with the API using [curl](https://curl.se/):
|
||||
|
||||
`curl -v -X POST -H "Content-Type: application/json" -d '{"samlEndpoint": "https://keycloak.example.com/realms/test/protocol/saml", "samlClientId": "jellyfin-saml", "samlCertificate": "Very long base64 encoded string here", "enabled": true, "enableAuthorization": true, "enableAllFolders": false, "enabledFolders": [], "adminRoles": ["jellyfin-admin"], "roles": ["allowed-to-use-jellyfin"], "enableFolderRoles": true, "folderRoleMapping": [{"role": "allowed-to-watch-movies", "folders": ["cc7df17e2f3509a4b5fc1d1ff0a6c4d0", "f137a2dd21bbc1b99aa5c0f6bf02a805"]}]}' "https://myjellyfin.example.com/sso/SAML/Add?api_key=API_KEY_HERE"`
|
||||
`curl -v -X POST -H "Content-Type: application/json" -d '{"samlEndpoint": "https://keycloak.example.com/realms/test/protocol/saml", "samlClientId": "jellyfin-saml", "samlCertificate": "Very long base64 encoded string here", "enabled": true, "enableAuthorization": true, "enableAllFolders": false, "enabledFolders": [], "adminRoles": ["jellyfin-admin"], "roles": ["allowed-to-use-jellyfin"], "enableFolderRoles": true, "folderRoleMapping": [{"role": "allowed-to-watch-movies", "folders": ["cc7df17e2f3509a4b5fc1d1ff0a6c4d0", "f137a2dd21bbc1b99aa5c0f6bf02a805"]}]}' "https://myjellyfin.example.com/sso/SAML/Add/PROVIDER_NAME?api_key=API_KEY_HERE"`
|
||||
|
||||
Make sure that the JSON is the same as the configuration you would like.
|
||||
|
||||
@@ -65,26 +87,26 @@ The SAML provider must have the following configuration (I am using Keycloak, an
|
||||
- Sign Documents on
|
||||
- Sign Assertions off
|
||||
- Client Signature Required off
|
||||
- Redirect URI: [https://myjellyfin.example.com/sso/SAML/p/clientid](https://myjellyfin.example.com/sso/OID/p/clientid)
|
||||
- Redirect URI: [https://myjellyfin.example.com/sso/SAML/p/PROVIDER_NAME](https://myjellyfin.example.com/sso/OID/p/PROVIDER_NAME)
|
||||
- Base URL: [https://myjellyfin.example.com](https://myjellyfin.example.com)
|
||||
- Master SAML processing URL: [https://myjellyfin.example.com/sso/SAML/p/clientid](https://myjellyfin.example.com/sso/SAML/p/clientid)
|
||||
- Master SAML processing URL: [https://myjellyfin.example.com/sso/SAML/p/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/p/PROVIDER_NAME)
|
||||
|
||||
Make sure that `clientid` is replaced with the actual client ID!
|
||||
Make sure that `clientid` is replaced with the actual client ID and `PROVIDER_NAME` is replaced with the chosen provider name!
|
||||
|
||||
### OpenID
|
||||
|
||||
Example for adding an OpenID configuration with the API using [curl](https://curl.se/)
|
||||
|
||||
`curl -v -X POST -H "Content-Type: application/json" -d '{"oidEndpoint": "https://keycloak.example.com/realms/test", "oidClientId": "jellyfin-oid", "oidSecret": "short secret here", "enabled": true, "enableAuthorization": true, "enableAllFolders": false, "enabledFolders": [], "adminRoles": ["jellyfin-admin"], "roles": ["allowed-to-use-jellyfin"], "enableFolderRoles": true, "folderRoleMapping": [{"role": "allowed-to-watch-movies", "folders": ["cc7df17e2f3509a4b5fc1d1ff0a6c4d0", "f137a2dd21bbc1b99aa5c0f6bf02a805"]}]' "https://myjellyfin.example.com/sso/OID/Add?api_key=API_KEY_HERE"`
|
||||
`curl -v -X POST -H "Content-Type: application/json" -d '{"oidEndpoint": "https://keycloak.example.com/realms/test", "oidClientId": "jellyfin-oid", "oidSecret": "short secret here", "enabled": true, "enableAuthorization": true, "enableAllFolders": false, "enabledFolders": [], "adminRoles": ["jellyfin-admin"], "roles": ["allowed-to-use-jellyfin"], "enableFolderRoles": true, "folderRoleMapping": [{"role": "allowed-to-watch-movies", "folders": ["cc7df17e2f3509a4b5fc1d1ff0a6c4d0", "f137a2dd21bbc1b99aa5c0f6bf02a805"]}], "roleClaim": "realm_access", "oidScopes" : [""]}' "https://myjellyfin.example.com/sso/OID/Add/PROVIDER_NAME?api_key=API_KEY_HERE"`
|
||||
|
||||
The OpenID provider must have the following configuration (again, I am using Keycloak)
|
||||
|
||||
- Access Type: Confidential
|
||||
- Standard Flow Enabled
|
||||
- Redirect URI: [https://myjellyfin.example.com/sso/OID/r/clientid](https://myjellyfin.example.com/sso/OID/r/clientid)
|
||||
- Redirect URI: [https://myjellyfin.example.com/sso/OID/r/PROVIDER_NAME](https://myjellyfin.example.com/sso/OID/r/PROVIDER_NAME)
|
||||
- Base URL: [https://myjellyfin.example.com](https://myjellyfin.example.com)
|
||||
|
||||
Make sure that `clientid` is replaced with the actual client ID!
|
||||
Make sure that `clientid` is replaced with the actual client ID and `PROVIDER_NAME` is replaced with the chosen provider name!
|
||||
|
||||
## API Endpoints
|
||||
|
||||
@@ -94,21 +116,20 @@ The API is all done from a base URL of `/sso/`
|
||||
|
||||
#### Flow
|
||||
|
||||
- POST `SAML/p/clientid`: This is the SAML POST endpoint. It accepts a form response from the SAML provider and returns HTML and JavaScript for the client to login.
|
||||
- GET `SAML/p/clientid`: This is the SAML initiator: it will begin the authorization flow for SAML with a given client ID.
|
||||
- POST `SAML/Auth`: This is the SAML client-side API: the HTML and JavaScript client will call this endpoint to receive Jellyfin credentials. Post format is in JSON with the following keys:
|
||||
- POST `SAML/p/PROVIDER_NAME`: This is the SAML POST endpoint. It accepts a form response from the SAML provider and returns HTML and JavaScript for the client to login with a given provider name.
|
||||
- GET `SAML/p/PROVIDER_NAME`: This is the SAML initiator: it will begin the authorization flow for SAML with a given provider name.
|
||||
- POST `SAML/Auth/PROVIDER_NAME`: This is the SAML client-side API: the HTML and JavaScript client will call this endpoint to receive Jellyfin credentials given a provider name. Post format is in JSON with the following keys:
|
||||
- `deviceId`: string. Device ID.
|
||||
- `deviceName`: string. Device name.
|
||||
- `appName`: string. App name.
|
||||
- `appVersion`: string. App version.
|
||||
- `data`: string. The signed SAML XML request. Used to verify a request.
|
||||
- `provider`: string. The current SAML client ID.
|
||||
|
||||
#### Configuration
|
||||
|
||||
These all require authorization. Append an API key to the end of the request: `curl "http://myjellyfin.example.com/sso/SAML/Get?api_key=API_KEY_HERE"`
|
||||
|
||||
- POST `SAML/Add`: This adds a configuration for SAML. It accepts JSON with the following keys and format:
|
||||
- POST `SAML/Add/PROVIDER_NAME`: This adds or overwrites a configuration for SAML for the given provider name. It accepts JSON with the following keys and format:
|
||||
- `samlEndpoint`: string. The SAML endpoint.
|
||||
- `samlClientId`: string. The SAML client ID.
|
||||
- `samlCertificate`: string. The base64 encoded SAML certificate.
|
||||
@@ -120,29 +141,28 @@ These all require authorization. Append an API key to the end of the request: `c
|
||||
- `adminRoles`: array of strings. This uses SAML response's `Role` attributes. If a user has any of these roles, then the user is an admin. Leave blank to disable (default is to not enable admin permissions).
|
||||
- `enableFolderRoles`: boolean. Determines if role-based folder access should be used.
|
||||
- `folderRoleMapping`: object in the format "role": string and "folders": array of strings. The user with this role will have access to the following folders if `enableFolderRoles` is enabled. To get the IDs of the folders, GET the `/Library/MediaFolders` URL with an API key. Look for the `Id` attribute.
|
||||
- GET `SAML/Del/clientId`: This removes a configuration for SAML for a given client ID.
|
||||
- `defaultProvider`: string. The set provider then gets assigned to the user after they have logged in. If it is not set, nothing is changed. With this, a user can login with SSO but is still able to log in via other providers later. See the `Unregister` endpoint.
|
||||
- GET `SAML/Del/PROVIDER_NAME`: This removes a configuration for SAML for a given provider name.
|
||||
- GET `SAML/Get`: Lists the configurations currently available.
|
||||
|
||||
|
||||
### OpenID
|
||||
|
||||
#### Flow
|
||||
|
||||
- GET `OID/r/clientId`: This is the OpenID callback path. This will return HTML and JavaScript for the client to login.
|
||||
- GET `OID/p/clientId`: This is the OpenID initiator: it will begin the authorization flow for OpenID with a given client ID.
|
||||
- POST `OID/Auth`: This is the OpenID client-side API: the HTML and JavaScript client will call this endpoint to receive Jellyfin credentials. Post format is in JSON with the following keys:
|
||||
- GET `OID/r/PROVIDER_NAME`: This is the OpenID callback path. This will return HTML and JavaScript for the client to login with a given provider name.
|
||||
- GET `OID/p/PROVIDER_NAME`: This is the OpenID initiator: it will begin the authorization flow for OpenID with a given provider name.
|
||||
- POST `OID/Auth/PROVIDER_NAME`: This is the OpenID client-side API: the HTML and JavaScript client will call this endpoint to receive Jellyfin credentials for a given provider name. Post format is in JSON with the following keys:
|
||||
- `deviceId`: string. Device ID.
|
||||
- `deviceName`: string. Device name.
|
||||
- `appName`: string. App name.
|
||||
- `appVersion`: string. App version.
|
||||
- `data`: string. The OpenID state. Used to verify a request.
|
||||
- `provider`: string. The current OpenID client ID.
|
||||
|
||||
#### Configuration
|
||||
|
||||
These all require authorization. Append an API key to the end of the request: `curl "http://myjellyfin.example.com/sso/OID/Get?api_key=9c6e5fae4ae145669e6b7a3942f813b7"`
|
||||
|
||||
- POST `OID/Add`: This adds a configuration for OpenID. It accepts JSON with the following keys and format:
|
||||
- POST `OID/Add/PROVIDERNAME`: This adds or overwrites a configuration for OpenID with a given provider name. It accepts JSON with the following keys and format:
|
||||
- `oidEndpoint`: string. The OpenID endpoint. Must have a `.well-known` path available.
|
||||
- `oidClientId`: string. The OpenID client ID.
|
||||
- `oidSecret`: string. The OpenID secret.
|
||||
@@ -154,8 +174,12 @@ These all require authorization. Append an API key to the end of the request: `c
|
||||
- `adminRoles`: array of strings. This uses the OpenID response against the claim set in `roleClaim`. If a user has any of these roles, then the user is an admin. Leave blank to disable (default is to not enable admin permissions).
|
||||
- `enableFolderRoles`: boolean. Determines if role-based folder access should be used.
|
||||
- `folderRoleMapping`: object in the format "role": string and "folders": array of strings. The user with this role will have access to the following folders if `enableFolderRoles` is enabled. To get the IDs of the folders, GET the `/Library/MediaFolders` URL with an API key. Look for the `Id` attribute.
|
||||
- `roleClaim`: string. This is the value in the OpenID response to check for roles. For Keycloak, it is `realm_roles` by default.
|
||||
- GET `OID/Del/clientId`: This removes a configuration for OpenID for a given client ID.
|
||||
- `roleClaim`: string. This is the value in the OpenID response to check for roles. For Keycloak, it is `realm_access.roles` by default. The first element is the claim type, the subsequent values are to parse the JSON of the claim value. Use a "\\." to denote a literal ".". This expects a list of strings from the OIDC server.
|
||||
- `oidScopes` : array of strings. each containing an additional scope name to include in the OIDC request.
|
||||
- For some OIDC providers (For example, [authelia](https://github.com/9p4/jellyfin-plugin-sso/issues/23#issuecomment-1112237616)), additional scopes may be required in order to validate group membership in role claim.
|
||||
- Leave empty to only request the default scopes.
|
||||
- `defaultProvider`: string. The set provider then gets assigned to the user after they have logged in. If it is not set, nothing is changed. With this, a user can login with SSO but is still able to log in via other providers later. See the `Unregister` endpoint.
|
||||
- GET `OID/Del/PROVIDER_NAME`: This removes a configuration for OpenID for a given provider name.
|
||||
- GET `OID/Get`: Lists the configurations currently available.
|
||||
- GET `OID/States`: Lists currently active OpenID flows in progress.
|
||||
|
||||
@@ -167,13 +191,13 @@ These all require authorization. Append an API key to the end of the request: `c
|
||||
|
||||
There is no GUI to sign in. You have to make it yourself! The buttons should redirect to something like this: [https://myjellyfin.example.com/sso/SAML/p/clientid](https://myjellyfin.example.com/sso/SAML/p/clientid) replacing `clientid` with the provider client ID and `SAML` with the auth scheme (either `SAML` or `OID`).
|
||||
|
||||
Furthermore, there is no functional admin page (yet). PRs for this are welcome. In the meantime, you have to interact with the API to add or remove configurations.
|
||||
~~Furthermore, there is no functional admin page (yet). PRs for this are welcome. In the meantime, you have to interact with the API to add or remove configurations.~~ Added by [matthewstrasiotto](https://github.com/matthewstrasiotto) in PR [#18](https://github.com/9p4/jellyfin-plugin-sso/pull/18) and [#27](https://github.com/9p4/jellyfin-plugin-sso/pull/27).
|
||||
|
||||
There is also no logout callback. Logging out of Jellyfin will log you out of Jellyfin only, instead of the SSO provider as well.
|
||||
|
||||
~~This only supports Jellyfin on it's own domain (for now). This is because I'm using string concatenation for generating some URLs. A PR is welcome to patch this.~~ Fixed in [PR #1](https://github.com/9p4/jellyfin-plugin-sso/pull/1).
|
||||
|
||||
**This only works on the web UI**. The user must open the Jellyfin web UI BEFORE using the SSO program to populate some values in the localStorage.
|
||||
**This only works on the web UI**. ~~The user must open the Jellyfin web UI BEFORE using the SSO program to populate some values in the localStorage.~~ Fixed by implementing a comment by [Pfuenzle](https://github.com/Pfuenzle) in [Issue #5](https://github.com/9p4/jellyfin-plugin-sso/issues/5#issuecomment-1041864820).
|
||||
|
||||
## Credits and Thanks
|
||||
|
||||
@@ -184,3 +208,7 @@ I use the [AspNet SAML](https://github.com/jitbit/AspNetSaml/) library for the S
|
||||
I use the [IdentityModel OIDC Client](https://github.com/IdentityModel/IdentityModel.OidcClient/) library for the OpenID side of things.
|
||||
|
||||
Thanks to these projects, without which I would have been pulling my hair out implementing these protocols from scratch.
|
||||
|
||||
## Something funny about the origins of this plugin
|
||||
|
||||
It totally slipped my mind, but I had [requested this functionality a few years back](https://github.com/jellyfin/jellyfin/issues/2012). What goes around comes around, I guess.
|
||||
|
||||
+445
-234
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Mime;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using IdentityModel.OidcClient;
|
||||
using Jellyfin.Data.Entities;
|
||||
@@ -10,9 +12,11 @@ using MediaBrowser.Controller.Authentication;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Jellyfin.Plugin.SSO_Auth.Api;
|
||||
|
||||
@@ -42,212 +46,294 @@ public class SSOController : ControllerBase
|
||||
_logger.LogInformation("SSO Controller initialized");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The GET endpoint for OpenID provider to callback to. Returns a webpage that parses client data and completes auth.
|
||||
/// </summary>
|
||||
/// <param name="provider">The ID of the provider which will use the callback information.</param>
|
||||
/// <param name="state">The current request state.</param>
|
||||
/// <returns>A webpage that will complete the client-side flow.</returns>
|
||||
// Actually a GET: https://github.com/IdentityModel/IdentityModel.OidcClient/issues/325
|
||||
[HttpGet("OID/r/{provider}")]
|
||||
public ActionResult OIDPost(string provider)
|
||||
public ActionResult OidPost(
|
||||
[FromRoute] string provider,
|
||||
[FromQuery] string state) // Although this is a GET function, this function is called `Post` for consistency with SAML
|
||||
{
|
||||
// Actually a GET: https://github.com/IdentityModel/IdentityModel.OidcClient/issues/325
|
||||
foreach (var config in SSOPlugin.Instance.Configuration.OIDConfigs)
|
||||
OidConfig config;
|
||||
try
|
||||
{
|
||||
if (config.OIDClientId == provider && config.Enabled)
|
||||
config = SSOPlugin.Instance.Configuration.OidConfigs[provider];
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return BadRequest("No matching provider found");
|
||||
}
|
||||
|
||||
if (config.Enabled)
|
||||
{
|
||||
var options = new OidcClientOptions
|
||||
{
|
||||
var options = new OidcClientOptions
|
||||
Authority = config.OidEndpoint,
|
||||
ClientId = config.OidClientId,
|
||||
ClientSecret = config.OidSecret,
|
||||
RedirectUri = GetRequestBase() + "/sso/OID/r/" + provider,
|
||||
Scope = string.Join(" ", config.OidScopes.Prepend("openid profile")),
|
||||
};
|
||||
options.Policy.Discovery.ValidateEndpoints = false; // For Google and other providers with different endpoints
|
||||
var oidcClient = new OidcClient(options);
|
||||
var currentState = StateManager[state].State;
|
||||
var result = oidcClient.ProcessResponseAsync(Request.QueryString.Value, currentState).Result;
|
||||
if (result.IsError)
|
||||
{
|
||||
return ReturnError(StatusCodes.Status400BadRequest, result.Error + " Try logging in again.");
|
||||
}
|
||||
|
||||
if (!config.EnableFolderRoles)
|
||||
{
|
||||
StateManager[state].Folders = new List<string>(config.EnabledFolders);
|
||||
}
|
||||
else
|
||||
{
|
||||
StateManager[state].Folders = new List<string>();
|
||||
}
|
||||
|
||||
foreach (var claim in result.User.Claims)
|
||||
{
|
||||
if (claim.Type == "preferred_username")
|
||||
{
|
||||
Authority = config.OIDEndpoint,
|
||||
ClientId = config.OIDClientId,
|
||||
ClientSecret = config.OIDSecret,
|
||||
RedirectUri = GetRequestBase() + "/sso/OID/r/" + provider,
|
||||
Scope = "openid profile",
|
||||
};
|
||||
options.Policy.Discovery.ValidateEndpoints = false; // For Google and other providers with different endpoints
|
||||
var oidcClient = new OidcClient(options);
|
||||
var state = StateManager[Request.Query["state"]].State;
|
||||
var result = oidcClient.ProcessResponseAsync(Request.QueryString.Value, state).Result;
|
||||
if (result.IsError)
|
||||
{
|
||||
return Content("Something went wrong...", MediaTypeNames.Text.Plain);
|
||||
StateManager[state].Username = claim.Value;
|
||||
if (config.Roles.Length == 0)
|
||||
{
|
||||
StateManager[state].Valid = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.EnableFolderRoles)
|
||||
// Role processing
|
||||
// The regex matches any "." not preceded by a "\": a.b.c will be split into a, b, and c, but a.b\.c will be split into a, b.c (after processing the escaped dots)
|
||||
// We have to first process the RoleClaim string
|
||||
string[] segments = Regex.Split(config.RoleClaim, "(?<!\\\\)\\.");
|
||||
// Now we make sure that any escaped "."s ("\.") are replaced with "."
|
||||
for (int i = 0; i < segments.Length; i++)
|
||||
{
|
||||
StateManager[Request.Query["state"]].Folders = new List<string>(config.EnabledFolders);
|
||||
} else {
|
||||
StateManager[Request.Query["state"]].Folders = new List<string>();
|
||||
segments[i] = segments[i].Replace("\\.", ".");
|
||||
}
|
||||
|
||||
if (claim.Type == segments[0])
|
||||
{
|
||||
List<string> roles;
|
||||
// If we are not using JSON values, just use the raw info from the claim value
|
||||
if (segments.Length == 1)
|
||||
{
|
||||
roles = new List<string> { claim.Value };
|
||||
}
|
||||
else
|
||||
{
|
||||
// We recursively traverse through the JSON data for the roles and parse it
|
||||
var json = JsonConvert.DeserializeObject<IDictionary<string, object>>(claim.Value);
|
||||
for (int i = 1; i < segments.Length - 1; i++)
|
||||
{
|
||||
var segment = segments[i];
|
||||
json = (json[segment] as JObject).ToObject<IDictionary<string, object>>();
|
||||
}
|
||||
|
||||
// The final step is to take the JSON and turn it from a dictionary into a string
|
||||
roles = (json[segments[^1]] as JArray).ToObject<List<string>>();
|
||||
}
|
||||
|
||||
foreach (string role in roles)
|
||||
{
|
||||
// Check if allowed to login based on roles
|
||||
if (config.Roles.Length != 0)
|
||||
{
|
||||
foreach (string validRoles in config.Roles)
|
||||
{
|
||||
if (role.Equals(validRoles))
|
||||
{
|
||||
StateManager[state].Valid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if admin based on roles
|
||||
if (config.AdminRoles.Length != 0)
|
||||
{
|
||||
foreach (string validAdminRoles in config.AdminRoles)
|
||||
{
|
||||
if (role.Equals(validAdminRoles))
|
||||
{
|
||||
StateManager[state].Admin = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get allowed folders from roles
|
||||
if (config.EnableFolderRoles)
|
||||
{
|
||||
foreach (FolderRoleMap folderRoleMap in config.FolderRoleMapping)
|
||||
{
|
||||
if (role.Equals(folderRoleMap.Role))
|
||||
{
|
||||
StateManager[state].Folders.AddRange(folderRoleMap.Folders);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the provider doesn't support preferred_username, then use sub
|
||||
if (!StateManager[state].Valid)
|
||||
{
|
||||
foreach (var claim in result.User.Claims)
|
||||
{
|
||||
if (claim.Type == "preferred_username")
|
||||
if (claim.Type == "sub")
|
||||
{
|
||||
StateManager[Request.Query["state"]].Username = claim.Value;
|
||||
StateManager[state].Username = claim.Value;
|
||||
if (config.Roles.Length == 0)
|
||||
{
|
||||
StateManager[Request.Query["state"]].Valid = true;
|
||||
StateManager[state].Valid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Role processing
|
||||
if (claim.Type == config.RoleClaim)
|
||||
{
|
||||
List<string> roles = JsonConvert.DeserializeObject<IDictionary<string, List<string>>>(claim.Value)["roles"]; // Might need error handling here
|
||||
foreach (string role in roles)
|
||||
{
|
||||
// Check if allowed to login based on roles
|
||||
if (config.Roles.Length != 0)
|
||||
{
|
||||
foreach (string validRoles in config.Roles)
|
||||
{
|
||||
if (role.Equals(validRoles))
|
||||
{
|
||||
StateManager[Request.Query["state"]].Valid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check if admin based on roles
|
||||
if (config.AdminRoles.Length != 0)
|
||||
{
|
||||
foreach (string validAdminRoles in config.AdminRoles)
|
||||
{
|
||||
if (role.Equals(validAdminRoles))
|
||||
{
|
||||
StateManager[Request.Query["state"]].Admin = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Get allowed folders from roles
|
||||
if (config.EnableFolderRoles)
|
||||
{
|
||||
foreach (FolderRoleMap folderRoleMap in config.FolderRoleMapping)
|
||||
{
|
||||
if (role.Equals(folderRoleMap.Role))
|
||||
{
|
||||
StateManager[Request.Query["state"]].Folders.AddRange(folderRoleMap.Folders);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (StateManager[state].Valid)
|
||||
{
|
||||
return Content(WebResponse.Generator(data: state, provider: provider, baseUrl: GetRequestBase(), mode: "OID"), MediaTypeNames.Text.Html);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"OpenID user {Username} has one or more incorrect role claims: {@Claims}. Expected any one of: {@ExpectedClaims}",
|
||||
StateManager[state].Username,
|
||||
result.User.Claims.Select(o => new { o.Type, o.Value }),
|
||||
config.Roles);
|
||||
|
||||
// If the provider doesn't support preferred_username, then use sub
|
||||
if (!StateManager[Request.Query["state"]].Valid)
|
||||
{
|
||||
foreach (var claim in result.User.Claims)
|
||||
{
|
||||
if (claim.Type == "sub")
|
||||
{
|
||||
StateManager[Request.Query["state"]].Username = claim.Value;
|
||||
if (config.Roles.Length == 0)
|
||||
{
|
||||
StateManager[Request.Query["state"]].Valid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (StateManager[Request.Query["state"]].Valid)
|
||||
{
|
||||
return Content(WebResponse.OIDGenerator(data: Request.Query["state"], provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Content("Error. Check permissions."); // TODO: Return error code as well
|
||||
}
|
||||
return ReturnError(StatusCodes.Status401Unauthorized, "Error. Check permissions.");
|
||||
}
|
||||
}
|
||||
|
||||
return Content("no active providers found"); // TODO: Return error code as well
|
||||
// If the config doesn't have an active provider matching the requeset, show an error
|
||||
return BadRequest("No matching provider found");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initiates the login flow for OpenID. This redirects the user to the auth provider.
|
||||
/// </summary>
|
||||
/// <param name="provider">The name of the provider.</param>
|
||||
/// <returns>An asynchronous result for the authentication.</returns>
|
||||
[HttpGet("OID/p/{provider}")]
|
||||
public async Task<ActionResult> OIDChallenge(string provider)
|
||||
public async Task<ActionResult> OidChallenge(string provider)
|
||||
{
|
||||
Invalidate();
|
||||
foreach (var config in SSOPlugin.Instance.Configuration.OIDConfigs)
|
||||
OidConfig config;
|
||||
try
|
||||
{
|
||||
if (config.OIDClientId == provider && config.Enabled)
|
||||
config = SSOPlugin.Instance.Configuration.OidConfigs[provider];
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
throw new ArgumentException("Provider does not exist");
|
||||
}
|
||||
|
||||
if (config.Enabled)
|
||||
{
|
||||
var options = new OidcClientOptions
|
||||
{
|
||||
var options = new OidcClientOptions
|
||||
{
|
||||
Authority = config.OIDEndpoint,
|
||||
ClientId = config.OIDClientId,
|
||||
ClientSecret = config.OIDSecret,
|
||||
RedirectUri = GetRequestBase() + "/sso/OID/r/" + provider,
|
||||
Scope = "openid profile"
|
||||
};
|
||||
options.Policy.Discovery.ValidateEndpoints = false; // For Google and other providers with different endpoints
|
||||
var oidcClient = new OidcClient(options);
|
||||
var state = await oidcClient.PrepareLoginAsync().ConfigureAwait(false);
|
||||
StateManager.Add(state.State, new TimedAuthorizeState(state, DateTime.Now));
|
||||
return Redirect(state.StartUrl);
|
||||
}
|
||||
Authority = config.OidEndpoint,
|
||||
ClientId = config.OidClientId,
|
||||
ClientSecret = config.OidSecret,
|
||||
RedirectUri = GetRequestBase() + "/sso/OID/r/" + provider,
|
||||
Scope = string.Join(" ", config.OidScopes.Prepend("openid profile")),
|
||||
};
|
||||
options.Policy.Discovery.ValidateEndpoints = false; // For Google and other providers with different endpoints
|
||||
var oidcClient = new OidcClient(options);
|
||||
var state = await oidcClient.PrepareLoginAsync().ConfigureAwait(false);
|
||||
StateManager.Add(state.State, new TimedAuthorizeState(state, DateTime.Now));
|
||||
return Redirect(state.StartUrl);
|
||||
}
|
||||
|
||||
throw new ArgumentException("Provider does not exist");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an OpenID auth configuration. Requires administrator privileges. If the provider already exists, it will be removed and readded.
|
||||
/// </summary>
|
||||
/// <param name="provider">The name of the provider to add.</param>
|
||||
/// <param name="config">The OID configuration (deserialized from a JSON post).</param>
|
||||
[Authorize(Policy = "RequiresElevation")]
|
||||
[HttpPost("OID/Add")]
|
||||
public void OIDAdd([FromBody] OIDConfig config)
|
||||
[HttpPost("OID/Add/{provider}")]
|
||||
public void OidAdd(string provider, [FromBody] OidConfig config)
|
||||
{
|
||||
var configuration = SSOPlugin.Instance.Configuration;
|
||||
for (var i = 0; i < configuration.OIDConfigs.Count; i++)
|
||||
{
|
||||
if (configuration.OIDConfigs[i].OIDClientId.Equals(config.OIDClientId))
|
||||
{
|
||||
configuration.OIDConfigs.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
configuration.OIDConfigs.Add(config);
|
||||
configuration.OidConfigs[provider] = config;
|
||||
SSOPlugin.Instance.UpdateConfiguration(configuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an OpenID provider.
|
||||
/// </summary>
|
||||
/// <param name="provider">Name of provider to delete.</param>
|
||||
[Authorize(Policy = "RequiresElevation")]
|
||||
[HttpGet("OID/Del/{provider}")]
|
||||
public void OIDDel(string provider)
|
||||
public void OidDel(string provider)
|
||||
{
|
||||
var configuration = SSOPlugin.Instance.Configuration;
|
||||
for (var i = 0; i < configuration.OIDConfigs.Count; i++)
|
||||
{
|
||||
if (configuration.OIDConfigs[i].OIDClientId.Equals(provider))
|
||||
{
|
||||
configuration.OIDConfigs.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
configuration.OidConfigs.Remove(provider);
|
||||
SSOPlugin.Instance.UpdateConfiguration(configuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists the OpenID providers configured. Requires administrator privileges.
|
||||
/// </summary>
|
||||
/// <returns>The list of OpenID configurations.</returns>
|
||||
[Authorize(Policy = "RequiresElevation")]
|
||||
[HttpGet("OID/Get")]
|
||||
public ActionResult OIDProviders()
|
||||
public ActionResult OidProviders()
|
||||
{
|
||||
return Ok(SSOPlugin.Instance.Configuration.OIDConfigs);
|
||||
return Ok(SSOPlugin.Instance.Configuration.OidConfigs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is a debug endpoint to list all running OpenID flows. Requires administrator privileges.
|
||||
/// </summary>
|
||||
/// <returns>The list of OpenID flows in progress.</returns>
|
||||
[Authorize(Policy = "RequiresElevation")]
|
||||
[HttpGet("OID/States")]
|
||||
public ActionResult OIDStates()
|
||||
public ActionResult OidStates()
|
||||
{
|
||||
return Ok(StateManager);
|
||||
}
|
||||
|
||||
[HttpPost("OID/Auth")]
|
||||
/// <summary>
|
||||
/// This endpoint accepts JSON and will authorize the user from the device values passed from the client.
|
||||
/// </summary>
|
||||
/// <param name="provider">Name of provider to authenticate against.</param>
|
||||
/// <param name="response">The data passed to the client to ensure it is the right one.</param>
|
||||
/// <returns>JSON for the client to populate information with.</returns>
|
||||
[HttpPost("OID/Auth/{provider}")]
|
||||
[Consumes(MediaTypeNames.Application.Json)]
|
||||
[Produces(MediaTypeNames.Application.Json)]
|
||||
public async Task<ActionResult> OIDAuth([FromBody] AuthResponse response)
|
||||
public async Task<ActionResult> OidAuth(string provider, [FromBody] AuthResponse response)
|
||||
{
|
||||
foreach (var config in SSOPlugin.Instance.Configuration.OIDConfigs)
|
||||
OidConfig config;
|
||||
try
|
||||
{
|
||||
if (config.OIDClientId == response.Provider && config.Enabled)
|
||||
config = SSOPlugin.Instance.Configuration.OidConfigs[provider];
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return BadRequest("No matching provider found");
|
||||
}
|
||||
|
||||
if (config.Enabled)
|
||||
{
|
||||
foreach (var kvp in StateManager)
|
||||
{
|
||||
foreach (var kvp in StateManager)
|
||||
if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid)
|
||||
{
|
||||
if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid)
|
||||
{
|
||||
var authenticationResult = await Authenticate(kvp.Value.Username, kvp.Value.Admin, config.EnableAuthorization, config.EnableAllFolders, kvp.Value.Folders.ToArray(), response)
|
||||
.ConfigureAwait(false);
|
||||
return Ok(authenticationResult);
|
||||
}
|
||||
var authenticationResult = await Authenticate(kvp.Value.Username, kvp.Value.Admin, config.EnableAuthorization, config.EnableAllFolders, kvp.Value.Folders.ToArray(), response, config.DefaultProvider)
|
||||
.ConfigureAwait(false);
|
||||
return Ok(authenticationResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,89 +341,121 @@ public class SSOController : ControllerBase
|
||||
return Problem("Something went wrong");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is the callback for the SAML flow. This creates a webpage to complete auth.
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider that is calling back.</param>
|
||||
/// <returns>A webpage that will complete the client-side flow.</returns>
|
||||
[HttpPost("SAML/p/{provider}")]
|
||||
public ActionResult SAMLPost(string provider)
|
||||
public ActionResult SamlPost(string provider)
|
||||
{
|
||||
// I'm sure there's a better way than using nested for loops but eh whatever
|
||||
foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs)
|
||||
SamlConfig config;
|
||||
try
|
||||
{
|
||||
if (config.SamlClientId == provider && config.Enabled)
|
||||
{
|
||||
var samlResponse = new Response(config.SamlCertificate, Request.Form["SAMLResponse"]);
|
||||
// If no roles are configured, don't use RBAC
|
||||
if (config.Roles.Length == 0)
|
||||
{
|
||||
return Content(WebResponse.SamlGenerator(xml: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html);
|
||||
}
|
||||
// Check if user is allowed to log in based on roles
|
||||
foreach (string role in samlResponse.GetCustomAttributes("Role"))
|
||||
{
|
||||
foreach (string allowedRole in config.Roles)
|
||||
{
|
||||
if (allowedRole.Equals(role))
|
||||
{
|
||||
return Content(WebResponse.SamlGenerator(xml: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Content("401 Forbidden"); // TODO: Return error code as well
|
||||
}
|
||||
config = SSOPlugin.Instance.Configuration.SamlConfigs[provider];
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return BadRequest("No matching provider found");
|
||||
}
|
||||
|
||||
return Content("no active providers found"); // TODO: Return error code as well
|
||||
if (config.Enabled)
|
||||
{
|
||||
var samlResponse = new Response(config.SamlCertificate, Request.Form["SAMLResponse"]);
|
||||
// If no roles are configured, don't use RBAC
|
||||
if (config.Roles.Length == 0)
|
||||
{
|
||||
return Content(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML"), MediaTypeNames.Text.Html);
|
||||
}
|
||||
|
||||
// Check if user is allowed to log in based on roles
|
||||
foreach (string role in samlResponse.GetCustomAttributes("Role"))
|
||||
{
|
||||
foreach (string allowedRole in config.Roles)
|
||||
{
|
||||
if (allowedRole.Equals(role))
|
||||
{
|
||||
return Content(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML"), MediaTypeNames.Text.Html);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"SAML user: {UserId} has insufficient roles: {@Roles}. Expected any one of: {@ExpectedRoles}",
|
||||
samlResponse.GetNameID(),
|
||||
samlResponse.GetCustomAttributes("Role"),
|
||||
config.Roles);
|
||||
return ReturnError(StatusCodes.Status401Unauthorized, "Error. Check permissions.");
|
||||
}
|
||||
|
||||
return ReturnError(StatusCodes.Status400BadRequest, "No active providers found");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the SAML flow. This will redirect the user to the SAML provider.
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider to being the flow with.</param>
|
||||
/// <returns>A redirect to the SAML provider's auth page.</returns>
|
||||
[HttpGet("SAML/p/{provider}")]
|
||||
public RedirectResult SAMLChallenge(string provider)
|
||||
public RedirectResult SamlChallenge(string provider)
|
||||
{
|
||||
foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs)
|
||||
SamlConfig config;
|
||||
try
|
||||
{
|
||||
if (config.SamlClientId == provider && config.Enabled)
|
||||
{
|
||||
var request = new AuthRequest(
|
||||
config.SamlClientId,
|
||||
GetRequestBase() + "/sso/SAML/p/" + provider);
|
||||
config = SSOPlugin.Instance.Configuration.SamlConfigs[provider];
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
throw new ArgumentException("Provider does not exist");
|
||||
}
|
||||
|
||||
return Redirect(request.GetRedirectUrl(config.SamlEndpoint));
|
||||
}
|
||||
if (config.Enabled)
|
||||
{
|
||||
var request = new AuthRequest(
|
||||
config.SamlClientId,
|
||||
GetRequestBase() + "/sso/SAML/p/" + provider);
|
||||
|
||||
return Redirect(request.GetRedirectUrl(config.SamlEndpoint));
|
||||
}
|
||||
|
||||
throw new ArgumentException("Provider does not exist");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a SAML configuration. If the provider already exists, overwrite it.
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider name to add.</param>
|
||||
/// <param name="newConfig">The SAML configuration object (deserialized) from JSON.</param>
|
||||
/// <returns>The success result.</returns>
|
||||
[Authorize(Policy = "RequiresElevation")]
|
||||
[HttpPost("SAML/Add")]
|
||||
public void SamlAdd([FromBody] SamlConfig config)
|
||||
[HttpPost("SAML/Add/{provider}")]
|
||||
public OkResult SamlAdd(string provider, [FromBody] SamlConfig newConfig)
|
||||
{
|
||||
var configuration = SSOPlugin.Instance.Configuration;
|
||||
for (var i = 0; i < configuration.SamlConfigs.Count; i++)
|
||||
{
|
||||
if (configuration.SamlConfigs[i].SamlClientId.Equals(config.SamlClientId))
|
||||
{
|
||||
configuration.SamlConfigs.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
configuration.SamlConfigs.Add(config);
|
||||
configuration.SamlConfigs[provider] = newConfig;
|
||||
SSOPlugin.Instance.UpdateConfiguration(configuration);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a provider from the configuration with a given ID.
|
||||
/// </summary>
|
||||
/// <param name="provider">The ID of the provider to delete.</param>
|
||||
/// <returns>The success result.</returns>
|
||||
[Authorize(Policy = "RequiresElevation")]
|
||||
[HttpGet("SAML/Del/{provider}")]
|
||||
public void SamlDel(string provider)
|
||||
public OkResult SamlDel(string provider)
|
||||
{
|
||||
var configuration = SSOPlugin.Instance.Configuration;
|
||||
for (var i = 0; i < configuration.SamlConfigs.Count; i++)
|
||||
{
|
||||
if (configuration.SamlConfigs[i].SamlClientId.Equals(provider))
|
||||
{
|
||||
configuration.SamlConfigs.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
configuration.SamlConfigs.Remove(provider);
|
||||
SSOPlugin.Instance.UpdateConfiguration(configuration);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of all SAML providers configured. Requires administrator privileges.
|
||||
/// </summary>
|
||||
/// <returns>A list of all of the Saml providers available.</returns>
|
||||
[Authorize(Policy = "RequiresElevation")]
|
||||
[HttpGet("SAML/Get")]
|
||||
public ActionResult SamlProviders()
|
||||
@@ -345,52 +463,77 @@ public class SSOController : ControllerBase
|
||||
return Ok(SSOPlugin.Instance.Configuration.SamlConfigs);
|
||||
}
|
||||
|
||||
[HttpPost("SAML/Auth")]
|
||||
/// <summary>
|
||||
/// This endpoint accepts JSON and will authorize the user from the device values passed from the client.
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider to authenticate against.</param>
|
||||
/// <param name="response">The data passed to the client to ensure it is the right one.</param>
|
||||
/// <returns>JSON for the client to populate information with.</returns>
|
||||
[HttpPost("SAML/Auth/{provider}")]
|
||||
[Consumes(MediaTypeNames.Application.Json)]
|
||||
[Produces(MediaTypeNames.Application.Json)]
|
||||
public async Task<ActionResult> SamlAuth([FromBody] AuthResponse response)
|
||||
public async Task<ActionResult> SamlAuth(string provider, [FromBody] AuthResponse response)
|
||||
{
|
||||
foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs)
|
||||
SamlConfig config;
|
||||
try
|
||||
{
|
||||
if (config.SamlClientId == response.Provider && config.Enabled)
|
||||
{
|
||||
bool isAdmin = false;
|
||||
var samlResponse = new Response(config.SamlCertificate, response.Data);
|
||||
List<string> folders;
|
||||
if (!config.EnableFolderRoles)
|
||||
{
|
||||
folders = new List<string>(config.EnabledFolders);
|
||||
} else {
|
||||
folders = new List<string>();
|
||||
}
|
||||
foreach (string role in samlResponse.GetCustomAttributes("Role"))
|
||||
{
|
||||
foreach (string allowedRole in config.AdminRoles)
|
||||
{
|
||||
if (allowedRole.Equals(role))
|
||||
{
|
||||
isAdmin = true;
|
||||
}
|
||||
}
|
||||
config = SSOPlugin.Instance.Configuration.SamlConfigs[provider];
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return BadRequest("No matching provider found");
|
||||
}
|
||||
|
||||
if (config.EnableFolderRoles) {
|
||||
foreach (FolderRoleMap folderRoleMap in config.FolderRoleMapping)
|
||||
if (config.Enabled)
|
||||
{
|
||||
bool isAdmin = false;
|
||||
var samlResponse = new Response(config.SamlCertificate, response.Data);
|
||||
List<string> folders;
|
||||
if (!config.EnableFolderRoles)
|
||||
{
|
||||
folders = new List<string>(config.EnabledFolders);
|
||||
}
|
||||
else
|
||||
{
|
||||
folders = new List<string>();
|
||||
}
|
||||
|
||||
foreach (string role in samlResponse.GetCustomAttributes("Role"))
|
||||
{
|
||||
foreach (string allowedRole in config.AdminRoles)
|
||||
{
|
||||
if (allowedRole.Equals(role))
|
||||
{
|
||||
isAdmin = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.EnableFolderRoles)
|
||||
{
|
||||
foreach (FolderRoleMap folderRoleMap in config.FolderRoleMapping)
|
||||
{
|
||||
if (folderRoleMap.Role.Equals(role))
|
||||
{
|
||||
if (folderRoleMap.Role.Equals(role)) {
|
||||
folders.AddRange(folderRoleMap.Folders);
|
||||
}
|
||||
folders.AddRange(folderRoleMap.Folders);
|
||||
}
|
||||
}
|
||||
}
|
||||
var authenticationResult = await Authenticate(samlResponse.GetNameID(), isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), response)
|
||||
.ConfigureAwait(false);
|
||||
return Ok(authenticationResult);
|
||||
}
|
||||
|
||||
var authenticationResult = await Authenticate(samlResponse.GetNameID(), isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), response, config.DefaultProvider)
|
||||
.ConfigureAwait(false);
|
||||
return Ok(authenticationResult);
|
||||
}
|
||||
|
||||
return Problem("Something went wrong");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a user from SSO auth and switches it back to another auth provider. Requires administrator privileges.
|
||||
/// </summary>
|
||||
/// <param name="username">The username to switch to the new provider.</param>
|
||||
/// <param name="provider">The new provider to switch to.</param>
|
||||
/// <returns>Whether this API endpoint succeeded.</returns>
|
||||
[Authorize(Policy = "RequiresElevation")]
|
||||
[HttpPost("Unregister/{username}")]
|
||||
public ActionResult Unregister(string username, [FromBody] string provider)
|
||||
@@ -401,7 +544,17 @@ public class SSOController : ControllerBase
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private async Task<AuthenticationResult> Authenticate(string username, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse)
|
||||
/// <summary>
|
||||
/// Authenticates the user with the given information.
|
||||
/// </summary>
|
||||
/// <param name="username">The username of the user to authenticate.</param>
|
||||
/// <param name="isAdmin">Determines whether this user is an administrator.</param>
|
||||
/// <param name="enableAuthorization">Determines whether RBAC is used for this user.</param>
|
||||
/// <param name="enableAllFolders">Determines whether all folders are enabled.</param>
|
||||
/// <param name="enabledFolders">Determines which folders should be enabled for this client.</param>
|
||||
/// <param name="authResponse">The client information to authenticate the user with.</param>
|
||||
/// <param name="defaultProvider">The default provider of the user to be set after logging in.</param>
|
||||
private async Task<AuthenticationResult> Authenticate(string username, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse, string defaultProvider)
|
||||
{
|
||||
User user = null;
|
||||
user = _userManager.GetUserByName(username);
|
||||
@@ -410,9 +563,11 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
_logger.LogInformation("SSO user doesn't exist, creating...");
|
||||
user = await _userManager.CreateUserAsync(username).ConfigureAwait(false);
|
||||
user.AuthenticationProviderId = GetType().FullName;
|
||||
}
|
||||
user.AuthenticationProviderId = GetType().FullName;
|
||||
if (enableAuthorization) {
|
||||
|
||||
if (enableAuthorization)
|
||||
{
|
||||
user.SetPermission(PermissionKind.IsAdministrator, isAdmin);
|
||||
user.SetPermission(PermissionKind.EnableAllFolders, enableAllFolders);
|
||||
if (!enableAllFolders)
|
||||
@@ -431,6 +586,13 @@ public class SSOController : ControllerBase
|
||||
authRequest.DeviceId = authResponse.DeviceID;
|
||||
authRequest.DeviceName = authResponse.DeviceName;
|
||||
_logger.LogInformation("Auth request created...");
|
||||
if (!string.IsNullOrEmpty(defaultProvider))
|
||||
{
|
||||
user.AuthenticationProviderId = defaultProvider;
|
||||
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
|
||||
_logger.LogInformation("Set default login provider to " + defaultProvider);
|
||||
}
|
||||
|
||||
return await _sessionManager.AuthenticateDirect(authRequest).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -450,25 +612,58 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
return Request.Scheme + "://" + Request.Host + Request.PathBase;
|
||||
}
|
||||
|
||||
private ContentResult ReturnError(int code, string message)
|
||||
{
|
||||
var errorResult = new ContentResult();
|
||||
errorResult.Content = message;
|
||||
errorResult.ContentType = MediaTypeNames.Text.Plain;
|
||||
errorResult.StatusCode = code;
|
||||
return errorResult;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The data the client should pass back to the API.
|
||||
/// </summary>
|
||||
public class AuthResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the device ID of the client.
|
||||
/// </summary>
|
||||
public string DeviceID { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the device name of the client.
|
||||
/// </summary>
|
||||
public string DeviceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the app name of the client.
|
||||
/// </summary>
|
||||
public string AppName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the app version of the client.
|
||||
/// </summary>
|
||||
public string AppVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the auth data of the client (for authorizing the response).
|
||||
/// </summary>
|
||||
public string Data { get; set; }
|
||||
|
||||
public string Provider { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A manager for OpenID to manage the state of the clients.
|
||||
/// </summary>
|
||||
public class TimedAuthorizeState
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimedAuthorizeState"/> class.
|
||||
/// </summary>
|
||||
/// <param name="state">The AuthorizeState to time.</param>
|
||||
/// <param name="created">When this state was created.</param>
|
||||
public TimedAuthorizeState(AuthorizeState state, DateTime created)
|
||||
{
|
||||
State = state;
|
||||
@@ -477,17 +672,33 @@ public class TimedAuthorizeState
|
||||
Admin = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Authorization State of the client.
|
||||
/// </summary>
|
||||
public AuthorizeState State { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when this object was created to time it out.
|
||||
/// </summary>
|
||||
public DateTime Created { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the user is valid.
|
||||
/// </summary>
|
||||
public bool Valid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user tied to the state.
|
||||
/// </summary>
|
||||
public string Username { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the user is an administrator.
|
||||
/// </summary>
|
||||
public bool Admin { get; set; }
|
||||
|
||||
public string Email { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the folders the user is allowed access to.
|
||||
/// </summary>
|
||||
public List<string> Folders { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,80 +13,183 @@ public class PluginConfiguration : MediaBrowser.Model.Plugins.BasePluginConfigur
|
||||
/// </summary>
|
||||
public PluginConfiguration()
|
||||
{
|
||||
SamlConfigs = new List<SamlConfig>();
|
||||
OIDConfigs = new List<OIDConfig>();
|
||||
SamlConfigs = new SerializableDictionary<string, SamlConfig>();
|
||||
OidConfigs = new SerializableDictionary<string, OidConfig>();
|
||||
}
|
||||
|
||||
[XmlArray("SamlConfigs")]
|
||||
[XmlArrayItem(typeof(SamlConfig), ElementName = "SamlConfigs")]
|
||||
public List<SamlConfig> SamlConfigs { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the SAML configurations available.
|
||||
/// </summary>
|
||||
[XmlElement("SamlConfigs")]
|
||||
public SerializableDictionary<string, SamlConfig> SamlConfigs { get; set; }
|
||||
|
||||
[XmlArray("OIDConfigs")]
|
||||
[XmlArrayItem(typeof(OIDConfig), ElementName = "OIDConfigs")]
|
||||
public List<OIDConfig> OIDConfigs { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the OpenID configurations available.
|
||||
/// </summary>
|
||||
[XmlElement("OidConfigs")]
|
||||
public SerializableDictionary<string, OidConfig> OidConfigs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The configuration required for a SAML flow.
|
||||
/// </summary>
|
||||
[XmlRoot("PluginConfiguration")]
|
||||
public class SamlConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the SAML information endpoint.
|
||||
/// </summary>
|
||||
public string SamlEndpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SAML provider's client ID.
|
||||
/// </summary>
|
||||
public string SamlClientId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SAML public key.
|
||||
/// </summary>
|
||||
public string SamlCertificate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the provider is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether RBAC is enabled.
|
||||
/// </summary>
|
||||
public bool EnableAuthorization { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether all folders are allowed by default.
|
||||
/// </summary>
|
||||
public bool EnableAllFolders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets what folders should users have access to by default.
|
||||
/// </summary>
|
||||
public string[] EnabledFolders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the roles that are checked to determine whether the user is an administrator.
|
||||
/// </summary>
|
||||
public string[] AdminRoles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets what roles are checked to determine whether the user is allowed to use Jellyfin.
|
||||
/// </summary>
|
||||
public string[] Roles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether RBAC is used to manage folder access.
|
||||
/// </summary>
|
||||
public bool EnableFolderRoles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets which folders map to what roles in RBAC.
|
||||
/// </summary>
|
||||
[XmlArray("FolderRoleMappings")]
|
||||
[XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMappings")]
|
||||
public List<FolderRoleMap> FolderRoleMapping { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default provider the user after logging in with SSO.
|
||||
/// </summary>
|
||||
public string DefaultProvider { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The configuration required for a OpenID flow.
|
||||
/// </summary>
|
||||
[XmlRoot("PluginConfiguration")]
|
||||
public class OIDConfig
|
||||
public class OidConfig
|
||||
{
|
||||
public string OIDEndpoint { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the OpenID well-known information endpoint.
|
||||
/// </summary>
|
||||
public string OidEndpoint { get; set; }
|
||||
|
||||
public string OIDClientId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets OpenID client ID.
|
||||
/// </summary>
|
||||
public string OidClientId { get; set; }
|
||||
|
||||
public string OIDSecret { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets OpenID shared secret.
|
||||
/// </summary>
|
||||
public string OidSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the provider is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether RBAC is enabled.
|
||||
/// </summary>
|
||||
public bool EnableAuthorization { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether all folders are allowed by default.
|
||||
/// </summary>
|
||||
public bool EnableAllFolders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets what folders should users have access to by default.
|
||||
/// </summary>
|
||||
public string[] EnabledFolders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the roles that are checked to determine whether the user is an administrator.
|
||||
/// </summary>
|
||||
public string[] AdminRoles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets what roles are checked to determine whether the user is allowed to use Jellyfin.
|
||||
/// </summary>
|
||||
public string[] Roles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether RBAC is used to manage folder access.
|
||||
/// </summary>
|
||||
public bool EnableFolderRoles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets which folders map to what roles in RBAC.
|
||||
/// </summary>
|
||||
[XmlArray("FolderRoleMappings")]
|
||||
[XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMappings")]
|
||||
public List<FolderRoleMap> FolderRoleMapping { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the claim to check roles against. Separated by "."s.
|
||||
/// </summary>
|
||||
public string RoleClaim { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or Sets additional Scopes to request access to in the authorization request.
|
||||
/// </summary>
|
||||
public string[] OidScopes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default provider the user after logging in with SSO.
|
||||
/// </summary>
|
||||
public string DefaultProvider { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The OpenID client ID.
|
||||
/// </summary>
|
||||
public class FolderRoleMap
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the role of the mapping.
|
||||
/// </summary>
|
||||
public string Role { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the folders that are allowed from the given role.
|
||||
/// </summary>
|
||||
public List<string> Folders { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
const ssoConfigurationPage = {
|
||||
pluginUniqueId: "505ce9d1-d916-42fa-86ca-673ef241d7df",
|
||||
loadConfiguration: (page) => {
|
||||
ApiClient.getPluginConfiguration(ssoConfigurationPage.pluginUniqueId).then(
|
||||
(config) => {
|
||||
ssoConfigurationPage.populateProviders(page, config.OidConfigs);
|
||||
}
|
||||
);
|
||||
|
||||
const folder_container = page.querySelector("#EnabledFolders");
|
||||
ssoConfigurationPage.populateFolders(folder_container);
|
||||
},
|
||||
populateProviders: (page, providers) => {
|
||||
// Clear providers in case there are out of date ones
|
||||
page
|
||||
.querySelector("#selectProvider")
|
||||
.querySelectorAll("option")
|
||||
.forEach((option) => {
|
||||
option.remove();
|
||||
});
|
||||
|
||||
// Add providers as options for the selector
|
||||
|
||||
Object.keys(providers).forEach((provider_name) => {
|
||||
var choice = new Option(provider_name, provider_name);
|
||||
|
||||
page.querySelector("#selectProvider").appendChild(choice);
|
||||
});
|
||||
},
|
||||
populateEnabledFolders: (folder_list, container) => {
|
||||
container.querySelectorAll(".folder-checkbox").forEach((e) => {
|
||||
e.checked = folder_list.includes(e.getAttribute("data-id"));
|
||||
});
|
||||
},
|
||||
serializeEnabledFolders: (container) => {
|
||||
return [...container.querySelectorAll(".folder-checkbox")]
|
||||
.filter((e) => e.checked)
|
||||
.map((e) => {
|
||||
return e.getAttribute("data-id");
|
||||
});
|
||||
},
|
||||
populateFolders: (container) => {
|
||||
return ApiClient.getJSON(
|
||||
ApiClient.getUrl("Library/MediaFolders", {
|
||||
IsHidden: false,
|
||||
})
|
||||
).then((folders) => {
|
||||
ssoConfigurationPage._populateFolders(container, folders);
|
||||
});
|
||||
},
|
||||
/*
|
||||
container: html element
|
||||
folders.Items: array of objects, with .Id & .Name
|
||||
*/
|
||||
_populateFolders: (container, folders) => {
|
||||
container
|
||||
.querySelectorAll(".emby-checkbox-label")
|
||||
.forEach((e) => e.remove());
|
||||
|
||||
const checkboxes = folders.Items.map((folder) => {
|
||||
var out = document.createElement("label");
|
||||
|
||||
out.innerHTML = `
|
||||
<input
|
||||
is="emby-checkbox"
|
||||
class="folder-checkbox chkFolder"
|
||||
data-id="${folder.Id}"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>${folder.Name}</span>
|
||||
`;
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
checkboxes.forEach((e) => {
|
||||
container.appendChild(e);
|
||||
});
|
||||
},
|
||||
|
||||
populateRoleMappings: (folder_role_mappings, container) => {
|
||||
container
|
||||
.querySelectorAll(".sso-role-mapping-container")
|
||||
.forEach((e) => e.remove());
|
||||
|
||||
const mapping_elements = folder_role_mappings.map((mapping) => {
|
||||
var elem = document.createElement("div");
|
||||
|
||||
elem.classList.add("sso-role-mapping-container");
|
||||
elem.innerHTML = `
|
||||
<label
|
||||
class="inputLabel inputLabelUnfocused sso-role-mapping-input-label"
|
||||
>Role:</label>
|
||||
<div class="listItem">
|
||||
<input
|
||||
is="emby-input"
|
||||
required=""
|
||||
type="text"
|
||||
class="listItemBody sso-role-mapping-name"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
is="paper-icon-button-light"
|
||||
class="listItemButton sso-remove-role-mapping"
|
||||
>
|
||||
<span class="material-icons remove_circle" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="checkboxList paperList sso-folder-list"
|
||||
></div>
|
||||
`;
|
||||
|
||||
var checklist = elem.querySelector(".sso-folder-list");
|
||||
const enabled_folders = mapping["Folders"];
|
||||
|
||||
ssoConfigurationPage
|
||||
.populateFolders(checklist)
|
||||
.then(() =>
|
||||
ssoConfigurationPage.populateEnabledFolders(
|
||||
enabled_folders,
|
||||
checklist
|
||||
)
|
||||
);
|
||||
|
||||
elem.querySelector(".sso-role-mapping-name").value = mapping["Role"];
|
||||
elem
|
||||
.querySelector(".sso-remove-role-mapping")
|
||||
.addEventListener(
|
||||
"click",
|
||||
ssoConfigurationPage.handleRoleMappingRemove
|
||||
);
|
||||
|
||||
return elem;
|
||||
});
|
||||
|
||||
mapping_elements.forEach((e) => container.appendChild(e));
|
||||
},
|
||||
serializeRoleMappings: (container) => {
|
||||
var out = [];
|
||||
const roles = [
|
||||
...container.querySelectorAll(".sso-role-mapping-container"),
|
||||
].forEach((elem) => {
|
||||
const role = elem.querySelector(".sso-role-mapping-name").value;
|
||||
const checklist = elem.querySelector(".sso-folder-list");
|
||||
|
||||
out.push({
|
||||
Role: role,
|
||||
Folders: ssoConfigurationPage.serializeEnabledFolders(checklist),
|
||||
});
|
||||
});
|
||||
|
||||
return out;
|
||||
},
|
||||
handleRoleMappingRemove: (evt) => {
|
||||
const targeted_mapping = evt.target.closest(".sso-role-mapping-container");
|
||||
targeted_mapping.remove();
|
||||
},
|
||||
listArgumentsByType: (page) => {
|
||||
const json_class = ".sso-json";
|
||||
const toggle_class = ".sso-toggle";
|
||||
const text_class = ".sso-text";
|
||||
const text_list_class = ".sso-line-list";
|
||||
|
||||
const folder_list_fields = ["EnabledFolders"];
|
||||
const role_map_fields = ["FolderRoleMapping"];
|
||||
|
||||
const oidc_form = page.querySelector("#sso-new-oidc-provider");
|
||||
|
||||
const text_fields = [...oidc_form.querySelectorAll(text_class)].map(
|
||||
(e) => e.id
|
||||
);
|
||||
|
||||
const json_fields = [...oidc_form.querySelectorAll(json_class)].map(
|
||||
(e) => e.id
|
||||
);
|
||||
|
||||
const text_list_fields = [
|
||||
...oidc_form.querySelectorAll(text_list_class),
|
||||
].map((e) => e.id);
|
||||
|
||||
const check_fields = [...oidc_form.querySelectorAll(toggle_class)].map(
|
||||
(e) => e.id
|
||||
);
|
||||
|
||||
const output = {
|
||||
json_fields,
|
||||
text_list_fields,
|
||||
text_fields,
|
||||
check_fields,
|
||||
folder_list_fields,
|
||||
role_map_fields,
|
||||
};
|
||||
|
||||
return output;
|
||||
},
|
||||
fillTextList: (text_list, element) => {
|
||||
// text_list is an array of strings
|
||||
// element is an input element
|
||||
const val = text_list.join("\r\n");
|
||||
element.value = val;
|
||||
},
|
||||
parseTextList: (element) => {
|
||||
// Return the parsed text list
|
||||
var out = element.value
|
||||
.split("\n")
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e);
|
||||
return out;
|
||||
},
|
||||
loadProvider: (page, provider_name) => {
|
||||
ApiClient.getPluginConfiguration(ssoConfigurationPage.pluginUniqueId).then(
|
||||
(config) => {
|
||||
var provider = config.OidConfigs[provider_name] || {};
|
||||
|
||||
const form_elements = ssoConfigurationPage.listArgumentsByType(page);
|
||||
|
||||
page.querySelector("#OidProviderName").value = provider_name;
|
||||
|
||||
form_elements.text_fields.forEach((id) => {
|
||||
if (provider[id]) page.querySelector("#" + id).value = provider[id];
|
||||
});
|
||||
|
||||
form_elements.json_fields.forEach((id) => {
|
||||
if (provider[id])
|
||||
page.querySelector("#" + id).value = JSON.stringify(provider[id]);
|
||||
});
|
||||
|
||||
form_elements.text_list_fields.forEach((id) => {
|
||||
if (provider[id])
|
||||
ssoConfigurationPage.fillTextList(
|
||||
provider[id],
|
||||
page.querySelector("#" + id)
|
||||
);
|
||||
});
|
||||
|
||||
form_elements.folder_list_fields.forEach((id) => {
|
||||
if (provider[id]) {
|
||||
ssoConfigurationPage.populateEnabledFolders(
|
||||
provider[id],
|
||||
page.querySelector(`#${id}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
form_elements.check_fields.forEach((id) => {
|
||||
if (provider[id]) page.querySelector("#" + id).checked = provider[id];
|
||||
});
|
||||
|
||||
form_elements.role_map_fields.forEach((id) => {
|
||||
const elem = page.querySelector(`#${id}`);
|
||||
if (provider[id])
|
||||
ssoConfigurationPage.populateRoleMappings(provider[id], elem);
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
deleteProvider: (page, provider_name) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Are you sure you want to delete the provider ${provider_name}?`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
ApiClient.getPluginConfiguration(
|
||||
ssoConfigurationPage.pluginUniqueId
|
||||
).then((config) => {
|
||||
if (!config.OidConfigs.hasOwnProperty(provider_name)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
delete config.OidConfigs[provider_name];
|
||||
ApiClient.updatePluginConfiguration(
|
||||
ssoConfigurationPage.pluginUniqueId,
|
||||
config
|
||||
).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
ssoConfigurationPage.loadConfiguration(page);
|
||||
|
||||
Dashboard.alert("Provider removed");
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
saveProvider: (page, provider_name) => {
|
||||
return new Promise((resolve) => {
|
||||
const form_elements = ssoConfigurationPage.listArgumentsByType(page);
|
||||
|
||||
ApiClient.getPluginConfiguration(
|
||||
ssoConfigurationPage.pluginUniqueId
|
||||
).then((config) => {
|
||||
var current_config = {};
|
||||
if (config.OidConfigs.hasOwnProperty(provider_name)) {
|
||||
current_config = config.OidConfigs[provider_name];
|
||||
}
|
||||
|
||||
form_elements.text_fields.forEach((id) => {
|
||||
const value = page.querySelector("#" + id).value;
|
||||
if (value) current_config[id] = page.querySelector("#" + id).value;
|
||||
});
|
||||
|
||||
form_elements.json_fields.forEach((id) => {
|
||||
const value = page.querySelector("#" + id).value;
|
||||
if (value) current_config[id] = JSON.parse(value);
|
||||
});
|
||||
|
||||
form_elements.check_fields.forEach((id) => {
|
||||
current_config[id] = page.querySelector("#" + id).checked;
|
||||
});
|
||||
|
||||
form_elements.text_list_fields.forEach((id) => {
|
||||
current_config[id] = ssoConfigurationPage.parseTextList(
|
||||
page.querySelector("#" + id)
|
||||
);
|
||||
});
|
||||
|
||||
form_elements.folder_list_fields.forEach((id) => {
|
||||
const elem = page.querySelector(`#${id}`);
|
||||
current_config[id] =
|
||||
ssoConfigurationPage.serializeEnabledFolders(elem);
|
||||
});
|
||||
|
||||
form_elements.role_map_fields.forEach((id) => {
|
||||
const elem = page.querySelector(`#${id}`);
|
||||
current_config[id] = ssoConfigurationPage.serializeRoleMappings(elem);
|
||||
});
|
||||
|
||||
config.OidConfigs[provider_name] = current_config;
|
||||
|
||||
ApiClient.updatePluginConfiguration(
|
||||
ssoConfigurationPage.pluginUniqueId,
|
||||
config
|
||||
).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
ssoConfigurationPage.loadConfiguration(page);
|
||||
ssoConfigurationPage.loadProvider(page, provider_name);
|
||||
|
||||
page.querySelector("#selectProvider").value = provider_name;
|
||||
Dashboard.alert("Settings saved.");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
addTextAreaStyle: (view) => {
|
||||
var style = document.createElement("link");
|
||||
style.rel = "stylesheet";
|
||||
style.href =
|
||||
ApiClient.getUrl("web/configurationpage") + "?name=SSO-Auth.css";
|
||||
view.appendChild(style);
|
||||
},
|
||||
};
|
||||
|
||||
export default function (view) {
|
||||
ssoConfigurationPage.addTextAreaStyle(view);
|
||||
ssoConfigurationPage.loadConfiguration(view);
|
||||
|
||||
ssoConfigurationPage.listArgumentsByType(view);
|
||||
|
||||
view.querySelector("#SaveProvider").addEventListener("click", (e) => {
|
||||
const target_provider = view.querySelector("#OidProviderName").value;
|
||||
|
||||
ssoConfigurationPage.saveProvider(view, target_provider);
|
||||
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
|
||||
view.querySelector("#LoadProvider").addEventListener("click", (e) => {
|
||||
const target_provider = view.querySelector("#selectProvider").value;
|
||||
|
||||
ssoConfigurationPage.loadProvider(view, target_provider);
|
||||
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
|
||||
view.querySelector("#DeleteProvider").addEventListener("click", (e) => {
|
||||
const target_provider = view.querySelector("#selectProvider").value;
|
||||
|
||||
ssoConfigurationPage.deleteProvider(view, target_provider);
|
||||
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
|
||||
view.querySelector("#AddRoleMapping").addEventListener("click", (e) => {
|
||||
const container = view.querySelector("#FolderRoleMapping");
|
||||
const current_mappings =
|
||||
ssoConfigurationPage.serializeRoleMappings(container);
|
||||
current_mappings.push({ Role: "", Folders: [] });
|
||||
console.log(current_mappings);
|
||||
ssoConfigurationPage.populateRoleMappings(current_mappings, container);
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,438 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<head>
|
||||
<title>SSO</title>
|
||||
</head>
|
||||
<body>
|
||||
<div data-role="page" class="page type-interior pluginConfigurationPage esqConfigurationPage">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<a href="https://github.com/9p4/jellyfin-plugin-sso/blob/main/README.md">Review the documentation. This plugin is configured via the API.</a>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="sso-config-page"
|
||||
data-role="page"
|
||||
class="page type-interior pluginConfigurationPage esqConfigurationPage"
|
||||
data-controller="__plugin/SSO-Auth.js"
|
||||
>
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<div class="sectionTitleContainer flex align-items-center">
|
||||
<h2 class="sectionTitle">SSO Settings:</h2>
|
||||
<a
|
||||
is="emby-button"
|
||||
class="raised button-alt headerHelpButton"
|
||||
target="_blank"
|
||||
href="https://github.com/9p4/jellyfin-plugin-sso"
|
||||
>${Help}</a
|
||||
>
|
||||
</div>
|
||||
<p>
|
||||
<i>Note:</i>
|
||||
Making changes to this configuration requires a restart of Jellyfin.
|
||||
<br />
|
||||
This plug-in is in early development, not all configuration options
|
||||
have been implented in the UI, for example, SAML provider
|
||||
configuration has not been implemented.
|
||||
<br />
|
||||
See the
|
||||
<a
|
||||
is="emby-linkbutton"
|
||||
href="https://github.com/9p4/jellyfin-plugin-sso"
|
||||
class="button-link"
|
||||
>help page</a
|
||||
>
|
||||
and
|
||||
<a
|
||||
is="emby-linkbutton"
|
||||
href="https://github.com/9p4/jellyfin-plugin-sso/projects/1"
|
||||
class="button-link"
|
||||
>roadmap
|
||||
</a>
|
||||
for more information.
|
||||
</p>
|
||||
|
||||
<form id="sso-load-config" class="esqConfigurationForm">
|
||||
<div
|
||||
class="verticalSection"
|
||||
is="emby-collapse"
|
||||
title="Select Existing Provider to Modify"
|
||||
>
|
||||
<div class="collapseContent">
|
||||
<div class="selectContainer">
|
||||
<label class="selectLabel" for="selectProvider"
|
||||
>Name of OID Provider:
|
||||
</label>
|
||||
<select
|
||||
is="emby-select"
|
||||
id="selectProvider"
|
||||
name="selectProvider"
|
||||
class="emby-select-withcolor emby-select"
|
||||
></select>
|
||||
<div class="selectArrowContainer">
|
||||
<div style="visibility: hidden; display: none">0</div>
|
||||
<span
|
||||
class="selectArrow material-icons keyboard_arrow_down"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="LoadProvider"
|
||||
is="emby-button"
|
||||
type="button"
|
||||
class="raised button-submit block emby-button"
|
||||
>
|
||||
<span>Load Provider</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="DeleteProvider"
|
||||
is="emby-button"
|
||||
type="button"
|
||||
class="raised button-delete block emby-button"
|
||||
>
|
||||
<span>Delete Provider</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="sso-new-oidc-provider" class="esqConfigurationForm">
|
||||
<div
|
||||
is="emby-collapse"
|
||||
data-expanded="true"
|
||||
title="Add / Update Provider Configuration"
|
||||
class="verticalSection verticalSection-extrabottompadding"
|
||||
>
|
||||
<div class="collapseContent">
|
||||
<div class="inputContainer">
|
||||
<label
|
||||
class="inputLabel inputLabelUnfocused"
|
||||
for="OidProviderName"
|
||||
>Name of OID Provider:</label
|
||||
>
|
||||
<input
|
||||
is="emby-input"
|
||||
id="OidProviderName"
|
||||
required=""
|
||||
type="text"
|
||||
class="sso-text"
|
||||
/>
|
||||
<div class="fieldDescription">
|
||||
The name used by Jellyfin to identify the OID provider.
|
||||
<br />
|
||||
If an OID provider with a matching name does not exist, a
|
||||
new provider with this name will be created.
|
||||
<br />
|
||||
If an OID provider with a matching name already exists, the
|
||||
settings for that provider will be updated.
|
||||
</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label
|
||||
class="inputLabel inputLabelUnfocused"
|
||||
for="OidEndpoint"
|
||||
>OID Endpoint:</label
|
||||
>
|
||||
<input
|
||||
is="emby-input"
|
||||
id="OidEndpoint"
|
||||
required=""
|
||||
type="text"
|
||||
class="sso-text"
|
||||
/>
|
||||
<div class="fieldDescription">
|
||||
The OpenID endpoint. Must have a .well-known path available.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label
|
||||
class="inputLabel inputLabelUnfocused"
|
||||
for="OidClientId"
|
||||
>OpenID Client ID:</label
|
||||
>
|
||||
<input
|
||||
is="emby-input"
|
||||
id="OidClientId"
|
||||
required=""
|
||||
type="text"
|
||||
class="sso-text"
|
||||
/>
|
||||
<div class="fieldDescription">
|
||||
The OpenID client ID, for this media server instance. This
|
||||
is configured on the OIDC provider to uniquely identify
|
||||
<strong>this</strong> Jellyfin instance.
|
||||
</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="OidSecret"
|
||||
>OID Secret:</label
|
||||
>
|
||||
<input
|
||||
is="emby-input"
|
||||
id="OidSecret"
|
||||
required=""
|
||||
type="text"
|
||||
class="sso-text"
|
||||
/>
|
||||
<div class="fieldDescription">
|
||||
The OpenID secret. Randomly generated & shared.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="checkboxContainer checkboxContainer-withDescription"
|
||||
>
|
||||
<label>
|
||||
<input
|
||||
is="emby-checkbox"
|
||||
id="Enabled"
|
||||
name="Enabled"
|
||||
type="checkbox"
|
||||
class="sso-toggle"
|
||||
/>
|
||||
<span>Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="checkboxContainer checkboxContainer-withDescription"
|
||||
>
|
||||
<label>
|
||||
<input
|
||||
is="emby-checkbox"
|
||||
id="EnableAuthorization"
|
||||
name="EnableAuthorization"
|
||||
type="checkbox"
|
||||
class="sso-toggle"
|
||||
/>
|
||||
<span>Enable Authorization by Plugin</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">
|
||||
Determines if the plugin sets permissions for the user.
|
||||
<br />
|
||||
If false, the user will start with no permissions and an
|
||||
administrator will add permissions.
|
||||
<br />
|
||||
The permissions of existing users will not be rewritten on
|
||||
subsequent logins.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="checkboxContainer checkboxContainer-withDescription"
|
||||
>
|
||||
<label>
|
||||
<input
|
||||
is="emby-checkbox"
|
||||
id="EnableAllFolders"
|
||||
name="EnableAllFolders"
|
||||
type="checkbox"
|
||||
class="sso-toggle"
|
||||
/>
|
||||
<span>Enable All Folders</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">
|
||||
If enabled, all libraries will be accessible to any user
|
||||
that logs in through this provider.
|
||||
</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label
|
||||
class="inputLabel inputLabelUnfocused"
|
||||
for="EnabledFolders"
|
||||
>Enabled Folders:</label
|
||||
>
|
||||
<div
|
||||
id="EnabledFolders"
|
||||
class="checkboxList paperList checkboxList-paperList sso-folder-list sso-bordered-list"
|
||||
></div>
|
||||
<div class="fieldDescription">
|
||||
Determines which libraries will be accessible to a user that
|
||||
logs in through this provider.
|
||||
<br />
|
||||
If <strong>"Enable All Folders"</strong> is checked, then
|
||||
this has no effect.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="Roles"
|
||||
>Roles:</label
|
||||
>
|
||||
<textarea
|
||||
is="emby-textarea"
|
||||
id="Roles"
|
||||
type="text"
|
||||
class="sso-line-list emby-textarea"
|
||||
></textarea>
|
||||
<div class="fieldDescription">
|
||||
A list of roles, one role per-line to look for in the OpenID
|
||||
response.
|
||||
<br />
|
||||
If a user has any of these roles, then the user is
|
||||
authenticated. This validates the OpenID response against
|
||||
the claim set in <strong>"RoleClaim"</strong>.
|
||||
<br />
|
||||
Leave blank to disable role checking.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="AdminRoles"
|
||||
>Admin Roles:</label
|
||||
>
|
||||
<textarea
|
||||
is="emby-textarea"
|
||||
id="AdminRoles"
|
||||
type="text"
|
||||
class="sso-line-list emby-textarea"
|
||||
></textarea>
|
||||
<div class="fieldDescription">
|
||||
A list of roles, one role per-line to look for in the OpenID
|
||||
response.
|
||||
<br />
|
||||
Like <strong>"Roles"</strong>, but having any of the roles
|
||||
confers admin privilege.
|
||||
<br />
|
||||
If unset will not grant admin privileges.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="checkboxContainer checkboxContainer-withDescription"
|
||||
>
|
||||
<label>
|
||||
<input
|
||||
is="emby-checkbox"
|
||||
id="EnableFolderRoles"
|
||||
name="EnableFolderRoles"
|
||||
type="checkbox"
|
||||
class="sso-toggle"
|
||||
/>
|
||||
<span>Enable Role-Based Folder Access:</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">
|
||||
Determines if user roles should be used to control library
|
||||
access.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label
|
||||
class="inputLabel inputLabelUnfocused"
|
||||
for="FolderRoleMapping"
|
||||
>Folder Role Mapping:</label
|
||||
>
|
||||
<button
|
||||
is="emby-button"
|
||||
id="AddRoleMapping"
|
||||
type="button"
|
||||
class="fab btnAddFolder submit"
|
||||
title="${Add}"
|
||||
>
|
||||
<span class="material-icons add" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div id="FolderRoleMapping" class="sso-role-map"></div>
|
||||
<div class="fieldDescription">
|
||||
Map roles (given by <strong>"Role Claim"</strong>) to lists
|
||||
of libraries. If a user has a given role, they will have
|
||||
access to the corresponding libraries. If
|
||||
<strong>"Enable Role-Based Folder Access"</strong> is
|
||||
disabled, has no effect.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="RoleClaim"
|
||||
>Role Claim:</label
|
||||
>
|
||||
<input
|
||||
is="emby-input"
|
||||
id="RoleClaim"
|
||||
required=""
|
||||
type="text"
|
||||
class="sso-text"
|
||||
/>
|
||||
<div class="fieldDescription">
|
||||
This is the value in the OpenID response to check for roles.
|
||||
The first element is the claim type, the subsequent values
|
||||
are to parse the JSON of the claim value. Use a
|
||||
<code>"\."</code> to denote a literal ".". This expects a
|
||||
list of strings from the OIDC server.
|
||||
<br />
|
||||
For Keycloak, it is <code>realm_access.roles</code> by
|
||||
default.
|
||||
<br />
|
||||
For Authelia, it is <code>groups</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="OidScopes"
|
||||
>Request Additional Scopes:</label
|
||||
>
|
||||
<textarea
|
||||
is="emby-textarea"
|
||||
id="OidScopes"
|
||||
required=""
|
||||
type="text"
|
||||
class="sso-line-list emby-textarea"
|
||||
></textarea>
|
||||
<div class="fieldDescription">
|
||||
Specify additional scopes to include in the OIDC request.
|
||||
<br />
|
||||
One scope per line, each line should contain a scope name to
|
||||
include in the OIDC request.
|
||||
<br />
|
||||
For some OIDC providers (For example,
|
||||
<a
|
||||
is="emby-linkbutton"
|
||||
href="https://github.com/9p4/jellyfin-plugin-sso/issues/23#issuecomment-1112237616"
|
||||
class="button-link"
|
||||
>authelia</a
|
||||
>), additional scopes may be required in order to validate
|
||||
group membership in role claim.
|
||||
<br />
|
||||
Leave blank to only request the default scopes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label
|
||||
class="inputLabel inputLabelUnfocused"
|
||||
for="DefaultProvider"
|
||||
>Set default Provider:</label
|
||||
>
|
||||
<input
|
||||
is="emby-input"
|
||||
id="DefaultProvider"
|
||||
type="text"
|
||||
class="sso-text"
|
||||
/>
|
||||
<div class="fieldDescription">
|
||||
The set provider then gets assigned to the user after they
|
||||
have logged in. If it is not set, nothing is changed. With
|
||||
this, a user can login with SSO but is still able to log in
|
||||
via other providers later.<br />A common option is
|
||||
<code
|
||||
>Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider</code
|
||||
>
|
||||
for the default provider.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="SaveProvider"
|
||||
is="emby-button"
|
||||
type="button"
|
||||
class="raised button-submit block emby-button"
|
||||
>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
.emby-textarea {
|
||||
display: block;
|
||||
margin: 0;
|
||||
margin-bottom: 0 !important;
|
||||
|
||||
/* Remove select styling */
|
||||
|
||||
/* Font size must the 16px or larger to prevent iOS page zoom on focus */
|
||||
font-size: inherit;
|
||||
|
||||
/* General select styles: change as needed */
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
padding: 0.35em 0.25em;
|
||||
|
||||
/* Prevent padding from causing width overflow */
|
||||
box-sizing: border-box;
|
||||
outline: none !important;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.emby-textarea::-moz-focus-inner {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.textareaLabel {
|
||||
display: inline-block;
|
||||
transition: all 0.2s ease-out;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.emby-textarea + .fieldDescription {
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
.sso-role-mapping-container,
|
||||
.sso-bordered-list {
|
||||
/*
|
||||
border-color: #101010;
|
||||
border-color: #383838;
|
||||
*/
|
||||
border-color: rgba(255, 255, 255, 0.135);
|
||||
|
||||
padding-top: 0.5em;
|
||||
border-style: solid;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
.sso-role-mapping-container + .sso-role-mapping-container,
|
||||
.sso-bordered-list + .sso-bordered-list {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.sso-role-mapping-container .sso-folder-list {
|
||||
padding-left: 1em;
|
||||
padding-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.sso-role-mapping-input-label {
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
@@ -3,22 +3,26 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.SSO_Auth</RootNamespace>
|
||||
<AssemblyVersion>3.0.0.0</AssemblyVersion>
|
||||
<FileVersion>3.0.0.0</FileVersion>
|
||||
<AssemblyVersion>3.3.0.0</AssemblyVersion>
|
||||
<FileVersion>3.3.0.0</FileVersion>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Config\configPage.html" />
|
||||
<None Remove="Config\config.js" />
|
||||
<None Remove="Config\style.css" />
|
||||
<EmbeddedResource Include="Config\configPage.html" />
|
||||
<EmbeddedResource Include="Config\config.js" />
|
||||
<EmbeddedResource Include="Config\style.css" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="IdentityModel.OidcClient" Version="5.0.0" />
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.*-*" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.*-*" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<PackageReference Include="System.Security.Cryptography.Xml" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
+37
-3
@@ -8,26 +8,60 @@ using MediaBrowser.Model.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SSO_Auth;
|
||||
|
||||
/// <summary>
|
||||
/// The SSO plugin class.
|
||||
/// </summary>
|
||||
public class SSOPlugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SSOPlugin"/> class.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">Internal Jellyfin interface for the ApplicationPath.</param>
|
||||
/// <param name="xmlSerializer">Internal Jellyfin interface for the XML information.</param>
|
||||
public SSOPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the instance of the SSO plugin.
|
||||
/// </summary>
|
||||
public static SSOPlugin Instance { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the SSO plugin.
|
||||
/// </summary>
|
||||
public override string Name => "SSO-Auth";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the GUID of the SSO plugin.
|
||||
/// </summary>
|
||||
public override Guid Id => Guid.Parse("505ce9d1-d916-42fa-86ca-673ef241d7df");
|
||||
|
||||
/// <summary>
|
||||
/// Returns the available internal web pages of this plugin.
|
||||
/// </summary>
|
||||
/// <returns>A list of internal webpages in this application.</returns>
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
yield return new PluginPageInfo
|
||||
return new[]
|
||||
{
|
||||
Name = Name,
|
||||
EmbeddedResourcePath = $"{GetType().Namespace}.Config.configPage.html"
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name,
|
||||
EmbeddedResourcePath = $"{GetType().Namespace}.Config.configPage.html"
|
||||
},
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name + ".js",
|
||||
EmbeddedResourcePath = $"{GetType().Namespace}.Config.config.js"
|
||||
},
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name + ".css",
|
||||
EmbeddedResourcePath = $"{GetType().Namespace}.Config.style.css"
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,33 +18,61 @@ using System.Xml;
|
||||
|
||||
namespace Jellyfin.Plugin.SSO_Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SAML response.
|
||||
/// </summary>
|
||||
public class Response
|
||||
{
|
||||
private readonly X509Certificate2 _certificate;
|
||||
private XmlDocument _xmlDoc;
|
||||
private XmlNamespaceManager _xmlNameSpaceManager; // we need this one to run our XPath queries on the SAML XML
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Response"/> class.
|
||||
/// </summary>
|
||||
/// <param name="certificateStr">The certificate formatted as a Base64 string.</param>
|
||||
/// <param name="responseString">The SAML response formatted as a string.</param>
|
||||
public Response(string certificateStr, string responseString)
|
||||
: this(Convert.FromBase64String(certificateStr), responseString)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Response"/> class.
|
||||
/// </summary>
|
||||
/// <param name="certificateBytes">The certificate formatted as an array of bytes.</param>
|
||||
/// <param name="responseString">The SAML response formatted as a string.</param>
|
||||
public Response(byte[] certificateBytes, string responseString) : this(certificateBytes)
|
||||
{
|
||||
LoadXmlFromBase64(responseString);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Response"/> class.
|
||||
/// </summary>
|
||||
/// <param name="certificateStr">The certificate formatted as a Base64 string.</param>
|
||||
public Response(string certificateStr) : this(Convert.FromBase64String(certificateStr))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Response"/> class.
|
||||
/// </summary>
|
||||
/// <param name="certificateBytes">The certificate formatted as an array of bytes.</param>
|
||||
public Response(byte[] certificateBytes)
|
||||
{
|
||||
_certificate = new X509Certificate2(certificateBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SAML response's XML data.
|
||||
/// </summary>
|
||||
public string Xml => _xmlDoc.OuterXml;
|
||||
|
||||
/// <summary>
|
||||
/// Loads XML from the parameter into the instance's XML data.
|
||||
/// </summary>
|
||||
/// <param name="xml">The XML string to put into the class.</param>
|
||||
public void LoadXml(string xml)
|
||||
{
|
||||
_xmlDoc = new XmlDocument();
|
||||
@@ -55,11 +83,19 @@ public class Response
|
||||
_xmlNameSpaceManager = GetNamespaceManager(); // lets construct a "manager" for XPath queries
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads Base64 encoded XML from the parameter into the instance's XML data.
|
||||
/// </summary>
|
||||
/// <param name="response">The Base64 encoded XML string to put into the class.</param>
|
||||
public void LoadXmlFromBase64(string response)
|
||||
{
|
||||
LoadXml(Encoding.UTF8.GetString(Convert.FromBase64String(response)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the XML response is valid by verifying the signature.
|
||||
/// </summary>
|
||||
/// <returns>Whether the XML response is valid.</returns>
|
||||
public bool IsValid()
|
||||
{
|
||||
var nodeList = _xmlDoc.SelectNodes("//ds:Signature", _xmlNameSpaceManager);
|
||||
@@ -118,17 +154,29 @@ public class Response
|
||||
return DateTime.UtcNow > expirationDate.ToUniversalTime();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name ID attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The name ID attribute.</returns>
|
||||
public string GetNameID()
|
||||
{
|
||||
var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:Subject/saml:NameID", _xmlNameSpaceManager);
|
||||
return node.InnerText;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the UPN attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The UPN attribute.</returns>
|
||||
public virtual string GetUpn()
|
||||
{
|
||||
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the email attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The email attribute.</returns>
|
||||
public virtual string GetEmail()
|
||||
{
|
||||
return GetCustomAttribute("User.email")
|
||||
@@ -138,6 +186,10 @@ public class Response
|
||||
?? GetCustomAttribute("mail");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the First Name attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The First Name attribute.</returns>
|
||||
public virtual string GetFirstName()
|
||||
{
|
||||
return GetCustomAttribute("first_name")
|
||||
@@ -148,6 +200,10 @@ public class Response
|
||||
?? GetCustomAttribute("givenName");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Last Name attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The Last Name attribute.</returns>
|
||||
public virtual string GetLastName()
|
||||
{
|
||||
return GetCustomAttribute("last_name")
|
||||
@@ -158,18 +214,30 @@ public class Response
|
||||
?? GetCustomAttribute("sn");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the department attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The department attribute.</returns>
|
||||
public virtual string GetDepartment()
|
||||
{
|
||||
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department")
|
||||
?? GetCustomAttribute("department");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the phone attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The phone attribute.</returns>
|
||||
public virtual string GetPhone()
|
||||
{
|
||||
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/homephone")
|
||||
?? GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/telephonenumber");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the company attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The company attribute.</returns>
|
||||
public virtual string GetCompany()
|
||||
{
|
||||
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/companyname")
|
||||
@@ -177,18 +245,32 @@ public class Response
|
||||
?? GetCustomAttribute("User.CompanyName");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the location attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <returns>The location attribute.</returns>
|
||||
public virtual string GetLocation()
|
||||
{
|
||||
return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/location")
|
||||
?? GetCustomAttribute("physicalDeliveryOfficeName");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the first custom attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <param name="attr">The custom attribute to query.</param>
|
||||
/// <returns>The custom attribute.</returns>
|
||||
public string GetCustomAttribute(string attr)
|
||||
{
|
||||
var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager);
|
||||
return node?.InnerText;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the values for a custom attribute from the XML response.
|
||||
/// </summary>
|
||||
/// <param name="attr">The custom attribute to query.</param>
|
||||
/// <returns>The custom attributes.</returns>
|
||||
public List<string> GetCustomAttributes(string attr)
|
||||
{
|
||||
var node = _xmlDoc.SelectNodes("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager);
|
||||
@@ -197,6 +279,7 @@ public class Response
|
||||
{
|
||||
output.Add(item?.InnerText);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
@@ -213,6 +296,9 @@ public class Response
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SAML request.
|
||||
/// </summary>
|
||||
public class AuthRequest
|
||||
{
|
||||
private readonly string _id;
|
||||
@@ -221,6 +307,11 @@ public class AuthRequest
|
||||
private readonly string _issuer;
|
||||
private readonly string _assertionConsumerServiceUrl;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AuthRequest"/> class..
|
||||
/// </summary>
|
||||
/// <param name="issuer">The issuer of the SAML request.</param>
|
||||
/// <param name="assertionConsumerServiceUrl">The SAML assertion URL.</param>
|
||||
public AuthRequest(string issuer, string assertionConsumerServiceUrl)
|
||||
{
|
||||
_id = "_" + Guid.NewGuid().ToString();
|
||||
@@ -230,6 +321,9 @@ public class AuthRequest
|
||||
_assertionConsumerServiceUrl = assertionConsumerServiceUrl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The formatting of the AuthRequest.
|
||||
/// </summary>
|
||||
public enum AuthRequestFormat
|
||||
{
|
||||
/// <summary>
|
||||
@@ -238,6 +332,11 @@ public class AuthRequest
|
||||
Base64 = 1
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SAML request.
|
||||
/// </summary>
|
||||
/// <param name="format">The format the request should be returned in.</param>
|
||||
/// <returns>The request as a string, either Base64 or not, depending on the format parameter.</returns>
|
||||
public string GetRequest(AuthRequestFormat format)
|
||||
{
|
||||
using var sw = new StringWriter();
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Xml.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// For some reason, the generic Dictionary in .net 2.0 is not XML serializable. The following code snippet is a xml serializable generic dictionary. The dictionary is serializable by implementing the IXmlSerializable interface.
|
||||
/// Also see https://weblogs.asp.net/pwelter34/444961 for additional information.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">Type of the dictionary key.</typeparam>
|
||||
/// <typeparam name="TValue">Type of the dictionary value.</typeparam>
|
||||
[XmlRoot("dictionary")]
|
||||
public class SerializableDictionary<TKey, TValue>
|
||||
: Dictionary<TKey, TValue>, IXmlSerializable
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SerializableDictionary{TKey,TValue}"/> class.
|
||||
/// </summary>
|
||||
public SerializableDictionary()
|
||||
{
|
||||
// Empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SerializableDictionary{TKey,TValue}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dictionary">Dictionary to convert from.</param>
|
||||
public SerializableDictionary(IDictionary<TKey, TValue> dictionary) : base(dictionary)
|
||||
{
|
||||
// Empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SerializableDictionary{TKey,TValue}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dictionary">Dictionary to convert from.</param>
|
||||
/// <param name="comparer">Comparer for the dictionary.</param>
|
||||
public SerializableDictionary(IDictionary<TKey, TValue> dictionary, IEqualityComparer<TKey> comparer) : base(dictionary, comparer)
|
||||
{
|
||||
// Empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SerializableDictionary{TKey,TValue}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="comparer">Comparer for the dictionary.</param>
|
||||
public SerializableDictionary(IEqualityComparer<TKey> comparer) : base(comparer)
|
||||
{
|
||||
// Empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SerializableDictionary{TKey,TValue}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="capacity">Capacity of the dictionary.</param>
|
||||
public SerializableDictionary(int capacity) : base(capacity)
|
||||
{
|
||||
// Empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SerializableDictionary{TKey,TValue}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="capacity">Capacity of the dictionary.</param>
|
||||
/// <param name="comparer">Comparer for the dictionary.</param>
|
||||
public SerializableDictionary(int capacity, IEqualityComparer<TKey> comparer) : base(capacity, comparer)
|
||||
{
|
||||
// Empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema of the XML object.
|
||||
/// </summary>
|
||||
/// <returns>Nothing.</returns>
|
||||
public System.Xml.Schema.XmlSchema GetSchema()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads XML and changes this object to be an instance of that data.
|
||||
/// </summary>
|
||||
/// <param name="reader">The XML reader to read from.</param>
|
||||
public void ReadXml(System.Xml.XmlReader reader)
|
||||
{
|
||||
XmlSerializer keySerializer = new XmlSerializer(typeof(TKey));
|
||||
XmlSerializer valueSerializer = new XmlSerializer(typeof(TValue));
|
||||
|
||||
bool wasEmpty = reader.IsEmptyElement;
|
||||
reader.Read();
|
||||
|
||||
if (wasEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (reader.NodeType != System.Xml.XmlNodeType.EndElement)
|
||||
{
|
||||
reader.ReadStartElement("item");
|
||||
|
||||
reader.ReadStartElement("key");
|
||||
TKey key = (TKey)keySerializer.Deserialize(reader);
|
||||
reader.ReadEndElement();
|
||||
|
||||
reader.ReadStartElement("value");
|
||||
TValue value = (TValue)valueSerializer.Deserialize(reader);
|
||||
reader.ReadEndElement();
|
||||
|
||||
this.Add(key, value);
|
||||
|
||||
reader.ReadEndElement();
|
||||
reader.MoveToContent();
|
||||
}
|
||||
|
||||
reader.ReadEndElement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes XML to the XML writer from this object.
|
||||
/// </summary>
|
||||
/// <param name="writer">An instance of the XmlWriter class.</param>
|
||||
public void WriteXml(System.Xml.XmlWriter writer)
|
||||
{
|
||||
XmlSerializer keySerializer = new XmlSerializer(typeof(TKey));
|
||||
XmlSerializer valueSerializer = new XmlSerializer(typeof(TValue));
|
||||
|
||||
foreach (TKey key in this.Keys)
|
||||
{
|
||||
writer.WriteStartElement("item");
|
||||
|
||||
writer.WriteStartElement("key");
|
||||
keySerializer.Serialize(writer, key);
|
||||
writer.WriteEndElement();
|
||||
|
||||
writer.WriteStartElement("value");
|
||||
TValue value = this[key];
|
||||
valueSerializer.Serialize(writer, value);
|
||||
writer.WriteEndElement();
|
||||
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
-54
@@ -1,9 +1,19 @@
|
||||
namespace Jellyfin.Plugin.SSO_Auth;
|
||||
|
||||
/// <summary>
|
||||
/// A helper class to return HTML for the client's auth flow.
|
||||
/// </summary>
|
||||
public static class WebResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The shared HTML between all of the responses.
|
||||
/// </summary>
|
||||
public static readonly string Base = @"<!DOCTYPE html>
|
||||
<html><head></head><body><script>
|
||||
<html><head></head><body>
|
||||
<p>Logging in...</p>
|
||||
<noscript>Please enable Javascript to complete the login</noscript>
|
||||
<script>
|
||||
|
||||
function isTv() {
|
||||
// This is going to be really difficult to get right
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
@@ -389,22 +399,39 @@ function getDeviceName() {
|
||||
return deviceName;
|
||||
}
|
||||
|
||||
const sleep = (milliseconds) => {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
";
|
||||
|
||||
public static string OIDGenerator(string data, string provider, string baseUrl)
|
||||
/// <summary>
|
||||
/// A generator for the web response that incorporates the data from the server.
|
||||
/// </summary>
|
||||
/// <param name="data">The data of the auth flow. Is signed XML for SAML and a state ID for OpenID.</param>
|
||||
/// <param name="provider">The name of the provider to callback to.</param>
|
||||
/// <param name="baseUrl">The base URL of the Jellyfin installation.</param>
|
||||
/// <param name="mode">The mode of the function; SAML or OID.</param>
|
||||
/// <returns>A string with the HTML to serve to the client.</returns>
|
||||
public static string Generator(string data, string provider, string baseUrl, string mode)
|
||||
{
|
||||
return Base + @"
|
||||
async function main() {
|
||||
var data = '" + data + @"';
|
||||
while (localStorage.getItem(""_deviceId2"") == null ||
|
||||
localStorage.getItem(""jellyfin_credentials"") == null ||
|
||||
JSON.parse(localStorage.getItem(""jellyfin_credentials""))['Servers'][0]['Id'] == null) {
|
||||
// If localStorage isn't initialized yet, try again.
|
||||
await sleep(100);
|
||||
}
|
||||
var deviceId = localStorage.getItem(""_deviceId2"");
|
||||
var appName = ""Jellyfin Web"";
|
||||
var appVersion = ""10.8.0"";
|
||||
var deviceName = getDeviceName();
|
||||
var provider = '" + provider + @"';
|
||||
|
||||
var request = {'deviceID': deviceId, 'appName': appName, 'appVersion': appVersion, deviceName: 'deviceName', data: data, provider: '" + provider + @"'};
|
||||
var request = {deviceId, appName, appVersion, deviceName, data};
|
||||
|
||||
var url = '" + baseUrl + @"/sso/OID/Auth';
|
||||
var url = '" + baseUrl + "/sso/" + mode + "/Auth/" + provider + @"';
|
||||
|
||||
let response = await new Promise(resolve => {
|
||||
var xhr = new XMLHttpRequest();
|
||||
@@ -428,60 +455,14 @@ async function main() {
|
||||
jfCreds['Servers'][0]['UserId'] = responseJson['User']['Id'];
|
||||
localStorage.setItem('jellyfin_credentials', JSON.stringify(jfCreds));
|
||||
localStorage.setItem('enableAutoLogin', 'true');
|
||||
window.location.replace('/');
|
||||
window.location.replace('" + baseUrl + @"');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
main();
|
||||
});
|
||||
|
||||
</script></body></html>";
|
||||
}
|
||||
|
||||
public static string SamlGenerator(string xml, string provider, string baseUrl)
|
||||
{
|
||||
return Base + @"
|
||||
async function main() {
|
||||
var xml = '" + xml + @"';
|
||||
var deviceId = localStorage.getItem(""_deviceId2"");
|
||||
var appName = ""Jellyfin Web"";
|
||||
var appVersion = ""10.8.0"";
|
||||
var deviceName = getDeviceName();
|
||||
var provider = '" + provider + @"';
|
||||
|
||||
var request = {'deviceID': deviceId, 'appName': appName, 'appVersion': appVersion, deviceName: 'deviceName', data: xml, provider: '" + provider + @"'};
|
||||
|
||||
var url = '" + baseUrl + @"/sso/SAML/Auth';
|
||||
|
||||
let response = await new Promise(resolve => {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url, true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.setRequestHeader('Accept', 'application/json');
|
||||
xhr.onload = function(e) {
|
||||
resolve(xhr.response);
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
resolve(undefined);
|
||||
};
|
||||
xhr.send(JSON.stringify(request));
|
||||
})
|
||||
var responseJson = JSON.parse(response);
|
||||
var userId = 'user-' + responseJson['User']['Id'] + '-' + responseJson['User']['ServerId'];
|
||||
responseJson['User']['EnableAutoLogin'] = true;
|
||||
localStorage.setItem(userId, JSON.stringify(responseJson['User']));
|
||||
var jfCreds = JSON.parse(localStorage.getItem('jellyfin_credentials'));
|
||||
jfCreds['Servers'][0]['AccessToken'] = responseJson['AccessToken'];
|
||||
jfCreds['Servers'][0]['UserId'] = responseJson['User']['Id'];
|
||||
localStorage.setItem('jellyfin_credentials', JSON.stringify(jfCreds));
|
||||
localStorage.setItem('enableAutoLogin', 'true');
|
||||
window.location.replace('/');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
main();
|
||||
});
|
||||
|
||||
</script></body></html>";
|
||||
// https://stackoverflow.com/a/25435165
|
||||
</script><iframe class='docs-texteventtarget-iframe' sandbox='allow-same-origin allow-forms allow-scripts' src='" + baseUrl + "' style='position: absolute;width:0;height:0;border:0;'></iframe></body></html>";
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -1,7 +1,7 @@
|
||||
name: "SSO Authentication"
|
||||
guid: "505ce9d1-d916-42fa-86ca-673ef241d7df"
|
||||
imageUrl: "https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/main/img/logo.png"
|
||||
version: "3.0.0.0"
|
||||
version: "3.3.0.0"
|
||||
targetAbi: "10.8.0.0"
|
||||
framework: "net6.0"
|
||||
owner: "9p4"
|
||||
@@ -15,6 +15,10 @@ artifacts:
|
||||
- "IdentityModel.OidcClient.dll"
|
||||
- "IdentityModel.dll"
|
||||
changelog: |
|
||||
3.3.0.0: Add fallback authentication provider. Add OpenID admin page.
|
||||
3.2.0.0: Switch to hashmaps (BREAKING) for performance. Dump expected permissions in logs on error.
|
||||
3.1.0.1: Fix redirect bug in WebResponse (#7)
|
||||
3.1.0.0: Simplify auth flow so loading the web UI is not required
|
||||
3.0.0.0: Add more RBAC features and option to unregister user from SSO
|
||||
2.0.1.0: Fix improper artifact loading
|
||||
2.0.0.0: Add RBAC and Google support
|
||||
|
||||
Reference in New Issue
Block a user