mirror of
https://github.com/9p4/jellyfin-plugin-sso.git
synced 2026-09-19 13:12:19 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f19cd74c19 | ||
|
|
8700686e2b | ||
|
|
9c8f6c64c7 | ||
|
|
075b4fb69f | ||
|
|
09bbe3aa47 | ||
|
|
aa135cdc65 | ||
|
|
dae0043192 | ||
|
|
32e009969b | ||
|
|
9cb3057c90 | ||
|
|
d5315a5263 | ||
|
|
e9273ca82c | ||
|
|
e13c403f95 |
@@ -1,6 +1,6 @@
|
||||
# Jellyfin SSO Plugin
|
||||
|
||||
This plugin allows users to sign in through an SSO provider (such as Google, Facebook, or your own provider). This enables one-click signin.
|
||||
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
|
||||
|
||||
@@ -75,7 +75,7 @@ Make sure that `clientid` is replaced with the actual client ID!
|
||||
|
||||
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"}' "https://myjellyfin.example.com/sso/OID/Add?api_key=API_KEY_HERE"`
|
||||
|
||||
The OpenID provider must have the following configuration (again, I am using Keycloak)
|
||||
|
||||
@@ -154,7 +154,7 @@ 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.
|
||||
- `roleClaim`: string. This is the value in the OpenID response to check for roles. For Keycloak, it is `realm_access` by default.
|
||||
- GET `OID/Del/clientId`: This removes a configuration for OpenID for a given client ID.
|
||||
- GET `OID/Get`: Lists the configurations currently available.
|
||||
- GET `OID/States`: Lists currently active OpenID flows in progress.
|
||||
@@ -173,7 +173,7 @@ There is also no logout callback. Logging out of Jellyfin will log you out of Je
|
||||
|
||||
~~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 +184,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.
|
||||
|
||||
@@ -145,7 +145,7 @@ public class SSOController : ControllerBase
|
||||
}
|
||||
if (StateManager[Request.Query["state"]].Valid)
|
||||
{
|
||||
return Content(WebResponse.OIDGenerator(data: Request.Query["state"], provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html);
|
||||
return Content(WebResponse.Generator(data: Request.Query["state"], provider: provider, baseUrl: GetRequestBase(), mode: "OID"), MediaTypeNames.Text.Html);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -267,7 +267,7 @@ public class SSOController : ControllerBase
|
||||
// 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);
|
||||
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"))
|
||||
@@ -276,7 +276,7 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
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(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML"), MediaTypeNames.Text.Html);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ public class OIDConfig
|
||||
public bool EnableFolderRoles { get; set; }
|
||||
|
||||
[XmlArray("FolderRoleMappings")]
|
||||
[XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMappings")]
|
||||
[XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMap")]
|
||||
public List<FolderRoleMap> FolderRoleMapping { get; set; }
|
||||
|
||||
public string RoleClaim { get; set; }
|
||||
|
||||
@@ -7,97 +7,9 @@
|
||||
<div data-role="page" class="page type-interior pluginConfigurationPage esqConfigurationPage">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<form class="esqConfigurationForm">
|
||||
<div class="verticalSection verticalSection-extrabottompadding">
|
||||
<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>
|
||||
<div class="verticalSection" is="emby-collapse" title="SSO Server Settings">
|
||||
<button id="newSaml" is="emby-button">
|
||||
<span>Add new SAML provider</span>
|
||||
</button>
|
||||
<button id="newOID" is="emby-button">
|
||||
<span>Add new OpenID provider</span>
|
||||
</button>
|
||||
<div class="collapseContent" id="default">
|
||||
<div class="samlProviderWrapper">
|
||||
<div class="samlProvider">
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="text" id="txtSamlEndpoint" required placeholder="https://saml-provider.example.com/login" label="SAML Endpoint" />
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="text" id="txtSamlClientId" required placeholder="myjellyfin.example.com" label="SAML Client ID" />
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<textarea is="emby-textarea" id="txtSamlCertificate" placeholder="Base64-encoded realm certificate" label="SAML Certificate"></textarea>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkSamlEnable" />
|
||||
<span>Enable Provider</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="btnSaveSettings" is="emby-button" type="submit" value="submit" class="raised button block">
|
||||
<span>Update Provider</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<a href="https://github.com/9p4/jellyfin-plugin-sso/blob/main/README.md">Review the documentation. This plugin is configured via the API.</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script type="text/javascript">
|
||||
var SSOConfigurationPage = {
|
||||
pluginUniqueId: "505ce9d1-d916-42fa-86ca-673ef241d7df",
|
||||
};
|
||||
|
||||
document.querySelector('.esqConfigurationPage').addEventListener("pageshow", function () {
|
||||
Dashboard.showLoadingMsg();
|
||||
|
||||
window.ApiClient.getPluginConfiguration(SSOConfigurationPage.pluginUniqueId).then(function (config) {
|
||||
SSOConfigurationPage.txtSamlEndpoint.value = config.SamlEndpoint;
|
||||
SSOConfigurationPage.txtSamlCertificate.value = config.SamlCertificate;
|
||||
SSOConfigurationPage.txtSamlClientId.value = config.SamlClientId;
|
||||
});
|
||||
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
|
||||
var form = document.querySelector(".esqConfigurationForm");
|
||||
form.addEventListener("submit", function(e){
|
||||
e.preventDefault();
|
||||
Dashboard.showLoadingMsg();
|
||||
|
||||
window.ApiClient.getPluginConfiguration(SSOConfigurationPage.pluginUniqueId).then(function (config) {
|
||||
config.SamlCertificate = SSOConfigurationPage.txtSamlCertificate.value;
|
||||
config.SamlEndpoint = SSOConfigurationPage.txtSamlEndpoint.value;
|
||||
config.SamlClientId = SSOConfigurationPage.txtSamlClientId.value;
|
||||
window.ApiClient.updatePluginConfiguration(SSOConfigurationPage.pluginUniqueId, config).then(Dashboard.processPluginConfigurationUpdateResult);
|
||||
});
|
||||
|
||||
// Disable default form submission
|
||||
return false;
|
||||
});
|
||||
|
||||
var newSaml = document.getElementById("newSaml");
|
||||
newSaml.addEventListener("click", function(e) {
|
||||
e.preventDefault();
|
||||
Dashboard.showLoadingMsg();
|
||||
fetch(window.ApiClient.getUrl("sso/SAML/Get?api_key=" + window.ApiClient.accessToken()))
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error("HTTP error " + response.status);
|
||||
}
|
||||
return response.json();
|
||||
}).then(json => {console.log(json)});
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.SSO_Auth</RootNamespace>
|
||||
<AssemblyVersion>2.0.1.0</AssemblyVersion>
|
||||
<FileVersion>2.0.1.0</FileVersion>
|
||||
<AssemblyVersion>3.1.0.0</AssemblyVersion>
|
||||
<FileVersion>3.1.0.0</FileVersion>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
+43
-80
@@ -3,7 +3,11 @@ namespace Jellyfin.Plugin.SSO_Auth;
|
||||
public static class WebResponse
|
||||
{
|
||||
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();
|
||||
@@ -391,97 +395,56 @@ function getDeviceName() {
|
||||
|
||||
";
|
||||
|
||||
public static string OIDGenerator(string data, string provider, string baseUrl)
|
||||
public static string Generator(string data, string provider, string baseUrl, string mode)
|
||||
{
|
||||
return Base + @"
|
||||
async function main() {
|
||||
var data = '" + data + @"';
|
||||
var deviceId = localStorage.getItem(""_deviceId2"");
|
||||
var appName = ""Jellyfin Web"";
|
||||
var appVersion = ""10.8.0"";
|
||||
var deviceName = getDeviceName();
|
||||
var provider = '" + provider + @"';
|
||||
if (localStorage.getItem(""_deviceId2"") == null || localStorage.getItem(""jellyfin_credentials"") == null) {
|
||||
// If localStorage isn't initialized yet, try again.
|
||||
setTimeout(main, 100);
|
||||
} else {
|
||||
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, provider: '" + provider + @"'};
|
||||
|
||||
var url = '" + baseUrl + @"/sso/OID/Auth';
|
||||
var url = '" + baseUrl + "/sso/" + mode + @"/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('/');
|
||||
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('" + 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' src='" + baseUrl + "' style='position: absolute;width:0;height:0;border:0;'></iframe></body></html>";
|
||||
}
|
||||
}
|
||||
|
||||
+3
-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: "2.0.1.0"
|
||||
version: "3.1.0.0"
|
||||
targetAbi: "10.8.0.0"
|
||||
framework: "net6.0"
|
||||
owner: "9p4"
|
||||
@@ -15,6 +15,8 @@ artifacts:
|
||||
- "IdentityModel.OidcClient.dll"
|
||||
- "IdentityModel.dll"
|
||||
changelog: |
|
||||
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
|
||||
1.0.0.0: Initial Release
|
||||
|
||||
Reference in New Issue
Block a user