Compare commits
10 commits
421faedc60
...
c33c61d69e
Author | SHA1 | Date | |
---|---|---|---|
c33c61d69e | |||
|
8d5e5027f0 | ||
|
a931bc4146 | ||
|
ff9427b78c | ||
|
0614458011 | ||
|
1a5e64edf7 | ||
|
b33f1118c4 | ||
|
a987f19df2 | ||
|
145b75d64e | ||
|
f3dc02ae32 |
14 changed files with 772 additions and 198 deletions
2
.github/workflows/build-dotnet.yml
vendored
2
.github/workflows/build-dotnet.yml
vendored
|
@ -15,7 +15,7 @@ jobs:
|
||||||
- name: "Setup .NET Core"
|
- name: "Setup .NET Core"
|
||||||
uses: actions/setup-dotnet@v1
|
uses: actions/setup-dotnet@v1
|
||||||
with:
|
with:
|
||||||
dotnet-version: "5.0.x"
|
dotnet-version: "8.0.x"
|
||||||
|
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
run: dotnet restore
|
run: dotnet restore
|
||||||
|
|
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
|
@ -25,7 +25,7 @@ jobs:
|
||||||
- name: Setup .NET Core
|
- name: Setup .NET Core
|
||||||
uses: actions/setup-dotnet@v1
|
uses: actions/setup-dotnet@v1
|
||||||
with:
|
with:
|
||||||
dotnet-version: 5.0.x
|
dotnet-version: 8.0.x
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v1
|
uses: github/codeql-action/init@v1
|
||||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,3 +1,5 @@
|
||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
|
.idea/
|
||||||
|
*.DotSettings.user
|
||||||
.vs/
|
.vs/
|
||||||
|
|
|
@ -1,25 +1,94 @@
|
||||||
using MediaBrowser.Model.Plugins;
|
using MediaBrowser.Model.Plugins;
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.Keycloak.Configuration
|
namespace Jellyfin.Plugin.Keycloak.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The main plugin.
|
||||||
|
/// </summary>
|
||||||
|
public class PluginConfiguration : BasePluginConfiguration
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
public class PluginConfiguration : BasePluginConfiguration
|
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public PluginConfiguration()
|
||||||
{
|
{
|
||||||
public bool CreateUser { get; set; }
|
// set default options here
|
||||||
public string AuthServerUrl { get; set; }
|
this.Enabled = false;
|
||||||
public string Realm { get; set; }
|
this.CreateUser = true;
|
||||||
public string Resource { get; set; }
|
this.Enable2Fa = false;
|
||||||
public string ClientSecret { get; set; }
|
this.AuthServerUrl = string.Empty;
|
||||||
|
this.Realm = "master";
|
||||||
|
this.ClientId = string.Empty;
|
||||||
public PluginConfiguration()
|
this.ClientSecret = string.Empty;
|
||||||
{
|
this.OAuthScope = string.Empty;
|
||||||
// set default options here
|
this.RolesTokenAttribute = string.Empty;
|
||||||
CreateUser = true;
|
this.UsernameTokenAttribute = "preferred_username";
|
||||||
AuthServerUrl = "";
|
this.EnableAllFolders = false;
|
||||||
Realm = "";
|
this.EnabledFolders = System.Array.Empty<string>();
|
||||||
Resource = "";
|
|
||||||
ClientSecret = "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether Keycloak authentication is enabled.
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether creation of new users that dont exist in Jellyfin is enabled.
|
||||||
|
/// </summary>
|
||||||
|
public bool CreateUser { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether 2-factor authentication (Password+TOTP).
|
||||||
|
/// </summary>
|
||||||
|
public bool Enable2Fa { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets Keycloak server URL.
|
||||||
|
/// </summary>
|
||||||
|
public string AuthServerUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets Keycloak server realm.
|
||||||
|
/// </summary>
|
||||||
|
public string Realm { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets Keycloak client ID.
|
||||||
|
/// </summary>
|
||||||
|
public string ClientId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets Keycloak client secret.
|
||||||
|
/// </summary>
|
||||||
|
public string ClientSecret { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets Keycloak OAuth scope.
|
||||||
|
/// </summary>
|
||||||
|
public string OAuthScope { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets Keycloak username token attribute.
|
||||||
|
/// </summary>
|
||||||
|
public string UsernameTokenAttribute { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets Keycloak roles token attribute.
|
||||||
|
/// </summary>
|
||||||
|
public string RolesTokenAttribute { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether users without a role are allowed to log in.
|
||||||
|
/// </summary>
|
||||||
|
public bool AllowUsersWithoutRole { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether to enable access to all library folders.
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableAllFolders { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a list of folder Ids which are enabled for access by default.
|
||||||
|
/// </summary>
|
||||||
|
public string[] EnabledFolders { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,33 +8,75 @@
|
||||||
<div id="TemplateConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
<div id="TemplateConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
||||||
<div data-role="content">
|
<div data-role="content">
|
||||||
<div class="content-primary">
|
<div class="content-primary">
|
||||||
|
<h1>Keycloak Authentication</h1>
|
||||||
<form id="TemplateConfigForm">
|
<form id="TemplateConfigForm">
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
<label class="emby-checkbox-label">
|
<label class="emby-checkbox-label">
|
||||||
<input id="CreateUser" name="TrueFalseCheckBox" type="checkbox" is="emby-checkbox"/>
|
<input id="Enabled" name="TrueFalseCheckBox" type="checkbox" is="emby-checkbox"/>
|
||||||
<span>Create User if doesn't exist</span>
|
<span>Enable Keycloak authentication</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input id="CreateUser" name="TrueFalseCheckBox" type="checkbox" is="emby-checkbox"/>
|
||||||
|
<span>Create Keycloak user if it does not exist</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input id="Enable2Fa" name="TrueFalseCheckBox" type="checkbox" is="emby-checkbox"/>
|
||||||
|
<span>Enable Two-factor authentication</span>
|
||||||
|
</label>
|
||||||
|
<div class="fieldDescription">You need to add the TOTP (6 digits) to the password when logging in</div>
|
||||||
|
</div>
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
<label class="inputLabel inputLabelUnfocused" for="AuthServerUrl">Auth Server URL</label>
|
<label class="inputLabel inputLabelUnfocused" for="AuthServerUrl">Keycloak server URL</label>
|
||||||
<input id="AuthServerUrl" name="AuthServerUrl" type="text" is="emby-input"/>
|
<input id="AuthServerUrl" name="AuthServerUrl" type="text" is="emby-input"/>
|
||||||
<div class="fieldDescription">Base Keycloak auth URI</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
<label class="inputeLabel inputLabelUnfocused" for="AString">Realm</label>
|
<label class="inputeLabel inputLabelUnfocused" for="Realm">Keycloak Realm</label>
|
||||||
<input id="Realm" name="AString" type="text" is="emby-input"/>
|
<input id="Realm" name="AString" type="text" is="emby-input"/>
|
||||||
<div class="fieldDescription">Keycloak Realm</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
<label class="inputeLabel inputLabelUnfocused" for="AString">Resource/Client</label>
|
<label class="inputeLabel inputLabelUnfocused" for="ClientId">Client ID</label>
|
||||||
<input id="Resource" name="AString" type="text" is="emby-input"/>
|
<input id="ClientId" name="AString" type="text" is="emby-input"/>
|
||||||
<div class="fieldDescription">Keycloak Resource/Client</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
<label class="inputeLabel inputLabelUnfocused" for="ClientSecret">Client Secret</label>
|
<label class="inputeLabel inputLabelUnfocused" for="ClientSecret">Client Secret</label>
|
||||||
<input id="ClientSecret" name="AString" type="text" is="emby-input"/>
|
<input id="ClientSecret" name="AString" type="text" is="emby-input"/>
|
||||||
<div class="fieldDescription">Client Secret</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputeLabel inputLabelUnfocused" for="OAuthScope"> OAuth Scope</label>
|
||||||
|
<input id="OAuthScope" name="AString" type="text" is="emby-input"/>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputeLabel inputLabelUnfocused" for="RolesTokenAttribute"> Roles token attribute</label>
|
||||||
|
<input id="RolesTokenAttribute" name="AString" type="text" is="emby-input"/>
|
||||||
|
<div class="fieldDescription">Access token attribute with the list of roles. Seperate keys with a '.' if the role list is part of a nested object.</div>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputeLabel inputLabelUnfocused" for="UsernameTokenAttribute"> Username token attribute</label>
|
||||||
|
<input id="UsernameTokenAttribute" name="AString" type="text" is="emby-input"/>
|
||||||
|
<div class="fieldDescription">Access token attribute with the username</div>
|
||||||
|
</div>
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input id="AllowUsersWithoutRole" name="TrueFalseCheckBox" type="checkbox" is="emby-checkbox"/>
|
||||||
|
<span>Allow users without a role to log in</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="checkboxContainer">
|
||||||
|
<input type="checkbox" is="emby-checkbox" id="EnableAllFolders" />
|
||||||
|
<span>Enable access to all libraries</span>
|
||||||
|
</label>
|
||||||
|
<div class="folderAccessListContainer">
|
||||||
|
<div id="FolderAccessList"></div>
|
||||||
|
<div class="fieldDescription">Enable access to certain libraries by default</div>
|
||||||
|
<div class="fieldDescription">Add library access to certain users by giving them the 'lib-<ID>' role</div>
|
||||||
|
<p class="fieldDescription" id="LibraryIdTable"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||||
<span>Save</span>
|
<span>Save</span>
|
||||||
|
@ -46,23 +88,41 @@
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
var KeycloakPluginConfig = {
|
var KeycloakPluginConfig = {
|
||||||
pluginUniqueId: '40886866-b3dd-4d6a-bf9b-25c83e6c3d10',
|
pluginUniqueId: '40886866-b3dd-4d6a-bf9b-25c83e6c3d10',
|
||||||
|
chkEnabled: document.querySelector('#Enabled'),
|
||||||
chkCreateUser: document.querySelector('#CreateUser'),
|
chkCreateUser: document.querySelector('#CreateUser'),
|
||||||
txtAuthServerUrl: document.querySelector('#AuthServerUrl'),
|
txtAuthServerUrl: document.querySelector('#AuthServerUrl'),
|
||||||
txtRealm: document.querySelector('#Realm'),
|
txtRealm: document.querySelector('#Realm'),
|
||||||
txtResource: document.querySelector('#Resource'),
|
txtClientId: document.querySelector('#ClientId'),
|
||||||
txtClientSecret: document.querySelector('#ClientSecret'),
|
txtClientSecret: document.querySelector('#ClientSecret'),
|
||||||
|
txtOAuthScope: document.querySelector('#OAuthScope'),
|
||||||
|
txtRolesTokenAttribute: document.querySelector('#RolesTokenAttribute'),
|
||||||
|
txtUsernameTokenAttribute: document.querySelector('#UsernameTokenAttribute'),
|
||||||
|
chkEnable2Fa: document.querySelector('#Enable2Fa'),
|
||||||
|
chkAllowUsersWithoutRole: document.querySelector('#AllowUsersWithoutRole'),
|
||||||
|
chkEnableAllFolders: document.querySelector('#EnableAllFolders'),
|
||||||
|
libraryIdTable: document.querySelector("#LibraryIdTable"),
|
||||||
|
folderAccessList: document.querySelector("#FolderAccessList"),
|
||||||
};
|
};
|
||||||
|
|
||||||
document.querySelector('#TemplateConfigPage')
|
document.querySelector('#TemplateConfigPage')
|
||||||
.addEventListener('pageshow', function () {
|
.addEventListener('pageshow', function () {
|
||||||
Dashboard.showLoadingMsg();
|
Dashboard.showLoadingMsg();
|
||||||
ApiClient.getPluginConfiguration(KeycloakPluginConfig.pluginUniqueId).then(function (config) {
|
ApiClient.getPluginConfiguration(KeycloakPluginConfig.pluginUniqueId).then(function (config) {
|
||||||
|
KeycloakPluginConfig.chkEnabled.checked = config.Enabled;
|
||||||
KeycloakPluginConfig.chkCreateUser.checked = config.CreateUser;
|
KeycloakPluginConfig.chkCreateUser.checked = config.CreateUser;
|
||||||
KeycloakPluginConfig.txtAuthServerUrl.value = config.AuthServerUrl;
|
KeycloakPluginConfig.txtAuthServerUrl.value = config.AuthServerUrl;
|
||||||
KeycloakPluginConfig.txtRealm.value = config.Realm;
|
KeycloakPluginConfig.txtRealm.value = config.Realm;
|
||||||
KeycloakPluginConfig.txtResource.value = config.Resource;
|
KeycloakPluginConfig.txtClientId.value = config.ClientId;
|
||||||
KeycloakPluginConfig.txtClientSecret.value = config.ClientSecret;
|
KeycloakPluginConfig.txtClientSecret.value = config.ClientSecret;
|
||||||
Dashboard.hideLoadingMsg();
|
KeycloakPluginConfig.txtOAuthScope.value = config.OAuthScope;
|
||||||
|
KeycloakPluginConfig.txtRolesTokenAttribute.value = config.RolesTokenAttribute;
|
||||||
|
KeycloakPluginConfig.txtUsernameTokenAttribute.value = config.UsernameTokenAttribute;
|
||||||
|
KeycloakPluginConfig.chkEnable2Fa.checked = config.Enable2Fa;
|
||||||
|
KeycloakPluginConfig.chkEnableAllFolders.checked = config.EnableAllFolders;
|
||||||
|
KeycloakPluginConfig.chkAllowUsersWithoutRole.checked = config.AllowUsersWithoutRole;
|
||||||
|
loadMediaFolders(config).then(() => {
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -71,17 +131,67 @@
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
Dashboard.showLoadingMsg();
|
Dashboard.showLoadingMsg();
|
||||||
ApiClient.getPluginConfiguration(KeycloakPluginConfig.pluginUniqueId).then(function (config) {
|
ApiClient.getPluginConfiguration(KeycloakPluginConfig.pluginUniqueId).then(function (config) {
|
||||||
|
config.Enabled = KeycloakPluginConfig.chkEnabled.checked;
|
||||||
config.CreateUser = KeycloakPluginConfig.chkCreateUser.checked;
|
config.CreateUser = KeycloakPluginConfig.chkCreateUser.checked;
|
||||||
config.AuthServerUrl = KeycloakPluginConfig.txtAuthServerUrl.value;
|
config.AuthServerUrl = KeycloakPluginConfig.txtAuthServerUrl.value;
|
||||||
config.Realm = KeycloakPluginConfig.txtRealm.value;
|
config.Realm = KeycloakPluginConfig.txtRealm.value;
|
||||||
config.Resource = KeycloakPluginConfig.txtResource.value;
|
config.ClientId = KeycloakPluginConfig.txtClientId.value;
|
||||||
config.ClientSecret = KeycloakPluginConfig.txtClientSecret.value;
|
config.ClientSecret = KeycloakPluginConfig.txtClientSecret.value;
|
||||||
|
config.OAuthScope = KeycloakPluginConfig.txtOAuthScope.value;
|
||||||
|
config.RolesTokenAttribute = KeycloakPluginConfig.txtRolesTokenAttribute.value;
|
||||||
|
config.UsernameTokenAttribute = KeycloakPluginConfig.txtUsernameTokenAttribute.value;
|
||||||
|
config.Enable2Fa = KeycloakPluginConfig.chkEnable2Fa.checked;
|
||||||
|
config.EnableAllFolders = KeycloakPluginConfig.chkEnableAllFolders.checked;
|
||||||
|
config.AllowUsersWithoutRole = KeycloakPluginConfig.chkAllowUsersWithoutRole.checked;
|
||||||
|
|
||||||
|
let folders = document.querySelectorAll('#folderList input');
|
||||||
|
folders = Array.prototype.filter.call(folders, folder => folder.checked)
|
||||||
|
.map(folder => folder.getAttribute("data-id"));
|
||||||
|
config.EnabledFolders = folders;
|
||||||
|
|
||||||
ApiClient.updatePluginConfiguration(KeycloakPluginConfig.pluginUniqueId, config).then(function (result) {
|
ApiClient.updatePluginConfiguration(KeycloakPluginConfig.pluginUniqueId, config).then(function (result) {
|
||||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function escapeHTML(str){
|
||||||
|
return new Option(str).innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMediaFolders(config) {
|
||||||
|
return ApiClient.getJSON(ApiClient.getUrl("Library/MediaFolders", { IsHidden: false })).then((mediaFolders) => {
|
||||||
|
let html = "";
|
||||||
|
html += '<h3 class="checkboxListLabel">${HeaderLibraries}</h3>';
|
||||||
|
html +=
|
||||||
|
'<div id="folderList" class="checkboxList paperList checkboxList-paperList">';
|
||||||
|
let tableHtml = "<table><tbody>\n";
|
||||||
|
|
||||||
|
if (Array.isArray(mediaFolders.Items)) {
|
||||||
|
mediaFolders.Items.forEach((folder) => {
|
||||||
|
const isChecked =
|
||||||
|
config.EnableAllFolders ||
|
||||||
|
config.EnabledFolders.indexOf(folder.Id) != -1;
|
||||||
|
const checkedAttribute = isChecked ? " checked" : "";
|
||||||
|
html +=
|
||||||
|
'<label class="emby-checkbox-label"><input type="checkbox" is="emby-checkbox" class="chkFolder emby-checkbox" data-id="' +
|
||||||
|
folder.Id +
|
||||||
|
'" ' +
|
||||||
|
checkedAttribute +
|
||||||
|
"><span>" +
|
||||||
|
escapeHTML(folder.Name) +
|
||||||
|
"</span></label>";
|
||||||
|
tableHtml += "<tr><td><b>" + escapeHTML(folder.Name) + "</b></td><td><code>lib-" + folder.Id + "</code></td></tr>\n";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
html += "</div>";
|
||||||
|
tableHtml += "</tbody></table>";
|
||||||
|
KeycloakPluginConfig.folderAccessList.innerHTML = html;
|
||||||
|
KeycloakPluginConfig.libraryIdTable.innerHTML = tableHtml;
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
@ -1,9 +1,15 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
<AssemblyVersion>2.0.0.1</AssemblyVersion>
|
||||||
<FileVersion>1.0.0.0</FileVersion>
|
<FileVersion>2.0.0.1</FileVersion>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
|
||||||
|
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
|
||||||
|
<NoWarn>CA1819</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||||
|
@ -16,6 +22,13 @@
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Code Analyzers-->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
|
||||||
|
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
|
||||||
|
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Remove="Configuration\configPage.html" />
|
<None Remove="Configuration\configPage.html" />
|
||||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Data.Entities;
|
using Jellyfin.Data.Entities;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Plugin.Keycloak.Configuration;
|
||||||
using JWT.Builder;
|
using JWT.Builder;
|
||||||
using MediaBrowser.Common;
|
using MediaBrowser.Common;
|
||||||
using MediaBrowser.Common.Net;
|
using MediaBrowser.Common.Net;
|
||||||
|
@ -16,150 +20,24 @@ using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.Keycloak
|
namespace Jellyfin.Plugin.Keycloak
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// KeyCloak Authentication Provider Plugin.
|
||||||
|
/// </summary>
|
||||||
public class KeyCloakAuthenticationProviderPlugin : IAuthenticationProvider
|
public class KeyCloakAuthenticationProviderPlugin : IAuthenticationProvider
|
||||||
{
|
{
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private readonly ILogger<KeyCloakAuthenticationProviderPlugin> _logger;
|
private readonly ILogger<KeyCloakAuthenticationProviderPlugin> _logger;
|
||||||
private readonly IApplicationHost _applicationHost;
|
private readonly IApplicationHost _applicationHost;
|
||||||
private IUserManager _userManager;
|
private string _twoFactorPattern = @"(.*)(\d{6})$";
|
||||||
|
|
||||||
private bool CreateUser => Plugin.Instance.Configuration.CreateUser;
|
/// <summary>
|
||||||
private String AuthServerUrl => Plugin.Instance.Configuration.AuthServerUrl;
|
/// Initializes a new instance of the <see cref="KeyCloakAuthenticationProviderPlugin"/> class.
|
||||||
private String Realm => Plugin.Instance.Configuration.Realm;
|
/// </summary>
|
||||||
private String Resource => Plugin.Instance.Configuration.Resource;
|
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
|
||||||
private String ClientSecret => Plugin.Instance.Configuration.ClientSecret;
|
/// <param name="applicationHost">Instance of the <see cref="IApplicationHost"/> interface.</param>
|
||||||
|
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
|
||||||
private HttpClient GetHttpClient()
|
public KeyCloakAuthenticationProviderPlugin(
|
||||||
{
|
IHttpClientFactory httpClientFactory,
|
||||||
return _httpClientFactory.CreateClient(NamedClient.Default);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String TokenURI => $"{AuthServerUrl}/realms/{Realm}/protocol/openid-connect/token";
|
|
||||||
|
|
||||||
private async Task<KeycloakUser> GetKeycloakUser(string username, string password)
|
|
||||||
{
|
|
||||||
var httpClient = GetHttpClient();
|
|
||||||
var keyValues = new List<KeyValuePair<string, string>>();
|
|
||||||
keyValues.Add( new KeyValuePair<string, string>("username", username));
|
|
||||||
keyValues.Add( new KeyValuePair<string, string>("password", password));
|
|
||||||
keyValues.Add( new KeyValuePair<string, string>("grant_type", "password"));
|
|
||||||
keyValues.Add( new KeyValuePair<string, string>("client_id", Resource));
|
|
||||||
if (!String.IsNullOrWhiteSpace(ClientSecret))
|
|
||||||
{
|
|
||||||
keyValues.Add(new KeyValuePair<string, string>("client_secret", ClientSecret));
|
|
||||||
}
|
|
||||||
|
|
||||||
var content = new FormUrlEncodedContent(keyValues);
|
|
||||||
var response = await httpClient.PostAsync(TokenURI, content).ConfigureAwait(false);
|
|
||||||
var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
|
|
||||||
var parsed = await JsonSerializer.DeserializeAsync<KeycloakTokenResponse>(responseStream).ConfigureAwait(false);
|
|
||||||
if (parsed == null)
|
|
||||||
return null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var jwtToken = JwtBuilder.Create().Decode<IDictionary<string, object>>(parsed.access_token);
|
|
||||||
List<string> perms = new List<string>();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var resourceAccess = (JObject)jwtToken["resource_access"];
|
|
||||||
perms = ((JArray)(((JObject)resourceAccess[Resource])["roles"])).ToObject<List<string>>();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, $"Could not parse permissions for resource {Resource}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new KeycloakUser {Username = username, Permissions = perms};
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error parsing jwt token");
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<User> UpdateUserInfo(KeycloakUser keycloakUser, User jellyfinUser)
|
|
||||||
{
|
|
||||||
jellyfinUser.SetPermission(PermissionKind.IsDisabled, true);
|
|
||||||
jellyfinUser.SetPermission(PermissionKind.IsAdministrator, false);
|
|
||||||
jellyfinUser.SetPermission(PermissionKind.EnableContentDownloading, false);
|
|
||||||
foreach (string permission in keycloakUser.Permissions)
|
|
||||||
{
|
|
||||||
switch (permission)
|
|
||||||
{
|
|
||||||
case "administrator":
|
|
||||||
jellyfinUser.SetPermission(PermissionKind.IsAdministrator, true);
|
|
||||||
break;
|
|
||||||
case "allowed_access":
|
|
||||||
jellyfinUser.SetPermission(PermissionKind.IsDisabled, false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await _userManager.UpdateUserAsync(jellyfinUser).ConfigureAwait(false);
|
|
||||||
return jellyfinUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ProviderAuthenticationResult> Authenticate(string username, string password)
|
|
||||||
{
|
|
||||||
_userManager ??= _applicationHost.Resolve<IUserManager>();
|
|
||||||
User user = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
user = _userManager.GetUserByName(username);
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("User Manager could not find a user for Keycloak User, this may not be fatal", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
KeycloakUser keycloakUser = await GetKeycloakUser(username, password);
|
|
||||||
if (keycloakUser == null)
|
|
||||||
{
|
|
||||||
throw new AuthenticationException("Error completing Keycloak login. Invalid username or password.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
if (CreateUser)
|
|
||||||
{
|
|
||||||
_logger.LogInformation($"Creating user {username}");
|
|
||||||
user = await _userManager.CreateUserAsync(username).ConfigureAwait(false);
|
|
||||||
user.AuthenticationProviderId = GetType().FullName;
|
|
||||||
await UpdateUserInfo(keycloakUser, user);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogError("Keycloak User not configured for Jellyfin: {username}", username);
|
|
||||||
throw new AuthenticationException(
|
|
||||||
$"Automatic User Creation is disabled and there is no Jellyfin user for authorized Uid: {username}");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await UpdateUserInfo(keycloakUser, user);
|
|
||||||
}
|
|
||||||
if (user.HasPermission(PermissionKind.IsDisabled))
|
|
||||||
{
|
|
||||||
// If the user no longer has permission to access revoke all sessions for this user
|
|
||||||
_logger.LogInformation($"{username} is disabled, revoking all sessions");
|
|
||||||
var sessionHandler = _applicationHost.Resolve<ISessionManager>();
|
|
||||||
sessionHandler.RevokeUserTokens(user.Id, null);
|
|
||||||
}
|
|
||||||
return new ProviderAuthenticationResult { Username = username};
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool HasPassword(User user)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task ChangePassword(User user, string newPassword)
|
|
||||||
{
|
|
||||||
throw new System.NotImplementedException();
|
|
||||||
}
|
|
||||||
public KeyCloakAuthenticationProviderPlugin(IHttpClientFactory httpClientFactory,
|
|
||||||
IApplicationHost applicationHost,
|
IApplicationHost applicationHost,
|
||||||
ILogger<KeyCloakAuthenticationProviderPlugin> logger)
|
ILogger<KeyCloakAuthenticationProviderPlugin> logger)
|
||||||
{
|
{
|
||||||
|
@ -168,7 +46,242 @@ namespace Jellyfin.Plugin.Keycloak
|
||||||
_applicationHost = applicationHost;
|
_applicationHost = applicationHost;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string TokenUri => $"{Cfg().AuthServerUrl}/realms/{Cfg().Realm}/protocol/openid-connect/token";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public string Name => "Keycloak-Authentication";
|
public string Name => "Keycloak-Authentication";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public bool IsEnabled => true;
|
public bool IsEnabled => true;
|
||||||
|
|
||||||
|
private HttpClient GetHttpClient()
|
||||||
|
{
|
||||||
|
return _httpClientFactory.CreateClient(NamedClient.Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PluginConfiguration Cfg()
|
||||||
|
{
|
||||||
|
return Plugin.Instance.Configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(KeycloakUser?, bool)> GetKeycloakUser(string username, string password, string? totp)
|
||||||
|
{
|
||||||
|
var httpClient = GetHttpClient();
|
||||||
|
var keyValues = new List<KeyValuePair<string, string?>>
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string?>("username", username),
|
||||||
|
new KeyValuePair<string, string?>("password", password),
|
||||||
|
new KeyValuePair<string, string?>("grant_type", "password"),
|
||||||
|
new KeyValuePair<string, string?>("client_id", Cfg().ClientId)
|
||||||
|
};
|
||||||
|
if (!string.IsNullOrWhiteSpace(Cfg().ClientSecret))
|
||||||
|
{
|
||||||
|
keyValues.Add(new KeyValuePair<string, string?>("client_secret", Cfg().ClientSecret));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(Cfg().OAuthScope))
|
||||||
|
{
|
||||||
|
keyValues.Add(new KeyValuePair<string, string?>("scope", Cfg().OAuthScope));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(totp))
|
||||||
|
{
|
||||||
|
keyValues.Add(new KeyValuePair<string, string?>("totp", totp));
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = new FormUrlEncodedContent(keyValues);
|
||||||
|
var response = await httpClient.PostAsync(TokenUri, content).ConfigureAwait(false);
|
||||||
|
var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
KeycloakErrorResponse? err = await JsonSerializer.DeserializeAsync<KeycloakErrorResponse>(responseStream).ConfigureAwait(false);
|
||||||
|
if (err == null)
|
||||||
|
{
|
||||||
|
return (null, false);
|
||||||
|
}
|
||||||
|
else if (err.Error == "invalid_grant")
|
||||||
|
{
|
||||||
|
return (null, true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError("Keycloak error {0}: {1}", err.Error, err.ErrorDescription);
|
||||||
|
return (null, err.Error == "invalid_grant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KeycloakTokenResponse? parsed = await JsonSerializer.DeserializeAsync<KeycloakTokenResponse>(responseStream).ConfigureAwait(false);
|
||||||
|
if (parsed == null)
|
||||||
|
{
|
||||||
|
_logger.LogError("Error parsing Keycloak token response");
|
||||||
|
return (null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var jwtToken = JwtBuilder.Create().Decode<IDictionary<string, object>>(parsed.AccessToken);
|
||||||
|
|
||||||
|
Collection<string> perms = new Collection<string>();
|
||||||
|
var permsObj = GetJwtAttribute(jwtToken, Cfg().RolesTokenAttribute);
|
||||||
|
if (permsObj != null)
|
||||||
|
{
|
||||||
|
perms = ((JArray)permsObj).ToObject<Collection<string>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var usernameObj = GetJwtAttribute(jwtToken, Cfg().UsernameTokenAttribute);
|
||||||
|
if (usernameObj != null)
|
||||||
|
{
|
||||||
|
username = (string)usernameObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
KeycloakUser user = new KeycloakUser(username, perms);
|
||||||
|
|
||||||
|
return (user, false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error parsing jwt token");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateUserInfo(KeycloakUser keycloakUser, User? jellyfinUser)
|
||||||
|
{
|
||||||
|
var userManager = _applicationHost.Resolve<IUserManager>();
|
||||||
|
if (jellyfinUser != null)
|
||||||
|
{
|
||||||
|
jellyfinUser.SetPermission(PermissionKind.IsDisabled, !keycloakUser.IsEnabled);
|
||||||
|
jellyfinUser.SetPermission(PermissionKind.IsAdministrator, keycloakUser.IsAdmin);
|
||||||
|
jellyfinUser.SetPermission(PermissionKind.EnableCollectionManagement, keycloakUser.IsEditor);
|
||||||
|
jellyfinUser.SetPermission(PermissionKind.EnableSubtitleManagement, keycloakUser.IsEditor);
|
||||||
|
jellyfinUser.SetPermission(PermissionKind.EnableAllFolders, Cfg().EnableAllFolders || keycloakUser.IsAdmin);
|
||||||
|
|
||||||
|
var enabledFolders = new HashSet<string>(Cfg().EnabledFolders);
|
||||||
|
enabledFolders.UnionWith(keycloakUser.EnabledFolders);
|
||||||
|
jellyfinUser.SetPreference(PreferenceKind.EnabledFolders, enabledFolders.ToArray());
|
||||||
|
|
||||||
|
await userManager.UpdateUserAsync(jellyfinUser).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object? GetJwtAttribute(IDictionary<string, object> jwt, string tokenAttribute)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(tokenAttribute))
|
||||||
|
{
|
||||||
|
var parts = tokenAttribute.Split('.');
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IDictionary<string, object> subObj = jwt;
|
||||||
|
for (int i = 0; i < parts.Length - 1; i++)
|
||||||
|
{
|
||||||
|
subObj = (IDictionary<string, object>)subObj[parts[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return subObj[parts[^1]];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Could not extract {TokenAttribute} from JWT", tokenAttribute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<ProviderAuthenticationResult> Authenticate(string username, string password)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Keycloak login: {Username}", username);
|
||||||
|
var userManager = _applicationHost.Resolve<IUserManager>();
|
||||||
|
string password_t = password;
|
||||||
|
string? totp = null;
|
||||||
|
if (Cfg().Enable2Fa)
|
||||||
|
{
|
||||||
|
var match = Regex.Match(password, _twoFactorPattern);
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
password_t = match.Groups[1].Value;
|
||||||
|
totp = match.Groups[2].Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(KeycloakUser? keycloakUser, bool retryNoOtp) = await GetKeycloakUser(username, password_t, totp).ConfigureAwait(false);
|
||||||
|
if (keycloakUser == null && totp != null && retryNoOtp)
|
||||||
|
{
|
||||||
|
(keycloakUser, _) = await GetKeycloakUser(username, password, null).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keycloakUser == null)
|
||||||
|
{
|
||||||
|
throw new AuthenticationException("Error completing Keycloak login. Invalid username or password.");
|
||||||
|
}
|
||||||
|
|
||||||
|
keycloakUser.IsEnabled |= Cfg().AllowUsersWithoutRole;
|
||||||
|
|
||||||
|
User? user = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
user = userManager.GetUserByName(keycloakUser.Username);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("User Manager could not find a user for Keycloak User, this may not be fatal: {E}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
if (!keycloakUser.IsEnabled)
|
||||||
|
{
|
||||||
|
_logger.LogError("Keycloak User not allowed to use Jellyfin: {Username}", username);
|
||||||
|
throw new AuthenticationException("User is not allowed to use Jellyfin");
|
||||||
|
}
|
||||||
|
else if (Cfg().CreateUser)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Creating user: {Username}", username);
|
||||||
|
user = await userManager.CreateUserAsync(username).ConfigureAwait(false);
|
||||||
|
var userAuthenticationProviderId = GetType().FullName;
|
||||||
|
if (userAuthenticationProviderId != null)
|
||||||
|
{
|
||||||
|
user.AuthenticationProviderId = userAuthenticationProviderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await UpdateUserInfo(keycloakUser, user).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError("Keycloak User not configured for Jellyfin: {Username}", username);
|
||||||
|
throw new AuthenticationException(
|
||||||
|
$"Automatic User Creation is disabled and there is no Jellyfin user for authorized Uid: {username}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await UpdateUserInfo(keycloakUser, user).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user != null && user.HasPermission(PermissionKind.IsDisabled))
|
||||||
|
{
|
||||||
|
// If the user no longer has permission to access revoke all sessions for this user
|
||||||
|
_logger.LogInformation("{Username} is disabled, revoking all sessions", username);
|
||||||
|
var sessionHandler = _applicationHost.Resolve<ISessionManager>();
|
||||||
|
await sessionHandler.RevokeUserTokens(user.Id, null).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ProviderAuthenticationResult { Username = username };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool HasPassword(User user)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task ChangePassword(User user, string newPassword)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.Keycloak
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
24
Jellyfin.Plugin.Keycloak/KeycloakErrorResponse.cs
Normal file
24
Jellyfin.Plugin.Keycloak/KeycloakErrorResponse.cs
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Keycloak
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Response model for Keycloak errors.
|
||||||
|
/// </summary>
|
||||||
|
[DataContract]
|
||||||
|
public class KeycloakErrorResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets error code.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("error")]
|
||||||
|
public string? Error { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets error description.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("error_description")]
|
||||||
|
public string? ErrorDescription { get; set; }
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,17 +1,54 @@
|
||||||
using System.Runtime.Serialization;
|
using System.Runtime.Serialization;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.Keycloak
|
namespace Jellyfin.Plugin.Keycloak
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Response model for the keycloak token.
|
||||||
|
/// </summary>
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class KeycloakTokenResponse
|
public class KeycloakTokenResponse
|
||||||
{
|
{
|
||||||
public string access_token { get; set; }
|
/// <summary>
|
||||||
public int expires_in { get; set; }
|
/// Gets or sets the access token instance.
|
||||||
[DataMember(Name = "not-before-policy")]
|
/// </summary>
|
||||||
public int not_before_policy { get; set; }
|
[JsonPropertyName("access_token")]
|
||||||
public string refresh_token { get; set; }
|
public string? AccessToken { get; set; }
|
||||||
public string scope { get; set; }
|
|
||||||
public string session_state { get; set; }
|
/// <summary>
|
||||||
public string token_type { get; set; }
|
/// Gets or sets the expiry time instance.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("expires_in")]
|
||||||
|
public int ExpiresIn { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the not-before-policy instance.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("not-before-policy")]
|
||||||
|
public int NotBeforePolicy { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the refresh token instance.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("refresh_token")]
|
||||||
|
public string? RefreshToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the scope instance.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("scope")]
|
||||||
|
public string? Scope { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the session state instance.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("session_state")]
|
||||||
|
public string? SessionState { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the token type instance.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("token_type")]
|
||||||
|
public string? TokenType { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,72 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.Keycloak
|
namespace Jellyfin.Plugin.Keycloak
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// User Model for Keycloak Users.
|
||||||
|
/// </summary>
|
||||||
public class KeycloakUser
|
public class KeycloakUser
|
||||||
{
|
{
|
||||||
public string Username { get; set; }
|
/// <summary>
|
||||||
public List<string> Permissions { get; set; }
|
/// Initializes a new instance of the <see cref="KeycloakUser"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="username">Instance of the username of the user.</param>
|
||||||
|
/// <param name="roles">User roles.</param>
|
||||||
|
public KeycloakUser(string username, Collection<string> roles)
|
||||||
|
{
|
||||||
|
Username = username;
|
||||||
|
Roles = roles;
|
||||||
|
|
||||||
|
IsAdmin = false;
|
||||||
|
IsEditor = false;
|
||||||
|
IsEnabled = false;
|
||||||
|
var enabledFolders = new List<string>();
|
||||||
|
|
||||||
|
foreach (string permission in Roles)
|
||||||
|
{
|
||||||
|
IsAdmin |= permission == "admin";
|
||||||
|
IsEditor |= permission == "editor";
|
||||||
|
IsEnabled |= permission == "user";
|
||||||
|
|
||||||
|
if (permission.StartsWith("lib-", System.StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
enabledFolders.Add(permission[4..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IsEnabled |= IsAdmin || IsEditor;
|
||||||
|
EnabledFolders = enabledFolders.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the value of the username of an user.
|
||||||
|
/// </summary>
|
||||||
|
public string Username { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the value of roles of an user.
|
||||||
|
/// </summary>
|
||||||
|
public Collection<string> Roles { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a list of the enabled media libraries of an user.
|
||||||
|
/// </summary>
|
||||||
|
public string[] EnabledFolders { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the user is an administrator.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsAdmin { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the user is an editor.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEditor { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether the user should be enabled.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEnabled { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,19 +8,33 @@ using MediaBrowser.Model.Serialization;
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.Keycloak
|
namespace Jellyfin.Plugin.Keycloak
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Keycloak Plugin.
|
||||||
|
/// </summary>
|
||||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||||
{
|
{
|
||||||
public override string Name => "Keycloak-Auth";
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||||
public override Guid Id => Guid.Parse("40886866-b3dd-4d6a-bf9b-25c83e6c3d10");
|
/// </summary>
|
||||||
|
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||||
|
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/>interface.</param>
|
||||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer)
|
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer)
|
||||||
{
|
{
|
||||||
Instance = this;
|
Instance = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Plugin Instance { get; private set; }
|
/// <summary>
|
||||||
|
/// Gets the plugin instance.
|
||||||
|
/// </summary>
|
||||||
|
public static Plugin Instance { get; private set; } = null!;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Name => "Keycloak-Auth";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override Guid Id => Guid.Parse("40886866-b3dd-4d6a-bf9b-25c83e6c3d10");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public IEnumerable<PluginPageInfo> GetPages()
|
public IEnumerable<PluginPageInfo> GetPages()
|
||||||
{
|
{
|
||||||
return new[]
|
return new[]
|
||||||
|
@ -28,7 +42,7 @@ namespace Jellyfin.Plugin.Keycloak
|
||||||
new PluginPageInfo
|
new PluginPageInfo
|
||||||
{
|
{
|
||||||
Name = this.Name,
|
Name = this.Name,
|
||||||
EmbeddedResourcePath = string.Format("{0}.Configuration.configPage.html", GetType().Namespace)
|
EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configPage.html"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
18
Jellyfin.Plugin.Keycloak/ServiceRegistrator.cs
Normal file
18
Jellyfin.Plugin.Keycloak/ServiceRegistrator.cs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Authentication;
|
||||||
|
using MediaBrowser.Controller.Plugins;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Keycloak;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register LDAP services.
|
||||||
|
/// </summary>
|
||||||
|
public class ServiceRegistrator : IPluginServiceRegistrator
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||||
|
{
|
||||||
|
serviceCollection.AddSingleton<IAuthenticationProvider, KeyCloakAuthenticationProviderPlugin>();
|
||||||
|
}
|
||||||
|
}
|
118
jellyfin.ruleset
Normal file
118
jellyfin.ruleset
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RuleSet Name="Rules for Jellyfin.Server" Description="Code analysis rules for Jellyfin.Server.csproj" ToolsVersion="14.0">
|
||||||
|
<Rules AnalyzerId="StyleCop.Analyzers" RuleNamespace="StyleCop.Analyzers">
|
||||||
|
<!-- disable warning SA1009: Closing parenthesis should be followed by a space. -->
|
||||||
|
<Rule Id="SA1009" Action="None" />
|
||||||
|
<!-- disable warning SA1011: Closing square bracket should be followed by a space. -->
|
||||||
|
<Rule Id="SA1011" Action="None" />
|
||||||
|
<!-- disable warning SA1101: Prefix local calls with 'this.' -->
|
||||||
|
<Rule Id="SA1101" Action="None" />
|
||||||
|
<!-- disable warning SA1108: Block statements should not contain embedded comments -->
|
||||||
|
<Rule Id="SA1108" Action="None" />
|
||||||
|
<!-- disable warning SA1118: Parameter must not span multiple lines. -->
|
||||||
|
<Rule Id="SA1118" Action="None" />
|
||||||
|
<!-- disable warning SA1128:: Put constructor initializers on their own line -->
|
||||||
|
<Rule Id="SA1128" Action="None" />
|
||||||
|
<!-- disable warning SA1130: Use lambda syntax -->
|
||||||
|
<Rule Id="SA1130" Action="None" />
|
||||||
|
<!-- disable warning SA1200: 'using' directive must appear within a namespace declaration -->
|
||||||
|
<Rule Id="SA1200" Action="None" />
|
||||||
|
<!-- disable warning SA1202: 'public' members must come before 'private' members -->
|
||||||
|
<Rule Id="SA1202" Action="None" />
|
||||||
|
<!-- disable warning SA1204: Static members must appear before non-static members -->
|
||||||
|
<Rule Id="SA1204" Action="None" />
|
||||||
|
<!-- disable warning SA1309: Fields must not begin with an underscore -->
|
||||||
|
<Rule Id="SA1309" Action="None" />
|
||||||
|
<!-- disable warning SA1413: Use trailing comma in multi-line initializers -->
|
||||||
|
<Rule Id="SA1413" Action="None" />
|
||||||
|
<!-- disable warning SA1512: Single-line comments must not be followed by blank line -->
|
||||||
|
<Rule Id="SA1512" Action="None" />
|
||||||
|
<!-- disable warning SA1515: Single-line comment should be preceded by blank line -->
|
||||||
|
<Rule Id="SA1515" Action="None" />
|
||||||
|
<!-- disable warning SA1600: Elements should be documented -->
|
||||||
|
<Rule Id="SA1600" Action="None" />
|
||||||
|
<!-- disable warning SA1602: Enumeration items should be documented -->
|
||||||
|
<Rule Id="SA1602" Action="None" />
|
||||||
|
<!-- disable warning SA1633: The file header is missing or not located at the top of the file -->
|
||||||
|
<Rule Id="SA1633" Action="None" />
|
||||||
|
</Rules>
|
||||||
|
|
||||||
|
<Rules AnalyzerId="Microsoft.CodeAnalysis.NetAnalyzers" RuleNamespace="Microsoft.Design">
|
||||||
|
<!-- error on CA1063: Implement IDisposable correctly -->
|
||||||
|
<Rule Id="CA1063" Action="Error" />
|
||||||
|
<!-- error on CA1305: Specify IFormatProvider -->
|
||||||
|
<Rule Id="CA1305" Action="Error" />
|
||||||
|
<!-- error on CA1307: Specify StringComparison for clarity -->
|
||||||
|
<Rule Id="CA1307" Action="Error" />
|
||||||
|
<!-- error on CA1309: Use ordinal StringComparison -->
|
||||||
|
<Rule Id="CA1309" Action="Error" />
|
||||||
|
<!-- error on CA1725: Parameter names should match base declaration -->
|
||||||
|
<Rule Id="CA1725" Action="Error" />
|
||||||
|
<!-- error on CA1725: Call async methods when in an async method -->
|
||||||
|
<Rule Id="CA1727" Action="Error" />
|
||||||
|
<!-- error on CA1813: Avoid unsealed attributes -->
|
||||||
|
<Rule Id="CA1813" Action="Error" />
|
||||||
|
<!-- error on CA1843: Do not use 'WaitAll' with a single task -->
|
||||||
|
<Rule Id="CA1843" Action="Error" />
|
||||||
|
<!-- error on CA1845: Use span-based 'string.Concat' -->
|
||||||
|
<Rule Id="CA1845" Action="Error" />
|
||||||
|
<!-- error on CA2016: Forward the CancellationToken parameter to methods that take one
|
||||||
|
or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token -->
|
||||||
|
<Rule Id="CA2016" Action="Error" />
|
||||||
|
<!-- error on CA2254: Template should be a static expression -->
|
||||||
|
<Rule Id="CA2254" Action="Error" />
|
||||||
|
|
||||||
|
<!-- disable warning CA1014: Mark assemblies with CLSCompliantAttribute -->
|
||||||
|
<Rule Id="CA1014" Action="Info" />
|
||||||
|
<!-- disable warning CA1024: Use properties where appropriate -->
|
||||||
|
<Rule Id="CA1024" Action="Info" />
|
||||||
|
<!-- disable warning CA1031: Do not catch general exception types -->
|
||||||
|
<Rule Id="CA1031" Action="Info" />
|
||||||
|
<!-- disable warning CA1032: Implement standard exception constructors -->
|
||||||
|
<Rule Id="CA1032" Action="Info" />
|
||||||
|
<!-- disable warning CA1040: Avoid empty interfaces -->
|
||||||
|
<Rule Id="CA1040" Action="Info" />
|
||||||
|
<!-- disable warning CA1062: Validate arguments of public methods -->
|
||||||
|
<Rule Id="CA1062" Action="Info" />
|
||||||
|
<!-- TODO: enable when false positives are fixed -->
|
||||||
|
<!-- disable warning CA1508: Avoid dead conditional code -->
|
||||||
|
<Rule Id="CA1508" Action="Info" />
|
||||||
|
<!-- disable warning CA1716: Identifiers should not match keywords -->
|
||||||
|
<Rule Id="CA1716" Action="Info" />
|
||||||
|
<!-- disable warning CA1720: Identifiers should not contain type names -->
|
||||||
|
<Rule Id="CA1720" Action="Info" />
|
||||||
|
<!-- disable warning CA1724: Type names should not match namespaces -->
|
||||||
|
<Rule Id="CA1724" Action="Info" />
|
||||||
|
<!-- disable warning CA1805: Do not initialize unnecessarily -->
|
||||||
|
<Rule Id="CA1805" Action="Info" />
|
||||||
|
<!-- disable warning CA1812: internal class that is apparently never instantiated.
|
||||||
|
If so, remove the code from the assembly.
|
||||||
|
If this class is intended to contain only static members, make it static -->
|
||||||
|
<Rule Id="CA1812" Action="Info" />
|
||||||
|
<!-- disable warning CA1822: Member does not access instance data and can be marked as static -->
|
||||||
|
<Rule Id="CA1822" Action="Info" />
|
||||||
|
<!-- disable warning CA2000: Dispose objects before losing scope -->
|
||||||
|
<Rule Id="CA2000" Action="Info" />
|
||||||
|
<!-- disable warning CA2253: Named placeholders should not be numeric values -->
|
||||||
|
<Rule Id="CA2253" Action="Info" />
|
||||||
|
<!-- disable warning CA5394: Do not use insecure randomness -->
|
||||||
|
<Rule Id="CA5394" Action="Info" />
|
||||||
|
|
||||||
|
<!-- disable warning CA1054: Change the type of parameter url from string to System.Uri -->
|
||||||
|
<Rule Id="CA1054" Action="None" />
|
||||||
|
<!-- disable warning CA1055: URI return values should not be strings -->
|
||||||
|
<Rule Id="CA1055" Action="None" />
|
||||||
|
<!-- disable warning CA1056: URI properties should not be strings -->
|
||||||
|
<Rule Id="CA1056" Action="None" />
|
||||||
|
<!-- disable warning CA1303: Do not pass literals as localized parameters -->
|
||||||
|
<Rule Id="CA1303" Action="None" />
|
||||||
|
<!-- disable warning CA1308: Normalize strings to uppercase -->
|
||||||
|
<Rule Id="CA1308" Action="None" />
|
||||||
|
<!-- disable warning CA1848: Use the LoggerMessage delegates -->
|
||||||
|
<Rule Id="CA1848" Action="None" />
|
||||||
|
<!-- disable warning CA2101: Specify marshaling for P/Invoke string arguments -->
|
||||||
|
<Rule Id="CA2101" Action="None" />
|
||||||
|
<!-- disable warning CA2234: Pass System.Uri objects instead of strings -->
|
||||||
|
<Rule Id="CA2234" Action="None" />
|
||||||
|
</Rules>
|
||||||
|
</RuleSet>
|
Loading…
Add table
Reference in a new issue