Files
timmy b20d993f33 Updates
Use a consistent http and oidc clients
Use the access token to send an a authenticated request to retrieve avatar and do it at the same time as requesting the userinfo endpoint
Merge JTokens instead of re-parsing json strings to merge configuration
2026-08-22 16:59:28 -07:00

1082 lines
50 KiB
C#

using System.IO;
using System.Linq;
using AspAuth = Microsoft.AspNetCore.Authorization;
using AspMVC = Microsoft.AspNetCore.Mvc;
using CodeAnalysis = System.Diagnostics.CodeAnalysis;
using Collections = System.Collections.Generic;
using DuendeClient = Duende.IdentityModel.Client;
using DuendeOidc = Duende.IdentityModel.OidcClient;
using JFAuth = MediaBrowser.Controller.Authentication;
using JFConfig = MediaBrowser.Controller.Configuration;
using JFCrypto = MediaBrowser.Model.Cryptography;
using JFEntities = Jellyfin.Database.Implementations.Entities;
using JFLibrary = MediaBrowser.Controller.Library;
using JFNet = MediaBrowser.Model.Net;
using JFNetController = MediaBrowser.Controller.Net;
using JFProvider = MediaBrowser.Controller.Providers;
using JFSession = MediaBrowser.Controller.Session;
using Json = Newtonsoft.Json;
using Logging = Microsoft.Extensions.Logging;
using MSMimeTypes = System.Net.Mime.MediaTypeNames;
using StatusCodes = Microsoft.AspNetCore.Http.StatusCodes;
using Threading = System.Threading.Tasks;
namespace Jellyfin.Plugin.OIDC_Auth.Api;
/// <summary>
/// The OIDC api controller.
/// </summary>
[AspMVC.ApiController]
[AspMVC.Route("[controller]")]
public partial class OIDCController : AspMVC.ControllerBase {
private static readonly Collections.IDictionary<string, TimedAuthorizeState> StateManager = new Collections.Dictionary<string, TimedAuthorizeState>();
private readonly JFNetController.IAuthorizationContext _authContext;
private readonly JFCrypto.ICryptoProvider _cryptoProvider;
private readonly System.Net.Http.IHttpClientFactory _httpClientFactory;
private readonly Logging.ILogger<OIDCController> _logger;
private readonly Logging.ILoggerFactory _loggerFactory;
private readonly JFProvider.IProviderManager _providerManager;
private readonly JFConfig.IServerConfigurationManager _serverConfigurationManager;
private readonly JFSession.ISessionManager _sessionManager;
private readonly JFLibrary.IUserManager _userManager;
/// <summary>
/// Initializes a new instance of the <see cref="OIDCController" /> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="Logging.ILogger{SSOController}" /> interface.</param>
/// <param name="loggerFactory">Instance of the <see cref="Logging.ILoggerFactory" /> interface.</param>
/// <param name="sessionManager">Instance of the <see cref="JFSession.ISessionManager" /> interface.</param>
/// <param name="authContext">Instance of the <see cref="JFNetController.IAuthorizationContext" /> interface.</param>
/// <param name="userManager">Instance of the <see cref="JFLibrary.IUserManager" /> interface.</param>
/// <param name="cryptoProvider">Instance of the <see cref="JFCrypto.ICryptoProvider" /> interface.</param>
/// <param name="providerManager">
/// Instance of the <see cref="JFProvider.IProviderManager" />
/// interface.
/// </param>
/// <param name="httpClientFactory">Instance of the <see cref="System.Net.Http.IHttpClientFactory" /> interface.</param>
/// <param name="serverConfigurationManager">
/// Instance of the
/// <see cref="JFConfig.IServerConfigurationManager" /> interface.
/// </param>
public OIDCController(
Logging.ILogger<OIDCController> logger,
Logging.ILoggerFactory loggerFactory,
JFSession.ISessionManager sessionManager,
JFLibrary.IUserManager userManager,
JFNetController.IAuthorizationContext authContext,
JFCrypto.ICryptoProvider cryptoProvider,
JFProvider.IProviderManager providerManager,
System.Net.Http.IHttpClientFactory httpClientFactory,
JFConfig.IServerConfigurationManager serverConfigurationManager
) {
this._sessionManager = sessionManager;
this._userManager = userManager;
this._authContext = authContext;
this._cryptoProvider = cryptoProvider;
this._logger = logger;
this._loggerFactory = loggerFactory;
this._providerManager = providerManager;
this._serverConfigurationManager = serverConfigurationManager;
this._httpClientFactory = httpClientFactory;
Logging.LoggerExtensions.LogInformation(this._logger, "OIDC Controller initialized");
}
/// <summary>
/// The GET endpoint for OpenID provider to callback to. Returns a webpage that parses client data and completes auth.
/// </summary>
/// <param name="provider">The ID of the provider which will use the callback information.</param>
/// <param name="state">The current request state.</param>
/// <returns>A webpage that will complete the client-side flow.</returns>
// Actually a GET: https://github.com/IdentityModel/IdentityModel.OidcClient/issues/325
[AspMVC.HttpGet("redirect/{provider}")]
[AspMVC.ResponseCache(Location = AspMVC.ResponseCacheLocation.None, NoStore = true)]
public async Threading.Task<AspMVC.ActionResult> OIDCCallback(
[AspMVC.FromRoute] string provider,
[AspMVC.FromQuery] string state
) {
OIDC_Auth.Config config;
try {
config = OIDCPlugin.Instance.Configuration.Configs[provider];
} catch (Collections.KeyNotFoundException) {
return this.BadRequest("No matching provider found");
}
if (!config.Enabled) {
return this.BadRequest("No matching provider found");
}
if (string.IsNullOrWhiteSpace(state)) {
return this.BadRequest("Missing state");
}
if (!OIDCController.StateManager.TryGetValue(state, out TimedAuthorizeState timedState) || !timedState.IsCurrent()) {
Logging.LoggerExtensions.LogWarning(this._logger, "Restarting login, login not found for state: {state}", state);
OIDCController.StateManager.Remove(state);
return this.Redirect(this.GetRequestBase(config.PortOverride, Path.Combine("oidc/start", provider)).ToString());
}
DuendeOidc.OidcClient oidcClient = this.NewOidcClient(provider, config);
DuendeOidc.AuthorizeState currentState = timedState.State;
DuendeOidc.LoginResult result = await oidcClient
.ProcessResponseAsync(this.Request.QueryString.Value, currentState)
.ConfigureAwait(false);
if (result.IsError) {
return this.ReturnError(StatusCodes.Status400BadRequest, $"Error logging in: {result.Error} - {result.ErrorDescription}");
}
// All the actual verification happens above, but Duende/Microsoft butcher the claims so now we extract it somewhat sanely
byte[] jsonStr = [];
try {
jsonStr = System.Buffers.Text.Base64Url.DecodeFromChars(result.IdentityToken.Split(".")[1]);
} catch (System.FormatException e) {
return this.ReturnError(StatusCodes.Status400BadRequest, $"Error logging in: {e} - {e.Message}<br/><br/>{result.IdentityToken}");
}
Json.Linq.JObject claims = Json.Linq.JObject.Parse(System.Text.Encoding.UTF8.GetString(jsonStr));
if (config.LoadProfile) {
var userinfo = await this.GetUserInfoAsync(oidcClient, result.AccessToken).ConfigureAwait(false);
claims.Merge(
userinfo,
new() {
MergeArrayHandling = Json.Linq.MergeArrayHandling.Replace,
MergeNullValueHandling = Json.Linq.MergeNullValueHandling.Merge,
PropertyNameComparison = System.StringComparison.InvariantCultureIgnoreCase
}
);
}
Json.Linq.JToken claim;
if (
claims.TryGetValue(string.IsNullOrWhiteSpace(config.UsernameClaim) ? "preferred_username" : config.UsernameClaim.Trim(), out claim)
&& claim.Type == Json.Linq.JTokenType.String
&& !string.IsNullOrWhiteSpace((string)claim)
) {
timedState.Username = ((string)claim).Trim();
Logging.LoggerExtensions.LogWarning(this._logger, "Extracted username {username}", timedState.Username);
} else {
Logging.LoggerExtensions.LogWarning(
this._logger,
"Failed to get the username from the OIDC user Claims: {@claim}: {@Claims}",
string.IsNullOrWhiteSpace(config.UsernameClaim) ? "preferred_username" : config.UsernameClaim.Trim(),
claims
);
}
if (
claims.TryGetValue("sub", out claim)
&& claim.Type == Json.Linq.JTokenType.String
&& !string.IsNullOrWhiteSpace((string)claim)
) {
timedState.Sub = ((string)claim).Trim();
} else {
Logging.LoggerExtensions.LogWarning(this._logger, "Invalid OIDC user Claims: no sub claim found");
return this.ReturnError(StatusCodes.Status401Unauthorized, "Error. Invalid OIDC user Claims: no sub claim found.");
}
// Role processing
Collections.HashSet<string> roles = [];
if (!string.IsNullOrWhiteSpace(config.RoleClaim) && claims.TryGetValue(config.RoleClaim, out claim)) {
if (claim.Type == Json.Linq.JTokenType.String) {
roles = [(string)claim];
} else if (claim.Type == Json.Linq.JTokenType.Array && claim.Count() > 0) {
roles.UnionWith(claim.Select(t => (string)t).ToList());
} else {
Logging.LoggerExtensions.LogWarning(
this._logger,
"Roles were not extracted, expected a string or list of string got ({type}): {roles}.",
claim.GetType(),
claim
);
}
}
if (config.EnableAuthorization) {
timedState.Admin = roles.Contains(config.AdminRole);
timedState.Valid = timedState.Admin || string.IsNullOrWhiteSpace(config.UserRole) || roles.Contains(config.UserRole);
} else {
timedState.Valid = true;
}
Json.Linq.JObject mergedConfig = this.extractConfigs(config, claims);
// If the provider doesn't support the preferred username claim, then use the sub claim
if (string.IsNullOrWhiteSpace(timedState.Username)) {
timedState.Username = timedState.Sub;
}
(System.Guid userID, string errorMessage) = await this.CreateCanonicalLinkAndUserIfNotExist(provider, timedState.Sub, timedState.Username);
if (userID == System.Guid.Empty) {
return this.ReturnError(StatusCodes.Status401Unauthorized, $"Unable to link user: {errorMessage}");
}
if (!string.IsNullOrWhiteSpace(config.AvatarUrlClaim)) {
if (claims.TryGetValue(config.AvatarUrlClaim.Trim(), out claim) && claim.Type == Json.Linq.JTokenType.String && !string.IsNullOrWhiteSpace((string)claim)) {
try {
await this.UpdateUserProfileImage(userID, ((string)claim).Trim(), result.AccessToken).ConfigureAwait(false);
} catch (System.Exception e) {
Logging.LoggerExtensions.LogError(this._logger, "Failed to update the profile image ({avatarUrl}) for {username}({userID}): {errorMessage}", new[] { ((string)claim).Trim(), timedState.Username, userID.ToString(), e.Message });
}
}
}
await this.updateUser(userID, mergedConfig);
if (!timedState.Valid) {
Logging.LoggerExtensions.LogWarning(
this._logger,
"Failed to validate OIDC user Claims: {@Claims}",
claims
);
return this.ReturnError(StatusCodes.Status401Unauthorized, "Error. User does not have permission to login.");
}
Logging.LoggerExtensions.LogInformation(this._logger, "Is request linking: {isLinking}", new[] { timedState.IsLinking });
return this.Content(
WebResponse.Generator(state, provider, this.GetRequestBase(config.PortOverride).Uri, timedState.IsLinking),
MSMimeTypes.Text.Html
);
}
private Json.Linq.JObject extractConfigs(OIDC_Auth.Config config, Json.Linq.JObject claims) {
Json.Linq.JObject configs = new();
Json.Linq.JToken dataClaim;
foreach (string configClaim in config.DataClaims.Reverse<string>()) {
if (!claims.TryGetValue(configClaim, out dataClaim)) {
continue;
}
if (dataClaim.Type == Json.Linq.JTokenType.String) {
configs.Merge(
Json.Linq.JObject.Parse((string)dataClaim),
new() {
MergeArrayHandling = Json.Linq.MergeArrayHandling.Replace,
MergeNullValueHandling = Json.Linq.MergeNullValueHandling.Merge,
PropertyNameComparison = System.StringComparison.InvariantCultureIgnoreCase
}
);
} else if (dataClaim.Type == Json.Linq.JTokenType.Object) {
configs.Merge(
dataClaim,
new() {
MergeArrayHandling = Json.Linq.MergeArrayHandling.Replace,
MergeNullValueHandling = Json.Linq.MergeNullValueHandling.Merge,
PropertyNameComparison = System.StringComparison.InvariantCultureIgnoreCase
}
);
}
}
if (!string.IsNullOrWhiteSpace(config.DataClaimPrefix)) {
foreach (Json.Linq.JProperty claim in claims.Properties().Where((claim) => claim.Name.StartsWith(config.DataClaimPrefix)).OrderByDescending((claim) => claim.Name)) {
if (claim.Type == Json.Linq.JTokenType.String) {
configs.Merge(
Json.Linq.JObject.Parse((string)claim),
new() {
MergeArrayHandling = Json.Linq.MergeArrayHandling.Replace,
MergeNullValueHandling = Json.Linq.MergeNullValueHandling.Merge,
PropertyNameComparison = System.StringComparison.InvariantCultureIgnoreCase
}
);
} else if (claim.Type == Json.Linq.JTokenType.Object) {
configs.Merge(
claim,
new() {
MergeArrayHandling = Json.Linq.MergeArrayHandling.Replace,
MergeNullValueHandling = Json.Linq.MergeNullValueHandling.Merge,
PropertyNameComparison = System.StringComparison.InvariantCultureIgnoreCase
}
);
}
}
}
return configs;
}
/// <summary>
/// Initiates the login flow for OpenID. This redirects the user to the auth provider.
/// </summary>
/// <param name="provider">The name of the provider.</param>
/// <param name="isLinking">Whether or not this request is to link accounts (Rather than authenticate).</param>
/// <returns>An asynchronous result for the authentication.</returns>
[AspMVC.HttpGet("start/{provider}")]
[AspMVC.ResponseCache(Location = AspMVC.ResponseCacheLocation.None, NoStore = true)]
public async Threading.Task<AspMVC.ActionResult> OIDCChallenge(
string provider,
[AspMVC.FromQuery] bool isLinking = false
) {
OIDCController.Invalidate();
OIDC_Auth.Config config;
if (!OIDCPlugin.Instance.Configuration.Configs.TryGetValue(provider, out config)) {
throw new System.ArgumentException("Provider does not exist"); // TODO: manually http error
}
if (!config.Enabled) {
throw new System.ArgumentException("Provider is not enabled");
}
DuendeOidc.OidcClient oidcClient = this.NewOidcClient(provider, config);
DuendeOidc.AuthorizeState state = await oidcClient.PrepareLoginAsync().ConfigureAwait(false);
if (state.IsError) {
return this.ReturnError(
StatusCodes.Status400BadRequest,
$"Error preparing login: {state.Error} - {state.ErrorDescription}"
);
}
OIDCController.StateManager.Add(state.State, new TimedAuthorizeState(state, System.DateTime.Now, provider));
// Track whether this is a linking request or not.
OIDCController.StateManager[state.State].IsLinking = isLinking;
return this.Redirect(state.StartUrl);
}
/// <summary>
/// Adds an OpenID auth configuration. Requires administrator privileges. If the provider already exists, it will be
/// removed and readded.
/// </summary>
/// <param name="provider">The name of the provider to add.</param>
/// <param name="config">The OIDC configuration (deserialized from a JSON post).</param>
[AspAuth.Authorize(Policy = MediaBrowser.Common.Api.Policies.RequiresElevation)]
[AspMVC.HttpPost("Add/{provider}")]
[AspMVC.ResponseCache(Location = AspMVC.ResponseCacheLocation.None, NoStore = true)]
public void OIDCAdd(
string provider,
[AspMVC.FromBody] OIDC_Auth.Config config
) {
OIDC_Auth.PluginConfiguration configuration = OIDCPlugin.Instance.Configuration;
configuration.Configs[provider] = config;
OIDCPlugin.Instance.UpdateConfiguration(configuration);
}
/// <summary>
/// Deletes an OpenID provider.
/// </summary>
/// <param name="provider">Name of provider to delete.</param>
[AspAuth.Authorize(Policy = MediaBrowser.Common.Api.Policies.RequiresElevation)]
[AspMVC.HttpGet("Del/{provider}")]
[AspMVC.ResponseCache(Location = AspMVC.ResponseCacheLocation.None, NoStore = true)]
public void OIDCDel(string provider) {
OIDC_Auth.PluginConfiguration configuration = OIDCPlugin.Instance.Configuration;
configuration.Configs.Remove(provider);
OIDCPlugin.Instance.UpdateConfiguration(configuration);
}
/// <summary>
/// Lists the OpenID providers configured. Requires administrator privileges.
/// </summary>
/// <returns>The list of OpenID configurations.</returns>
[AspAuth.Authorize(Policy = MediaBrowser.Common.Api.Policies.RequiresElevation)]
[AspMVC.HttpGet("Get")]
[AspMVC.ResponseCache(Location = AspMVC.ResponseCacheLocation.None, NoStore = true)]
public AspMVC.ActionResult OIDCProviders() {
return this.Ok(OIDCPlugin.Instance.Configuration.Configs);
}
/// <summary>
/// Gets the default config for an OpenID configuration.
/// </summary>
/// <returns>A default OpenID configurations.</returns>
[AspAuth.Authorize(Policy = MediaBrowser.Common.Api.Policies.RequiresElevation)]
[AspMVC.HttpGet("Get/default")]
[AspMVC.ResponseCache(Location = AspMVC.ResponseCacheLocation.None, NoStore = true)]
public AspMVC.ActionResult OIDCProviderDefault() {
return this.Ok(new OIDC_Auth.Config());
}
/// <summary>
/// Lists the OpenID providers names only.
/// </summary>
/// <returns>The list of OpenID configurations.</returns>
[AspMVC.HttpGet("GetNames")]
[AspMVC.ResponseCache(Location = AspMVC.ResponseCacheLocation.None, NoStore = true)]
public AspMVC.ActionResult OIDCProviderNames() {
return this.Ok(OIDCPlugin.Instance.Configuration.Configs.Keys);
}
/// <summary>
/// This is a debug endpoint to list all running OpenID flows. Requires administrator privileges.
/// </summary>
/// <returns>The list of OpenID flows in progress.</returns>
[AspAuth.Authorize(Policy = MediaBrowser.Common.Api.Policies.RequiresElevation)]
[AspMVC.HttpGet("States")]
[AspMVC.ResponseCache(Location = AspMVC.ResponseCacheLocation.None, NoStore = true)]
public AspMVC.ActionResult OIDCStates() {
return this.Ok(OIDCController.StateManager);
}
/// <summary>
/// This endpoint accepts JSON and will authorize the user from the device values passed from the client.
/// </summary>
/// <param name="provider">Name of provider to authenticate against.</param>
/// <param name="response">The data passed to the client to ensure it is the right one.</param>
/// <returns>JSON for the client to populate information with.</returns>
[AspMVC.HttpPost("Auth/{provider}")]
[AspMVC.Consumes(MSMimeTypes.Application.Json)]
[AspMVC.Produces(MSMimeTypes.Application.Json)]
[AspMVC.ResponseCache(Location = AspMVC.ResponseCacheLocation.None, NoStore = true)]
public async Threading.Task<AspMVC.ActionResult> OIDCAuth(
string provider,
[AspMVC.FromBody] AuthResponse response
) {
OIDC_Auth.Config config;
try {
config = OIDCPlugin.Instance.Configuration.Configs[provider];
} catch (Collections.KeyNotFoundException) {
return this.BadRequest("No matching provider found");
}
if (!config.Enabled) {
return this.BadRequest("Provider is not enabled");
}
TimedAuthorizeState state;
if (!OIDCController.StateManager.TryGetValue(response.Data, out state) || !state.IsCurrent()) {
return this.BadRequest($"login not found for state {response.Data}");
}
System.Guid userID = await this.GetCanonicalLink(provider, state.Sub, state.Username);
if (userID == System.Guid.Empty) {
return this.Problem("Unable to link user");
}
JFAuth.AuthenticationResult authenticationResult = await this.Authenticate(
userID,
state.Admin,
config.EnableAuthorization,
response,
config.DefaultProvider?.Trim()
).ConfigureAwait(false);
OIDCController.StateManager.Remove(state.State.State);
return this.Ok(authenticationResult);
}
/// <summary>
/// Removes a user from OIDC Auth and switches it back to another auth provider. Requires administrator privileges.
/// </summary>
/// <param name="username">The username to switch to the new provider.</param>
/// <param name="provider">The new provider to switch to.</param>
/// <returns>Whether this API endpoint succeeded.</returns>
[AspAuth.Authorize(Policy = MediaBrowser.Common.Api.Policies.RequiresElevation)]
[AspMVC.HttpPost("Unregister/{username}")]
[AspMVC.ResponseCache(Location = AspMVC.ResponseCacheLocation.None, NoStore = true)]
public AspMVC.ActionResult Unregister(string username, [AspMVC.FromBody] string provider) {
JFEntities.User user = this._userManager.GetUserByName(username);
user.AuthenticationProviderId = provider;
return this.Ok();
}
private OIDC_Auth.Config GetConfig(string provider) {
OIDC_Auth.Config cfg;
if (OIDCPlugin.Instance.Configuration.Configs.TryGetValue(provider, out cfg)) {
return cfg;
}
return new OIDC_Auth.Config();
}
private async Threading.Task updateUser(System.Guid userID, Json.Linq.JObject claims) {
JFEntities.User user = this._userManager.GetUserById(userID);
if (user == null) {
return;
}
var original_dto = this._userManager.GetUserDto(user);
var dto = this._userManager.GetUserDto(user);
Json.JsonConvert.PopulateObject(claims.ToString(), dto.Configuration);
Json.JsonConvert.PopulateObject(claims.ToString(), dto.Policy);
dto.Policy.IsAdministrator = original_dto.Policy.IsAdministrator;
dto.Policy.IsDisabled = original_dto.Policy.IsDisabled;
if (string.IsNullOrWhiteSpace(dto.Policy.AuthenticationProviderId)) {
dto.Policy.AuthenticationProviderId = original_dto.Policy.AuthenticationProviderId;
}
if (string.IsNullOrWhiteSpace(dto.Policy.PasswordResetProviderId)) {
dto.Policy.PasswordResetProviderId = original_dto.Policy.PasswordResetProviderId;
}
await this._userManager.UpdateConfigurationAsync(user.Id, dto.Configuration).ConfigureAwait(false);
await this._userManager.UpdatePolicyAsync(user.Id, dto.Policy).ConfigureAwait(false);
}
private async Threading.Task<(System.Guid, string)> CreateCanonicalLinkAndUserIfNotExist(string provider, string providerID, string providerUsername) {
JFEntities.User user = null;
// First try to get the user by its id in case it was already registered before
OIDC_Auth.Config config = this.GetConfig(provider);
System.Guid userID = System.Guid.Empty;
if (config.CanonicalLinks.TryGetValue(providerID, out userID)) {
user = this._userManager.GetUserById(userID);
if (user == null) {
config.CanonicalLinks.Remove(providerID);
return (System.Guid.Empty, "It appears this user was deleted. Please try again.");
}
} else {
user = this._userManager.GetUserByName(providerUsername);
}
if (user == null) {
Logging.LoggerExtensions.LogInformation(this._logger, "OIDC user {canonicalName} ({canonicalId}) doesn't exist, creating...", providerUsername, providerID);
user = await this._userManager.CreateUserAsync(providerUsername).ConfigureAwait(false);
} else {
Logging.LoggerExtensions.LogInformation(this._logger, "OIDC user link doesn't exist, linking... user: {user}({userID})", user.Username, user.Id); // Move log to config.CreateCanonicalLink
}
(bool success, string errorMessage) = config.CreateCanonicalLink(user.Id, providerID);
if (!success) {
Logging.LoggerExtensions.LogInformation(this._logger, "Unable to link OIDC user {canonicalName} ({canonicalId}) to JellyFin user {user}({userID}): {message}", providerUsername, providerID, user.Username, user.Id, errorMessage);
return (System.Guid.Empty, errorMessage);
}
Logging.LoggerExtensions.LogInformation(this._logger, "User is linked to OIDC Provider {canonicalName} ({canonicalId}) to JellyFin user {user}({userID}): {message}", providerUsername, providerID, user.Username, user.Id, errorMessage);
if (user.Username != providerUsername) {
Logging.LoggerExtensions.LogInformation(this._logger, "OIDC user {canonicalName} ({canonicalId}) has mismatched username {username}, updating...", providerUsername, providerID, user.Username);
await this._userManager.RenameUser(user.Id, user.Username, providerUsername).ConfigureAwait(false);
// Normally need to get the new user but we only use the id
}
return (user.Id, errorMessage);
}
private async Threading.Task<System.Guid> GetCanonicalLink(string provider, string providerID, string providerUsername) {
JFEntities.User user = null;
// First try to get the user by its id in case it was already registered before
OIDC_Auth.Config config = this.GetConfig(provider);
System.Guid userID = System.Guid.Empty;
if (config.CanonicalLinks.TryGetValue(providerID, out userID)) {
user = this._userManager.GetUserById(userID);
}
if (user.Username != providerUsername && this._userManager.GetUserByName(providerUsername) == null) {
Logging.LoggerExtensions.LogInformation(this._logger, "OIDC user {canonicalName} ({canonicalId}) has mismatched username {username}, updating...", providerUsername, providerID, user.Username);
await this._userManager.RenameUser(user.Id, user.Username, providerUsername).ConfigureAwait(false);
}
return user.Id;
}
/// <summary>
/// Create a canonical link for a given user. Must be performed by the user being changed, or admin.
/// </summary>
/// <param name="provider">The name of the provider to link to a jellyfin account.</param>
/// <param name="jellyfinUserId">The user ID within jellyfin to link to the provider.</param>
/// <param name="authResponse">The client information to authenticate the user with.</param>
/// <returns>Whether this API endpoint succeeded.</returns>
[AspAuth.Authorize]
[AspMVC.HttpPost("Link/{provider}/{jellyfinUserId}")]
[AspMVC.Consumes(MSMimeTypes.Application.Json)]
[AspMVC.Produces(MSMimeTypes.Application.Json)]
[AspMVC.ResponseCache(Location = AspMVC.ResponseCacheLocation.None, NoStore = true)]
public async Threading.Task<AspMVC.ActionResult> AddCanonicalLink(
[AspMVC.FromRoute] string provider,
[AspMVC.FromRoute] System.Guid jellyfinUserId,
[AspMVC.FromBody] AuthResponse authResponse
) {
if (
!await OIDC_Auth.Helpers.RequestHelpers
.AssertCanUpdateUser(this._authContext, this.HttpContext.Request, jellyfinUserId, true)
.ConfigureAwait(false)
) {
return this.StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to link OIDC providers.");
}
return this.OIDCLink(provider, jellyfinUserId, authResponse);
}
/// <summary>
/// Unregisters a given mapping from id within provider to user.
/// </summary>
/// <param name="provider">The name of the provider from which the link should be removed.</param>
/// <param name="jellyfinUserId">The user ID within jellyfin to unlink from the provider.</param>
/// <param name="providerID">The user ID within jellyfin to unlink.</param>
/// <returns>Whether this API endpoint succeeded.</returns>
[AspAuth.Authorize]
[AspMVC.HttpDelete("Link/{provider}/{jellyfinUserId}/{providerID}")]
[AspMVC.Consumes(MSMimeTypes.Application.Json)]
[AspMVC.Produces(MSMimeTypes.Application.Json)]
[AspMVC.ResponseCache(Location = AspMVC.ResponseCacheLocation.None, NoStore = true)]
public async Threading.Task<AspMVC.ActionResult> DeleteCanonicalLink(
[AspMVC.FromRoute] string provider,
[AspMVC.FromRoute] System.Guid jellyfinUserId,
[AspMVC.FromRoute] string providerID
) {
if (
!await OIDC_Auth.Helpers.RequestHelpers
.AssertCanUpdateUser(this._authContext, this.HttpContext.Request, jellyfinUserId, true)
.ConfigureAwait(false)
) {
return this.StatusCode(StatusCodes.Status403Forbidden, "Current user is not allowed to unlink OIDC providers for user ID.");
}
OIDC_Auth.Config config = this.GetConfig(provider);
string linkedID;
if (!config.ReverseCanonicalLinks.TryGetValue(jellyfinUserId, out linkedID)) {
return this.StatusCode(StatusCodes.Status409Conflict, "jellyfin UID is not registered with this provider.");
}
if (linkedID != providerID) {
return this.StatusCode(StatusCodes.Status409Conflict, "jellyfin UID is not registered with the given provider ID.");
}
config.ReverseCanonicalLinks.Remove(jellyfinUserId);
config.CanonicalLinks.Remove(linkedID);
OIDCPlugin.Instance.UpdateConfiguration(OIDCPlugin.Instance.Configuration);
return this.NoContent();
}
/// <summary>
/// Gets all the OIDC links for a user.
/// </summary>
/// <param name="jellyfinUserId">The user ID within jellyfin for which to return the links.</param>
/// <returns>A dictionary of provider : link mappings.</returns>
[AspAuth.Authorize]
[AspMVC.HttpGet("links/{jellyfinUserId}")]
[AspMVC.Produces(MSMimeTypes.Application.Json)]
[AspMVC.ResponseCache(Location = AspMVC.ResponseCacheLocation.None, NoStore = true)]
public async Threading.Task<AspMVC.ActionResult<SerializableDictionary<string, Collections.IEnumerable<string>>>> GetOidLinksByUser(System.Guid jellyfinUserId) {
if (
!await OIDC_Auth.Helpers.RequestHelpers
.AssertCanUpdateUser(this._authContext, this.HttpContext.Request, jellyfinUserId, true)
.ConfigureAwait(false)
) {
return this.StatusCode(StatusCodes.Status403Forbidden, "Non-admin is not allowed to query other user's mappings.");
}
SerializableDictionary<string, Collections.IEnumerable<string>> mappings = new();
SerializableDictionary<string, OIDC_Auth.Config> providerList = OIDCPlugin.Instance.Configuration.Configs;
foreach (string providerName in providerList.Keys) {
SerializableDictionary<string, System.Guid> canonLinks = providerList[providerName].CanonicalLinks;
Collections.IEnumerable<string> canonKeys = from link in canonLinks where link.Value == jellyfinUserId select link.Key;
mappings[providerName] = canonKeys;
}
return mappings;
}
/// <summary>
/// Validate an OIDC link request and create the link if it is valid.
/// </summary>
/// <param name="provider">The provider to authenticate against.</param>
/// <param name="jellyfinUserId">
/// The ID of the account to be linked to the provider.
/// Must be performed by this user, or an admin.
/// </param>
/// <param name="response">The data passed to the client to ensure it is the right one.</param>
/// <returns>JSON for the client to populate information with.</returns>
[AspMVC.Consumes(MSMimeTypes.Application.Json)]
[AspMVC.Produces(MSMimeTypes.Application.Json)]
[AspMVC.ResponseCache(Location = AspMVC.ResponseCacheLocation.None, NoStore = true)]
private AspMVC.ActionResult OIDCLink(string provider, System.Guid jellyfinUserId, AuthResponse response) {
OIDC_Auth.Config config;
try {
config = OIDCPlugin.Instance.Configuration.Configs[provider];
} catch (Collections.KeyNotFoundException) {
return this.BadRequest("No matching provider found");
}
TimedAuthorizeState state;
if (!OIDCController.StateManager.TryGetValue(response.Data, out state) || state.Valid || !state.IsCurrent()) {
return this.Problem("No initial linking request found!");
}
string providerUserId = state.Username;
return this.CreateCanonicalLink(provider, jellyfinUserId, providerUserId);
}
private AspMVC.ActionResult CreateCanonicalLink(string provider, System.Guid jellyfinUserId, string providerUserId) {
var providerConfig = this.GetConfig(provider);
if (!providerConfig.Enabled) {
return this.BadRequest($"Provider {provider} is not enabled");
}
(bool success, string errorMessage) = providerConfig.CreateCanonicalLink(jellyfinUserId, providerUserId);
if (success) {
OIDCPlugin.Instance.UpdateConfiguration(OIDCPlugin.Instance.Configuration);
return this.NoContent();
}
return this.BadRequest(errorMessage);
}
/// <summary>
/// Authenticates the user with the given information.
/// </summary>
/// <param name="userID">The user id of the user to authenticate.</param>
/// <param name="isAdmin">Determines whether this user is an administrator.</param>
/// <param name="enableAuthorization">Determines whether RBAC is used for this user.</param>
/// <param name="authResponse">The client information to authenticate the user with.</param>
/// <param name="defaultProvider">The default provider of the user to be set after logging in.</param>
private async Threading.Task<JFAuth.AuthenticationResult> Authenticate(
System.Guid userID,
bool isAdmin,
bool enableAuthorization,
AuthResponse authResponse,
string defaultProvider
) {
JFEntities.User user = this._userManager.GetUserById(userID);
if (enableAuthorization) {
Jellyfin.Data.UserEntityExtensions.SetPermission(user, Jellyfin.Database.Implementations.Enums.PermissionKind.IsAdministrator, isAdmin);
}
JFSession.AuthenticationRequest authRequest = new();
authRequest.UserId = user.Id;
authRequest.Username = user.Username;
authRequest.App = authResponse.AppName;
authRequest.AppVersion = authResponse.AppVersion;
authRequest.DeviceId = authResponse.DeviceID;
authRequest.DeviceName = authResponse.DeviceName;
Logging.LoggerExtensions.LogInformation(this._logger, "Auth request created...");
if (!string.IsNullOrWhiteSpace(defaultProvider) && !string.IsNullOrWhiteSpace(user.AuthenticationProviderId)) {
user.AuthenticationProviderId = defaultProvider;
Logging.LoggerExtensions.LogInformation(this._logger, "Set default login provider to {provider}", defaultProvider);
}
await this._userManager.UpdateUserAsync(user).ConfigureAwait(false);
return await this._sessionManager.AuthenticateDirect(authRequest).ConfigureAwait(false);
}
// Adapted from ImageController
private async Threading.Task UpdateUserProfileImage(System.Guid userID, string avatarUrl, string accessToken) {
JFEntities.User user = this._userManager.GetUserById(userID);
if (user == null) {
return;
}
if (user is null) {
return;
}
System.Net.Http.HttpClient client = this.NewHTTPClient();
System.Net.Http.HttpRequestMessage request = new(System.Net.Http.HttpMethod.Get, avatarUrl);
request.Headers.Authorization = new("Bearer", accessToken);
System.Net.Http.HttpResponseMessage avatarResponse = client.Send(request);
if (!avatarResponse.Content.Headers.TryGetValues("content-type", out Collections.IEnumerable<string> contentTypeList)) {
throw new System.Exception("Cannot get Content-Type of image : " + avatarUrl);
}
string contentType = contentTypeList.First();
if (!contentType.StartsWith("image")) {
throw new System.Exception("Content type of avatar URL is not an image, got : " + contentType);
}
if (!OIDCController.GetImageExtension(Request.ContentType, out string extension)) {
return;
}
using (Request.Body) {
// Handle image/png; charset=utf-8
var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
if (user.ProfileImage is not null) {
await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
}
user.ProfileImage = new Database.Implementations.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + extension));
await _providerManager
.SaveImage(Request.Body, mimeType, user.ProfileImage.Path)
.ConfigureAwait(false);
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
}
}
// Adapted from TryGetImageExtensionFromContentType in ImageController.cs
internal static bool GetImageExtension(string contentType, [CodeAnalysis.NotNullWhen(true)] out string extension) {
extension = null;
if (string.IsNullOrWhiteSpace(contentType)) {
return false;
}
if (System.Net.Http.Headers.MediaTypeHeaderValue.TryParse(contentType, out var parsedValue)
&& parsedValue.MediaType != null
&& JFNet.MimeTypes.IsImage(parsedValue.MediaType)) {
extension = JFNet.MimeTypes.ToExtension(parsedValue.MediaType);
return extension is not null;
}
return false;
}
/// <summary>
/// Gets all the OIDC links for a user.
/// </summary>
/// <returns>A dictionary of provider : link mappings.</returns>
[AspMVC.HttpGet("OIDC-Auth-auth.html")]
public AspMVC.ActionResult AuthHTML() {
return this.ServeEmbeddedPage("auth.html", "text/html; charset=utf-8");
}
/// <summary>
/// Gets all the OIDC links for a user.
/// </summary>
/// <returns>A dictionary of provider : link mappings.</returns>
[AspMVC.HttpGet("OIDC-Auth-linking.html")]
public AspMVC.ActionResult LinkingHTML() {
return this.ServeEmbeddedPage("linking.html", "text/html; charset=utf-8");
}
/// <summary>
/// Gets all the OIDC links for a user.
/// </summary>
/// <returns>A dictionary of provider : link mappings.</returns>
[AspMVC.HttpGet("OIDC-Auth-linking.js")]
public AspMVC.ActionResult LinkingJS() {
return this.ServeEmbeddedPage("linking.js", "application/javascript");
}
/// <summary>
/// Gets all the OIDC links for a user.
/// </summary>
/// <returns>A dictionary of provider : link mappings.</returns>
[AspMVC.HttpGet("OIDC-Auth-config.js")]
public AspMVC.ActionResult ConfigJS() {
return this.ServeEmbeddedPage("config.js", "application/javascript");
}
private static void Invalidate() {
System.DateTime now = System.DateTime.Now;
foreach (Collections.KeyValuePair<string, TimedAuthorizeState> kvp in OIDCController.StateManager) {
if (!kvp.Value.IsCurrent()) {
OIDCController.StateManager.Remove(kvp.Key);
}
}
}
private System.UriBuilder GetRequestBase(int? portOverride = null, string pathAppend = null) {
var uri = new System.UriBuilder(
scheme: "https",
host: this.Request.Host.Host,
port: this.Request.Host.Port ?? -1,
pathValue: this.Request.PathBase
);
if (portOverride != null) {
uri.Port = portOverride.Value;
}
// Scheme is always https.
// Disallow putting http(80) on https(443)
if (new int[] { 80, 443 }.Contains(uri.Port)) {
uri.Port = -1;
}
if (pathAppend != null) {
uri.Path = Path.Combine(uri.Path, pathAppend);
}
return uri;
}
/// <summary>
/// Creates a new oidc client.
/// </summary>
/// <param name="provider">Name of OIDC Provider.</param>
/// <param name="config">Config for OIDC Provider.</param>
/// <returns>User info.</returns>
public DuendeOidc.OidcClient NewOidcClient(string provider, OIDC_Auth.Config config) {
System.UriBuilder redirectUri = this.GetRequestBase(config.PortOverride, Path.Combine("oidc/redirect", provider));
Collections.List<string> scopes = new() { "openid" };
if (config.Scopes != null) {
scopes.AddRange(config.Scopes);
}
string issuer = config.Issuer.Trim();
if (issuer.EndsWith("/.well-known/openid-configuration")) {
issuer = issuer[..^33];
}
DuendeOidc.OidcClientOptions options = new() {
Authority = config.Issuer.Trim(),
ClientId = config.ClientId.Trim(),
ClientSecret = config.ClientSecret?.Trim(),
RedirectUri = redirectUri.ToString(),
Scope = string.Join(" ", scopes.Distinct()),
DisablePushedAuthorization = !config.PushedAuthorization,
LoggerFactory = this._loggerFactory,
HttpClientFactory = o => {
System.Reflection.Assembly assembly = System.Reflection.Assembly.GetExecutingAssembly();
System.Diagnostics.FileVersionInfo fvi = System.Diagnostics.FileVersionInfo.GetVersionInfo(assembly.Location);
string version = fvi.FileVersion;
System.Net.Http.HttpClient client = this._httpClientFactory.CreateClient(assembly.FullName);
client.DefaultRequestHeaders.UserAgent.Clear();
client.DefaultRequestHeaders.UserAgent.ParseAdd($"{assembly.FullName}/{version} (https://gitea.narnian.us/lordwelch/jellyfin-plugin-oidc)");
return client;
},
LoadProfile = false, // We do this manually
FilterClaims = false,
Policy = new() {
Discovery = new() {
// For Google and other providers with different endpoints.
// Enabling this is more restrictive than the spec for OIDC.
// Realistically most IDPs will use the same base url for all of their endpoints but it makes no sense to enable by default
ValidateEndpoints = config.ValidateEndpoints,
ValidateIssuerName = config.ValidateIssuerName,
},
},
};
return new DuendeOidc.OidcClient(options);
}
/// <summary>
/// Creates a new HTTP Client configured for with appropriate headers.
/// </summary>
/// <returns>User info.</returns>
public System.Net.Http.HttpClient NewHTTPClient() {
System.Net.Http.HttpClient client = System.Net.Http.HttpClientFactoryExtensions.CreateClient(this._httpClientFactory);
System.Reflection.Assembly assembly = System.Reflection.Assembly.GetExecutingAssembly();
System.Diagnostics.FileVersionInfo fvi = System.Diagnostics.FileVersionInfo.GetVersionInfo(assembly.Location);
string version = fvi.FileVersion;
client.DefaultRequestHeaders.UserAgent.ParseAdd($"Jellyfin-Plugin-OIDC-Auth +{version} (https://gitea.narnian.us/lordwelch/jellyfin-plugin-oidc)");
return client;
}
/// <summary>
/// Gets userinfo using an oidc client.
/// </summary>
/// <param name="oidcClient">oidcClient.</param>
/// <param name="accessToken">accessToken.</param>
/// <returns>User info.</returns>
public async Threading.Task<Json.Linq.JObject> GetUserInfoAsync(DuendeOidc.OidcClient oidcClient, string accessToken) {
System.Net.Http.HttpClient httpClient = this.NewHTTPClient();
DuendeClient.UserInfoRequest userinfoRequest = new() {
Address = oidcClient.Options.ProviderInformation.UserInfoEndpoint,
Token = accessToken
};
System.Threading.CancellationToken cancellationToken = default;
DuendeClient.UserInfoResponse userinfo = await DuendeClient.HttpClientUserInfoExtensions.GetUserInfoAsync(
httpClient,
userinfoRequest,
cancellationToken
).ConfigureAwait(false);
return Json.Linq.JObject.Parse(userinfo.Raw);
}
private AspMVC.ContentResult ReturnError(int code, string message) {
AspMVC.ContentResult errorResult = new();
errorResult.Content = message;
errorResult.ContentType = MSMimeTypes.Text.Plain;
errorResult.StatusCode = code;
return errorResult;
}
private AspMVC.ActionResult ServeEmbeddedPage(string filename, string contentType) {
OIDCPlugin plugin = OIDCPlugin.Instance;
string content = plugin.GetPage(filename);
if (contentType.Contains("html")) {
content = plugin.GetHTMLPage(content, this.GetRequestBase().Uri.ToString());
}
// Copied from JellyFin Security
// Anti-framing: /Setup reveals recovery codes and QR secret on screen;
// /Challenge has a "Trust this device" click target. Both are prime
// clickjacking targets. frame-ancestors 'none' is the modern equivalent
// of X-Frame-Options: DENY; include both for browser coverage.
Response.Headers["X-Frame-Options"] = "DENY";
Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate";
Response.Headers["Content-Security-Policy"] = "frame-ancestors 'none'";
Response.Headers["X-Content-Type-Options"] = "nosniff";
Response.Headers["Referrer-Policy"] = "no-referrer";
return this.Content(content, contentType);
}
}
/// <summary>
/// The data the client should pass back to the API.
/// </summary>
public class AuthResponse {
/// <summary>
/// Gets or sets the device ID of the client.
/// </summary>
public string DeviceID { get; set; }
/// <summary>
/// Gets or sets the device name of the client.
/// </summary>
public string DeviceName { get; set; }
/// <summary>
/// Gets or sets the app name of the client.
/// </summary>
public string AppName { get; set; }
/// <summary>
/// Gets or sets the app version of the client.
/// </summary>
public string AppVersion { get; set; }
/// <summary>
/// Gets or sets the auth data of the client (for authorizing the response).
/// </summary>
public string Data { get; set; }
}
/// <summary>
/// A manager for OpenID to manage the state of the clients.
/// </summary>
public class TimedAuthorizeState {
// Investigate If this actually protects against anything. I don't think it does.
/// <summary>
/// Initializes a new instance of the <see cref="TimedAuthorizeState" /> class.
/// </summary>
/// <param name="state">The AuthorizeState to time.</param>
/// <param name="created">When this state was created.</param>
/// <param name="provider">Provider this state is created for.</param>
public TimedAuthorizeState(DuendeOidc.AuthorizeState state, System.DateTime created, string provider) {
this.State = state;
this.Created = created;
this.Valid = false;
this.Admin = false;
this.IsLinking = false;
this.Provider = provider;
}
/// <summary>
/// Gets or sets the Authorization State of the client.
/// </summary>
public DuendeOidc.AuthorizeState State { get; set; }
/// <summary>
/// Gets or sets when this object was created to time it out.
/// </summary>
public System.DateTime Created { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user is valid.
/// </summary>
public bool Valid { get; set; }
/// <summary>
/// Gets or sets the user name tied to the state.
/// </summary>
public string Username { get; set; }
/// <summary>
/// Gets or sets the user id tied to the state.
/// </summary>
public string Sub { get; set; }
/// <summary>
/// Gets or sets the provider that started this login attempt.
/// </summary>
public string Provider { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user is an administrator.
/// </summary>
public bool Admin { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the state is
/// tied to a linking flow (instead of a login flow).
/// </summary>
public bool IsLinking { get; set; }
/// <summary>
/// Checks if this state is still valid.
/// </summary>
/// <returns>Whether this state is current.</returns>
public bool IsCurrent() {
OIDC_Auth.Config config;
if (!OIDCPlugin.Instance.Configuration.Configs.TryGetValue(this.Provider, out config)) {
return false;
}
if (config.AuthenticationTimeout == 0) {
return true;
}
return System.DateTime.Now.Subtract(this.Created).TotalMinutes <= config.AuthenticationTimeout;
}
}