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
1082 lines
50 KiB
C#
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;
|
|
}
|
|
}
|