Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Avatar via OIDC Provider #202

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ These all require authorization. Append an API key to the end of the request: `c
- Leave empty to only request the default scopes.
- `defaultProvider`: string. The set provider then gets assigned to the user after they have logged in. If it is not set, nothing is changed. With this, a user can login with SSO but is still able to log in via other providers later. See the `Unregister` endpoint.
- `defaultUsernameClaim`: string. The provider will use the claim to create the users' usernames. If not set, it fallbacks to `preferred_username`.
- `avatarUrlFormat`: string. The URL format for the users avatars. OIDC claims can be used by using the `@{claim_type}` syntax. If not set, the avatars won't change.
- `disableHttps`: boolean. Determines whether the OpenID discovery endpoint requires HTTPS.
- `doNotValidateEndpoints`: boolean. Determines whether the OpenID discovery process will validate endpoints. This may be required for Google.
- `doNotValidateIssuerName`: boolean. Determines whether the OpenID discovery process will validate the OpenID issuer name.
Expand Down
84 changes: 79 additions & 5 deletions SSO-Auth/Api/SSOController.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Mime;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
Expand All @@ -12,14 +14,15 @@
using Jellyfin.Plugin.SSO_Auth.Helpers;
using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Cryptography;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
Expand All @@ -38,6 +41,8 @@ public class SSOController : ControllerBase
private readonly IAuthorizationContext _authContext;
private readonly ILogger<SSOController> _logger;
private readonly ICryptoProvider _cryptoProvider;
private readonly IProviderManager _providerManager;
private readonly IServerConfigurationManager _serverConfigurationManager;
private static readonly IDictionary<string, TimedAuthorizeState> StateManager = new Dictionary<string, TimedAuthorizeState>();

/// <summary>
Expand All @@ -48,13 +53,24 @@ public class SSOController : ControllerBase
/// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
/// <param name="cryptoProvider">Instance of the <see cref="ICryptoProvider"/> interface.</param>
public SSOController(ILogger<SSOController> logger, ISessionManager sessionManager, IUserManager userManager, IAuthorizationContext authContext, ICryptoProvider cryptoProvider)
/// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
public SSOController(
ILogger<SSOController> logger,
ISessionManager sessionManager,
IUserManager userManager,
IAuthorizationContext authContext,
ICryptoProvider cryptoProvider,
IProviderManager providerManager,
IServerConfigurationManager serverConfigurationManager)
{
_sessionManager = sessionManager;
_userManager = userManager;
_authContext = authContext;
_cryptoProvider = cryptoProvider;
_logger = logger;
_providerManager = providerManager;
_serverConfigurationManager = serverConfigurationManager;
_logger.LogInformation("SSO Controller initialized");
}

Expand Down Expand Up @@ -117,6 +133,13 @@ public async Task<ActionResult> OidPost(
StateManager[state].EnableLiveTv = config.EnableLiveTv;
StateManager[state].EnableLiveTvManagement = config.EnableLiveTvManagement;

if (config.AvatarUrlFormat is not null)
{
StateManager[state].AvatarURL = result.User.Claims.Aggregate(
config.AvatarUrlFormat,
(s, claim) => s.Contains($"@{{{claim.Type}}}") ? s.Replace($"@{{{claim.Type}}}", claim.Value) : s);
}

foreach (var claim in result.User.Claims)
{
if (claim.Type == (config.DefaultUsernameClaim?.Trim() ?? "preferred_username"))
Expand Down Expand Up @@ -421,7 +444,7 @@ public async Task<ActionResult> OidAuth(string provider, [FromBody] AuthResponse
{
Guid userId = await CreateCanonicalLinkAndUserIfNotExist("oid", provider, kvp.Value.Username);

var authenticationResult = await Authenticate(userId, kvp.Value.Admin, config.EnableAuthorization, config.EnableAllFolders, kvp.Value.Folders.ToArray(), kvp.Value.EnableLiveTv, kvp.Value.EnableLiveTvManagement, response, config.DefaultProvider?.Trim())
var authenticationResult = await Authenticate(userId, kvp.Value.Admin, config.EnableAuthorization, config.EnableAllFolders, kvp.Value.Folders.ToArray(), kvp.Value.EnableLiveTv, kvp.Value.EnableLiveTvManagement, response, config.DefaultProvider?.Trim(), kvp.Value.AvatarURL)
.ConfigureAwait(false);
return Ok(authenticationResult);
}
Expand Down Expand Up @@ -686,7 +709,7 @@ public async Task<ActionResult> SamlAuth(string provider, [FromBody] AuthRespons

Guid userId = await CreateCanonicalLinkAndUserIfNotExist("saml", provider, samlResponse.GetNameID());

var authenticationResult = await Authenticate(userId, isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), liveTv, liveTvManagement, response, config.DefaultProvider?.Trim())
var authenticationResult = await Authenticate(userId, isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), liveTv, liveTvManagement, response, config.DefaultProvider?.Trim(), null)
.ConfigureAwait(false);
return Ok(authenticationResult);
}
Expand Down Expand Up @@ -1020,7 +1043,8 @@ private OkResult UpdateCanonicalLinkConfig(SerializableDictionary<string, Guid>
/// <param name="enableLiveTvAdmin">Determines whether live TV can be managed by 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 Task<AuthenticationResult> Authenticate(Guid userId, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, bool enableLiveTv, bool enableLiveTvAdmin, AuthResponse authResponse, string defaultProvider)
/// <param name="avatarUrl">The new avatar url for the user.</param>
private async Task<AuthenticationResult> Authenticate(Guid userId, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, bool enableLiveTv, bool enableLiveTvAdmin, AuthResponse authResponse, string defaultProvider, string avatarUrl)
{
User user = _userManager.GetUserById(userId);
if (enableAuthorization)
Expand All @@ -1033,6 +1057,50 @@ private async Task<AuthenticationResult> Authenticate(Guid userId, bool isAdmin,
}
}

if (avatarUrl is not null)
{
try
{
using var client = new HttpClient();
var avatarResponse = await client.GetAsync(avatarUrl);

if (!avatarResponse.Content.Headers.TryGetValues("content-type", out var contentTypeList))
{
throw new Exception("Cannot get Content-Type of image : " + avatarUrl);
}

var contentType = contentTypeList.First();
if (!contentType.StartsWith("image"))
{
throw new Exception("Content type of avatar URL is not an image, got : " + contentType);
}

var extension = contentType.Split("/").Last();
var stream = await avatarResponse.Content.ReadAsStreamAsync();

if (user != null)
{
var userDataPath =
Path.Combine(
_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath,
user.Username);
if (user.ProfileImage is not null)
{
await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
}

user.ProfileImage = new ImageInfo(Path.Combine(userDataPath, "profile" + extension));

await _providerManager.SaveImage(stream, contentType, user.ProfileImage.Path)
.ConfigureAwait(false);
}
}
catch (Exception e)
{
_logger.LogError(e.Message);
}
}

user.SetPermission(PermissionKind.EnableLiveTvAccess, enableLiveTv);
user.SetPermission(PermissionKind.EnableLiveTvManagement, enableLiveTvAdmin);

Expand Down Expand Up @@ -1150,6 +1218,7 @@ public TimedAuthorizeState(AuthorizeState state, DateTime created)
IsLinking = false;
EnableLiveTv = false;
EnableLiveTvManagement = false;
AvatarURL = null;
}

/// <summary>
Expand Down Expand Up @@ -1197,4 +1266,9 @@ public TimedAuthorizeState(AuthorizeState state, DateTime created)
/// Gets or sets a value indicating whether the user is allowed to manage live TV.
/// </summary>
public bool EnableLiveTvManagement { get; set; }

/// <summary>
/// Gets or sets the user avatar url.
/// </summary>
public string AvatarURL { get; set; }
}
5 changes: 5 additions & 0 deletions SSO-Auth/Config/PluginConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,11 @@ public SerializableDictionary<string, Guid> CanonicalLinks
/// </summary>
public string DefaultUsernameClaim { get; set; }

/// <summary>
/// Gets or sets the URL format of the new user avatar.
/// </summary>
public string AvatarUrlFormat { get; set; }

/// <summary>
/// Gets or sets a value indicating whether HTTPS in the discovery endpoint is required.
/// </summary>
Expand Down
13 changes: 11 additions & 2 deletions SSO-Auth/Config/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -301,12 +301,21 @@ const ssoConfigurationPage = {

form_elements.text_fields.forEach((id) => {
const value = page.querySelector("#" + id).value;
if (value) current_config[id] = page.querySelector("#" + id).value;
if (value) {
current_config[id] = page.querySelector("#" + id).value
} else {
current_config[id] = null;
}
});

form_elements.json_fields.forEach((id) => {
const value = page.querySelector("#" + id).value;
if (value) current_config[id] = JSON.parse(value);
if (value) {
current_config[id] = JSON.parse(value)
}
else {
current_config[id] = null;
}
});

form_elements.check_fields.forEach((id) => {
Expand Down
18 changes: 18 additions & 0 deletions SSO-Auth/Config/configPage.html
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,24 @@ <h2 class="sectionTitle">SSO Settings:</h2>
</div>
</div>

<div class="inputContainer">
<label
class="inputLabel inputLabelUnfocused"
for="AvatarUrlFormat"
>Set avatar url format</label
>
<input
is="emby-input"
id="AvatarUrlFormat"
type="text"
class="sso-text"
/>
<div class="fieldDescription">
The url of the avatar with sso variable format:
example : <code>https://example.com/@{user_id}.png</code>
</div>
</div>

<div class="checkboxContainer">
<label>
<input
Expand Down
Loading