mirror of
https://github.com/9p4/jellyfin-plugin-sso.git
synced 2026-09-19 13:12:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e232d7cf9 | ||
|
|
eaf7e87747 | ||
|
|
8df2bd94e8 | ||
|
|
815d6cbe7e | ||
|
|
6a0fcfe711 | ||
|
|
f9331665e7 | ||
|
|
299f3f7220 | ||
|
|
4eec8bb789 | ||
|
|
55d795b9d8 | ||
|
|
5a8d1da6de | ||
|
|
e3efd97964 | ||
|
|
9199fce64f | ||
|
|
0e590858c0 | ||
|
|
9b4393981c | ||
|
|
29e902c109 | ||
|
|
b777e3a346 | ||
|
|
5da7e02faf | ||
|
|
07aa74b1d7 | ||
|
|
f98509633b | ||
|
|
f7d066d6b1 | ||
|
|
4600f56863 | ||
|
|
22dd6629eb | ||
|
|
ece0dc31db | ||
|
|
8771f63eb5 | ||
|
|
54ef348009 | ||
|
|
63ea90114c | ||
|
|
3a8e89db82 | ||
|
|
08bf263255 | ||
|
|
b1beef8155 | ||
|
|
062be242ae | ||
|
|
d9458d5933 | ||
|
|
6cb5fc33bf | ||
|
|
fd371006af | ||
|
|
5d28f1ba31 | ||
|
|
c500a5d435 | ||
|
|
2d14342ad7 | ||
|
|
e8276eae87 | ||
|
|
2d459b7945 | ||
|
|
b8cddb3467 | ||
|
|
875fc62728 | ||
|
|
653341f3dd | ||
|
|
0eb7cd4f50 | ||
|
|
06ed040381 |
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ""
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Configuration**
|
||||
Add your plugin configuration XML file here formatted as code (with three backticks surrounding the text), or as an upload to a pastebin service.
|
||||
|
||||
**Versions (please complete the following information):**
|
||||
|
||||
- OS: [e.g. Linux]
|
||||
- Browser: [e.g. chrome, safari]
|
||||
- Jellyfin Version: [e.g. 10.8 Alpha 4]
|
||||
- Plugin Version: [e.g. 2.0.1.0 or a Git tag]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here. Was the plugin built from source?
|
||||
@@ -0,0 +1,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,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}'
|
||||
@@ -0,0 +1,62 @@
|
||||
name: Publish Nightly
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v1
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
- name: Build Dotnet
|
||||
run: dotnet build --no-restore --warnaserror
|
||||
- name: "Flag as nightly in build.yaml"
|
||||
uses: fjogeleit/[email protected]
|
||||
with:
|
||||
valueFile: 'build.yaml'
|
||||
propertyPath: 'version'
|
||||
value: "0.0.0.9000"
|
||||
commitChange: false
|
||||
updateFile: true
|
||||
- name: "JPRM: Build"
|
||||
id: jrpm
|
||||
uses: oddstr13/jellyfin-plugin-repository-manager@b9e92867a6aa279d611a5ea80cf61f6358838c39
|
||||
with:
|
||||
version: "0.0.0.9000"
|
||||
verbosity: debug
|
||||
path: .
|
||||
dotnet-target: "net6.0"
|
||||
output: _dist
|
||||
- name: Publish output artifacts
|
||||
id: publish-assets
|
||||
uses: softprops/action-gh-release@50195ba7f6f93d1ac97ba8332a178e008ad176aa
|
||||
with:
|
||||
prerelease: false
|
||||
fail_on_unmatched_files: true
|
||||
tag_name: nightly
|
||||
files: |
|
||||
_dist/*
|
||||
build.yaml
|
||||
body: |
|
||||
Nightly build
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Publish Plugin Manifest
|
||||
uses: Kevinjil/jellyfin-plugin-repo-action@a7832ecc44c6b1a45d531970f6647b8682b005b8
|
||||
with:
|
||||
ignorePrereleases: false
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: ${{ github.repository }}
|
||||
pagesBranch: manifest-release
|
||||
pagesFile: manifest.json
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Publish Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- released
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: jellyfin/jellyfin-meta-plugins/.github/workflows/build.yaml@30fd723cc3bafc4457a4a85be3cdbee2f3e2013b
|
||||
with:
|
||||
dotnet-version: "6.0.*"
|
||||
dotnet-target: "net6.0"
|
||||
upload:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download Artifact
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
name: build-artifact
|
||||
- name: Prepare GitHub Release assets
|
||||
run: |-
|
||||
for file in ./*; do
|
||||
md5sum ${file#./} >> ${file%.*}.md5
|
||||
sha256sum ${file#./} >> ${file%.*}.sha256
|
||||
done
|
||||
ls -l
|
||||
- name: Upload GitHub Release assets
|
||||
uses: shogo82148/[email protected]
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: ./*
|
||||
generate:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- upload
|
||||
steps:
|
||||
- name: Publish Plugin Manifest
|
||||
uses: Kevinjil/jellyfin-plugin-repo-action@a7832ecc44c6b1a45d531970f6647b8682b005b8
|
||||
with:
|
||||
ignorePrereleases: false
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: ${{ github.repository }}
|
||||
pagesBranch: manifest-release
|
||||
pagesFile: manifest.json
|
||||
@@ -22,7 +22,6 @@
|
||||
</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
|
||||
@@ -31,7 +30,7 @@ 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](https://github.com/9p4/jellyfin-plugin-sso/issues/3) and only on the Web UI!**
|
||||
|
||||
@@ -39,8 +38,13 @@ There is NO admin configuration! You must use the API to configure the program!
|
||||
|
||||
## Tested Providers
|
||||
|
||||
[Find provider specific documentation in providers.md](providers.md)
|
||||
|
||||
- Authelia
|
||||
- authentik
|
||||
- Keycloak
|
||||
- OIDC & SAML
|
||||
- Google OpenID: Works, but usernames are all numeric
|
||||
- Keycloak OpenID and SAML: Works
|
||||
|
||||
## Supported Protocols
|
||||
|
||||
@@ -53,7 +57,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
|
||||
|
||||
@@ -67,7 +71,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
|
||||
@@ -98,7 +102,7 @@ Make sure that `clientid` is replaced with the actual client ID and `PROVIDER_NA
|
||||
|
||||
Example for adding an OpenID configuration with the API using [curl](https://curl.se/)
|
||||
|
||||
`curl -v -X POST -H "Content-Type: application/json" -d '{"oidEndpoint": "https://keycloak.example.com/realms/test", "oidClientId": "jellyfin-oid", "oidSecret": "short secret here", "enabled": true, "enableAuthorization": true, "enableAllFolders": false, "enabledFolders": [], "adminRoles": ["jellyfin-admin"], "roles": ["allowed-to-use-jellyfin"], "enableFolderRoles": true, "folderRoleMapping": [{"role": "allowed-to-watch-movies", "folders": ["cc7df17e2f3509a4b5fc1d1ff0a6c4d0", "f137a2dd21bbc1b99aa5c0f6bf02a805"]}], "roleClaim": "realm_access"}' "https://myjellyfin.example.com/sso/OID/Add/PROVIDER_NAME?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)
|
||||
|
||||
@@ -142,10 +146,10 @@ 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.
|
||||
- `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
|
||||
@@ -176,6 +180,10 @@ These all require authorization. Append an API key to the end of the request: `c
|
||||
- `enableFolderRoles`: boolean. Determines if role-based folder access should be used.
|
||||
- `folderRoleMapping`: object in the format "role": string and "folders": array of strings. The user with this role will have access to the following folders if `enableFolderRoles` is enabled. To get the IDs of the folders, GET the `/Library/MediaFolders` URL with an API key. Look for the `Id` attribute.
|
||||
- `roleClaim`: string. This is the value in the OpenID response to check for roles. For Keycloak, it is `realm_access.roles` by default. The first element is the claim type, the subsequent values are to parse the JSON of the claim value. Use a "\\." to denote a literal ".". This expects a list of strings from the OIDC server.
|
||||
- `oidScopes` : array of strings. each containing an additional scope name to include in the OIDC request.
|
||||
- For some OIDC providers (For example, [authelia](https://github.com/9p4/jellyfin-plugin-sso/issues/23#issuecomment-1112237616)), additional scopes may be required in order to validate group membership in role claim.
|
||||
- Leave empty to only request the default scopes.
|
||||
- `defaultProvider`: string. The set provider then gets assigned to the user after they have logged in. If it is not set, nothing is changed. With this, a user can login with SSO but is still able to log in via other providers later. See the `Unregister` endpoint.
|
||||
- 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.
|
||||
@@ -188,7 +196,7 @@ 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.
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ 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;
|
||||
@@ -49,10 +50,13 @@ public class SSOController : ControllerBase
|
||||
/// 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) // Although this is a GET function, this function is called `Post` for consistency with SAML
|
||||
public ActionResult OidPost(
|
||||
[FromRoute] string provider,
|
||||
[FromQuery] string state) // Although this is a GET function, this function is called `Post` for consistency with SAML
|
||||
{
|
||||
OidConfig config;
|
||||
try
|
||||
@@ -72,34 +76,34 @@ public class SSOController : ControllerBase
|
||||
ClientId = config.OidClientId,
|
||||
ClientSecret = config.OidSecret,
|
||||
RedirectUri = GetRequestBase() + "/sso/OID/r/" + provider,
|
||||
Scope = "openid profile",
|
||||
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 = StateManager[Request.Query["state"]].State;
|
||||
var result = oidcClient.ProcessResponseAsync(Request.QueryString.Value, state).Result;
|
||||
var currentState = StateManager[state].State;
|
||||
var result = oidcClient.ProcessResponseAsync(Request.QueryString.Value, currentState).Result;
|
||||
if (result.IsError)
|
||||
{
|
||||
return ReturnError(400, result.Error + " Try logging in again.");
|
||||
return ReturnError(StatusCodes.Status400BadRequest, result.Error + " Try logging in again.");
|
||||
}
|
||||
|
||||
if (!config.EnableFolderRoles)
|
||||
{
|
||||
StateManager[Request.Query["state"]].Folders = new List<string>(config.EnabledFolders);
|
||||
StateManager[state].Folders = new List<string>(config.EnabledFolders);
|
||||
}
|
||||
else
|
||||
{
|
||||
StateManager[Request.Query["state"]].Folders = new List<string>();
|
||||
StateManager[state].Folders = new List<string>();
|
||||
}
|
||||
|
||||
foreach (var claim in result.User.Claims)
|
||||
{
|
||||
if (claim.Type == "preferred_username")
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +136,7 @@ public class SSOController : ControllerBase
|
||||
}
|
||||
|
||||
// The final step is to take the JSON and turn it from a dictionary into a string
|
||||
roles = (json[segments[segments.Length - 1]] as JArray).ToObject<List<string>>();
|
||||
roles = (json[segments[^1]] as JArray).ToObject<List<string>>();
|
||||
}
|
||||
|
||||
foreach (string role in roles)
|
||||
@@ -144,7 +148,7 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
if (role.Equals(validRoles))
|
||||
{
|
||||
StateManager[Request.Query["state"]].Valid = true;
|
||||
StateManager[state].Valid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,7 +160,7 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
if (role.Equals(validAdminRoles))
|
||||
{
|
||||
StateManager[Request.Query["state"]].Admin = true;
|
||||
StateManager[state].Admin = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,7 +172,7 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
if (role.Equals(folderRoleMap.Role))
|
||||
{
|
||||
StateManager[Request.Query["state"]].Folders.AddRange(folderRoleMap.Folders);
|
||||
StateManager[state].Folders.AddRange(folderRoleMap.Folders);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,29 +181,34 @@ public class SSOController : ControllerBase
|
||||
}
|
||||
|
||||
// If the provider doesn't support preferred_username, then use sub
|
||||
if (!StateManager[Request.Query["state"]].Valid)
|
||||
if (!StateManager[state].Valid)
|
||||
{
|
||||
foreach (var claim in result.User.Claims)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (StateManager[Request.Query["state"]].Valid)
|
||||
if (StateManager[state].Valid)
|
||||
{
|
||||
return Content(WebResponse.Generator(data: Request.Query["state"], provider: provider, baseUrl: GetRequestBase(), mode: "OID"), MediaTypeNames.Text.Html);
|
||||
return Content(WebResponse.Generator(data: state, provider: provider, baseUrl: GetRequestBase(), mode: "OID"), MediaTypeNames.Text.Html);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("OpenID user " + StateManager[Request.Query["state"]].Username + " has one or more incorrect role claims: " + string.Join(", ", result.User.Claims.Select(o => new { o.Type, o.Value })) + ". Expected any one of: " + string.Join(", ", config.Roles) + ".");
|
||||
return ReturnError(401, "Error. Check permissions.");
|
||||
_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);
|
||||
|
||||
return ReturnError(StatusCodes.Status401Unauthorized, "Error. Check permissions.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +243,7 @@ public class SSOController : ControllerBase
|
||||
ClientId = config.OidClientId,
|
||||
ClientSecret = config.OidSecret,
|
||||
RedirectUri = GetRequestBase() + "/sso/OID/r/" + provider,
|
||||
Scope = "openid profile"
|
||||
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);
|
||||
@@ -322,7 +331,7 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid)
|
||||
{
|
||||
var authenticationResult = await Authenticate(kvp.Value.Username, kvp.Value.Admin, config.EnableAuthorization, config.EnableAllFolders, kvp.Value.Folders.ToArray(), response)
|
||||
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);
|
||||
}
|
||||
@@ -371,11 +380,15 @@ public class SSOController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning("SAML user " + samlResponse.GetNameID() + " has insufficient roles: " + string.Join(", ", samlResponse.GetCustomAttributes("Role")) + ". Expected any one of: " + string.Join(", ", config.Roles) + ".");
|
||||
return ReturnError(401, "Error. Check permissions.");
|
||||
_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(400, "No active providers found");
|
||||
return ReturnError(StatusCodes.Status400BadRequest, "No active providers found");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -507,7 +520,7 @@ public class SSOController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
var authenticationResult = await Authenticate(samlResponse.GetNameID(), isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), response)
|
||||
var authenticationResult = await Authenticate(samlResponse.GetNameID(), isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), response, config.DefaultProvider)
|
||||
.ConfigureAwait(false);
|
||||
return Ok(authenticationResult);
|
||||
}
|
||||
@@ -540,7 +553,8 @@ public class SSOController : ControllerBase
|
||||
/// <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>
|
||||
private async Task<AuthenticationResult> Authenticate(string username, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse)
|
||||
/// <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);
|
||||
@@ -549,9 +563,9 @@ 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)
|
||||
{
|
||||
user.SetPermission(PermissionKind.IsAdministrator, isAdmin);
|
||||
@@ -572,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);
|
||||
}
|
||||
|
||||
@@ -596,7 +617,7 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
var errorResult = new ContentResult();
|
||||
errorResult.Content = message;
|
||||
errorResult.ContentType = "text/plain";
|
||||
errorResult.ContentType = MediaTypeNames.Text.Plain;
|
||||
errorResult.StatusCode = code;
|
||||
return errorResult;
|
||||
}
|
||||
|
||||
@@ -92,6 +92,11 @@ public class SamlConfig
|
||||
[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>
|
||||
@@ -161,6 +166,16 @@ public class OidConfig
|
||||
/// 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>
|
||||
|
||||
@@ -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.2.0.0</AssemblyVersion>
|
||||
<FileVersion>3.2.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>
|
||||
|
||||
+16
-3
@@ -45,10 +45,23 @@ public class SSOPlugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
/// <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"
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+2
-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.2.0.0"
|
||||
version: "3.3.0.0"
|
||||
targetAbi: "10.8.0.0"
|
||||
framework: "net6.0"
|
||||
owner: "9p4"
|
||||
@@ -15,6 +15,7 @@ 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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
+200
@@ -0,0 +1,200 @@
|
||||
# Provider Specific Configuration
|
||||
|
||||
This plugin has been tested to work against various providers, though not all providers provide support for all of this plugins' features.
|
||||
|
||||
## TOC / Tested Providers:
|
||||
|
||||
This section is broken into providers that support Role-Based Access Control (RBAC), and those that do not
|
||||
|
||||
### Providers that support RBAC
|
||||
|
||||
- ✅ [Authelia](#authelia)
|
||||
- ✅ [authentik](#authentik)
|
||||
- [✅ Keycloak](#keycloak-oidc)
|
||||
- Both [OIDC](#keycloak-oidc) & [SAML](#keycloak-saml)
|
||||
|
||||
### No RBAC Support
|
||||
|
||||
- ✅ Google OIDC
|
||||
- ❗ Usernames are numeric
|
||||
|
||||
## General Options, when RBAC is supported
|
||||
|
||||
For any provider that supports RBAC, we can configure it as we see fit:
|
||||
|
||||
```yaml
|
||||
Enabled: true
|
||||
EnableAuthorization: true
|
||||
EnableAllFolders: true
|
||||
EnabledFolders: []
|
||||
Roles: ["jellyfin_user"]
|
||||
AdminRoles: ["jellyfin_admin"]
|
||||
EnableFolderRoles: false
|
||||
FolderRoleMapping: []
|
||||
```
|
||||
|
||||
## Authelia
|
||||
|
||||
Authelia is simple to configure, and RBAC is straightforward.
|
||||
|
||||
### Authelia's Config
|
||||
|
||||
Below is the `identity_providers` section of an Authelia config:
|
||||
|
||||
```yaml
|
||||
identity_providers:
|
||||
oidc:
|
||||
# hmac secret and private key given by env variables
|
||||
clients:
|
||||
- id: jellyfin
|
||||
description: My media server
|
||||
# Client secret should be randomly generated
|
||||
secret: <redacted>
|
||||
authorization_policy: one_factor
|
||||
redirect_uris:
|
||||
- https://jellyfin.example.com/sso/OID/r/authelia
|
||||
```
|
||||
|
||||
### Jellyfin's Config
|
||||
|
||||
On Jellyfin's end, we need to configure an Authelia provider as follows:
|
||||
|
||||
In order to test group membership, we need to request Authelia's `groups` OIDC scope, which we will use to check user roles.
|
||||
|
||||
```yaml
|
||||
authelia:
|
||||
OidEndpoint: https://authelia.example.com
|
||||
OidClientId: jellyfin
|
||||
OidSecret: <redacted>
|
||||
RoleClaim: groups
|
||||
OidScopes: ["groups"]
|
||||
```
|
||||
|
||||
## authentik
|
||||
|
||||
To begin with, we must set up an OIDC provider + application in authentik. Refer to the official documentation for detailed instruction.
|
||||
|
||||
### authentik's Config
|
||||
|
||||
authentik supports RBAC, but is slightly more complicated to configure than Authelia, as we need to configure a custom scope binding to include in the OIDC response.
|
||||
|
||||
To do this, we:
|
||||
|
||||
- create a **Custom Property Mapping**
|
||||
|
||||

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

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

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

|
||||
|
||||
- Edit / Update your Jellyfin OAuth provider
|
||||
- Under **"Advanced Protocol Settings"**, add the **Group Membership** Scope
|
||||
|
||||

|
||||
|
||||
### Jellyfin's Config
|
||||
|
||||
On Jellyfin's end, we need to configure an authentik provider as follows:
|
||||
|
||||
In order to test group membership, we need to request authentik's OIDC scope `groups`, which we will use to check user roles.
|
||||
|
||||
```yaml
|
||||
authentik:
|
||||
OidEndpoint: https://authentik.example.com/application/o/jellyfin
|
||||
OidClientId: <same-as-in-authentik>
|
||||
OidSecret: <redacted>
|
||||
RoleClaim: groups
|
||||
OidScopes: ["groups"]
|
||||
```
|
||||
|
||||
## Keycloak OIDC
|
||||
|
||||
Keycloak in general is a little more complicated than other providers. Ensure that you have a realm created and have some usable users.
|
||||
|
||||
### Keycloak's Config
|
||||
|
||||
Create a new Keycloak `openid-connect` application. Set the root URL to your Jellyfin URL (ie https://myjellyfin.example.com)
|
||||
|
||||
Ensure that the following configuration options are set:
|
||||
|
||||
- Access Type: Confidential
|
||||
- Standard Flow Enabled
|
||||
- Redirect URI: https://myjellyfin.example.com/sso/OID/r/PROVIDER_NAME
|
||||
- Base URL: https://myjellyfin.example.com
|
||||
|
||||
Press the "Save" button at the bottom of the page and open the "Credentials" tab. Note down the secret.
|
||||
|
||||
For adding groups and RBAC, go to the "mappers" tab, press "Add Builtin", and select either "Groups", "Realm Roles", or "Client Roles", depending on the role system you are planning on using. Once the mapper is added, edit the mapper and ensure that you note down the Token Claim Name as well as enable all four toggles: "Multivalued", "Add to ID token", "Add to access token", and "Add to userinfo" are enabled.
|
||||
|
||||
Note that if you are using the template for the "Client Roles" mapper, the default token claim name has `${client_id}` in it. When noting down this value, make sure you note down the actual Client ID (which should be written above).
|
||||
|
||||
### Jellyfin's Config
|
||||
|
||||
On Jellyfin's side, we need to configure a Keycloak provider as follows:
|
||||
|
||||
```yaml
|
||||
keycloak:
|
||||
OidEndpoint: https://keycloak.example.com/realms/<realm>
|
||||
OidClientId: <same-as-in-keycloak>
|
||||
OidSecret: <redacted>
|
||||
RoleClaim: <same-as-token-claim-name>
|
||||
```
|
||||
|
||||
## Keycloak SAML
|
||||
|
||||
Keycloak with SAML is very similar to OpenID. Again, Keycloak in general is a little more complicated than other providers. Ensure that you have a realm created and have some usable users.
|
||||
|
||||
### Keycloak's Config
|
||||
|
||||
Create a new Keycloak `saml` application. Set the root URL to your Jellyfin URL (ie https://myjellyfin.example.com)
|
||||
|
||||
Ensure that the following configuration options are set:
|
||||
|
||||
- Sign Documents on
|
||||
- Sign Assertions off
|
||||
- Client Signature Required off
|
||||
- Redirect URI: [https://myjellyfin.example.com/sso/SAML/p/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/p/PROVIDER_NAME)
|
||||
- Base URL: [https://myjellyfin.example.com](https://myjellyfin.example.com)
|
||||
- Master SAML processing URL: [https://myjellyfin.example.com/sso/SAML/p/PROVIDER_NAME](https://myjellyfin.example.com/sso/SAML/p/PROVIDER_NAME)
|
||||
|
||||
Press the "Save" button at the bottom of the page.
|
||||
|
||||
For adding groups and RBAC, go to the "mappers" tab, press "Add Builtin", and select either "Groups", "Realm Roles", or "Client Roles", depending on the role system you are planning on using. Once the mapper is added, edit the mapper and ensure that you note down the Token Claim Name as well as enable all four toggles: "Multivalued", "Add to ID token", "Add to access token", and "Add to userinfo" are enabled.
|
||||
|
||||
Note that if you are using the template for the "Client Roles" mapper, the default token claim name has `${client_id}` in it. When noting down this value, make sure you note down the actual Client ID (which should be written above).
|
||||
|
||||
Finally, download the certificate. Open the "Installation" tab, select "Mod Auth Mellon files", and download the zip. Extract the zip file, and open the `idp-metadata.xml` file. Note down the contents of the `X509Certificate` value.
|
||||
|
||||
### Jellyfin's Config
|
||||
|
||||
```yaml
|
||||
keycloak:
|
||||
SamlEndpoint: https://keycloak.example.com/realms/<realm>/protocol/saml
|
||||
SamlClientId: <same-as-in-keycloak>
|
||||
SamlCertificate: <copied-from-xml-file>
|
||||
```
|
||||
Reference in New Issue
Block a user