Compare commits

..

22 Commits

Author SHA1 Message Date
9p4 7331e2ffe3 Merge branch 'discovery-no-https' of https://github.com/9p4/jellyfin-plugin-sso into discovery-no-https 2022-09-09 10:17:04 -04:00
9p4 cef4fe5784 feat: allow for HTTPS to be disabled in OpenID discovery (#76) 2022-09-09 10:16:05 -04:00
9p4 0d2952423d feat: allow for HTTPS to be disabled in OpenID discovery (#76) 2022-09-09 10:13:07 -04:00
9p4 98ddb9e352 Merge pull request #74 from 9p4/custom-username-claim
feat: allow configuration of preferred username claim
2022-08-23 21:12:59 -04:00
9p4 46eb00bf43 Merge branch 'main' into custom-username-claim 2022-08-23 19:50:01 -04:00
9p4 5723cd718d ci: change commit linter 2022-08-23 19:48:41 -04:00
9p4 96b14f7a5a ci: run commit name lint on every push and pr 2022-08-23 19:42:56 -04:00
9p4 320551bc18 ci: update hash for commit lint action 2022-08-23 19:39:47 -04:00
Sambhav Saggi 9f8626bdf7 Merge branch 'custom-username-claim' of https://github.com/9p4/jellyfin-plugin-sso into custom-username-claim 2022-08-23 19:38:21 -04:00
Sambhav Saggi d5925fc1de docs: add custom username claim api doc 2022-08-23 19:38:01 -04:00
9p4 facf45058f Merge branch 'main' into custom-username-claim 2022-08-23 19:32:16 -04:00
9p4 f3d0497801 ci: lint commits 2022-08-23 19:31:02 -04:00
Sambhav Saggi 672fce8189 fix: prevent null passwords when fallback is set to default provider 2022-08-23 19:22:02 -04:00
Sambhav Saggi c2f50e1e4c style: run prettier 2022-08-23 18:57:57 -04:00
Sambhav Saggi a90e0359e4 feat: add config page for preferred username config 2022-08-23 18:26:58 -04:00
Sambhav Saggi 332f62d76e feat: allow configuration of preferred username claim (#47) 2022-08-23 18:18:42 -04:00
9p4 47db5cb504 Merge pull request #71 from fredriklindberg/improve-get-request-base
fix: don't include port in redirect URL for 80 and 443
2022-08-23 18:12:19 -04:00
Matthew Strasiotto fe1fdad0b0 Merge pull request #72 from fredriklindberg/make-user-creation-more-robust 2022-08-20 21:07:53 +10:00
Fredrik Lindberg d06f680407 fix: make user creation more robust
Reverse logic of CreateCanonicalLinkAndUserIfNotExist() to first check if the user exists,
then create the canonical link. This allows seamless re-creation of users through SSO after
they have been deleted from Jellyfin but not properly de-linked.
2022-08-18 17:55:41 +02:00
Fredrik Lindberg 976a816d8c fix: don't include port in redirect URL for 80 and 443
Skip port number in redirect URLs during the following conditions
 - The request scheme is http and request port is 80
 - The request scheme is https and request port is 443

This creates redirect urls as https://server/path instead of https://server:443/path
2022-08-18 17:48:37 +02:00
Matthew Strasiotto f7ce40fe5e Merge pull request #69 from matthewstrasiotto/main
ci: 📌 change from shogo82148/actions-upload-release-asset to softprops/action-gh-release
2022-08-18 19:09:45 +10:00
Matthew Strasiotto 8666818dfa ci: 📌 change from shogo82148/actions-upload-release-asset to softprops/action-gh-release 2022-08-18 19:09:12 +10:00
6 changed files with 90 additions and 20 deletions
+11
View File
@@ -0,0 +1,11 @@
name: Lint Commit Messages
on: [pull_request, push]
jobs:
commitlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: wagoid/commitlint-github-action@4caf21aed4a778f940d0b17eb109942ef167bb27
+9 -4
View File
@@ -28,11 +28,16 @@ jobs:
sha256sum ${file#./} >> ${file%.*}.sha256 sha256sum ${file#./} >> ${file%.*}.sha256
done done
ls -l ls -l
- name: Upload GitHub Release assets - name: Publish output artifacts
uses: shogo82148/actions-upload-release-asset@v1.5.0 id: publish-assets
uses: softprops/action-gh-release@50195ba7f6f93d1ac97ba8332a178e008ad176aa
with: with:
upload_url: ${{ github.event.release.upload_url }} prerelease: false
asset_path: ./* fail_on_unmatched_files: true
tag_name: ${{ github.event.release.tag_name }}
files: ./*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
generate: generate:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
+3 -1
View File
@@ -192,10 +192,12 @@ These all require authorization. Append an API key to the end of the request: `c
- `enableFolderRoles`: boolean. Determines if role-based folder access should be used. - `enableFolderRoles`: boolean. Determines if role-based folder access should be used.
- `folderRoleMapping`: object in the format "role": string and "folders": array of strings. The user with this role will have access to the following folders if `enableFolderRoles` is enabled. To get the IDs of the folders, GET the `/Library/MediaFolders` URL with an API key. Look for the `Id` attribute. - `folderRoleMapping`: object in the format "role": string and "folders": array of strings. The user with this role will have access to the following folders if `enableFolderRoles` is enabled. To get the IDs of the folders, GET the `/Library/MediaFolders` URL with an API key. Look for the `Id` attribute.
- `roleClaim`: string. This is the value in the OpenID response to check for roles. For Keycloak, it is `realm_access.roles` by default. The first element is the claim type, the subsequent values are to parse the JSON of the claim value. Use a "\\." to denote a literal ".". This expects a list of strings from the OIDC server. - `roleClaim`: string. This is the value in the OpenID response to check for roles. For Keycloak, it is `realm_access.roles` by default. The first element is the claim type, the subsequent values are to parse the JSON of the claim value. Use a "\\." to denote a literal ".". This expects a list of strings from the OIDC server.
- `oidScopes` : array of strings. each containing an additional scope name to include in the OIDC request. - `oidScopes` : array of strings. Each contains an additional scope name to include in the OIDC request.
- For some OIDC providers (For example, [authelia](https://github.com/9p4/jellyfin-plugin-sso/issues/23#issuecomment-1112237616)), additional scopes may be required in order to validate group membership in role claim. - For some OIDC providers (For example, [authelia](https://github.com/9p4/jellyfin-plugin-sso/issues/23#issuecomment-1112237616)), additional scopes may be required in order to validate group membership in role claim.
- Leave empty to only request the default scopes. - 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. - `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`.
- `requireHttps`: boolean. Determines whether the OpenID discovery endpoint requires HTTP. It is advised NOT to set this to false. If not set, it fallbacks to `true`.
- GET `OID/Del/PROVIDER_NAME`: This removes a configuration for OpenID for a given provider name. - GET `OID/Del/PROVIDER_NAME`: This removes a configuration for OpenID for a given provider name.
- GET `OID/Get`: Lists the configurations currently available. - GET `OID/Get`: Lists the configurations currently available.
- GET `OID/States`: Lists currently active OpenID flows in progress. - GET `OID/States`: Lists currently active OpenID flows in progress.
+38 -15
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net.Mime; using System.Net.Mime;
using System.Security.Cryptography;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using IdentityModel.OidcClient; using IdentityModel.OidcClient;
@@ -13,6 +14,7 @@ using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session; using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Cryptography;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -34,6 +36,7 @@ public class SSOController : ControllerBase
private readonly ISessionManager _sessionManager; private readonly ISessionManager _sessionManager;
private readonly IAuthorizationContext _authContext; private readonly IAuthorizationContext _authContext;
private readonly ILogger<SSOController> _logger; private readonly ILogger<SSOController> _logger;
private readonly ICryptoProvider _cryptoProvider;
private static readonly IDictionary<string, TimedAuthorizeState> StateManager = new Dictionary<string, TimedAuthorizeState>(); private static readonly IDictionary<string, TimedAuthorizeState> StateManager = new Dictionary<string, TimedAuthorizeState>();
/// <summary> /// <summary>
@@ -43,11 +46,13 @@ public class SSOController : ControllerBase
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
/// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
public SSOController(ILogger<SSOController> logger, ISessionManager sessionManager, IUserManager userManager, IAuthorizationContext authContext) /// <param name="cryptoProvider">Instance of the <see cref="ICryptoProvider"/> interface.</param>
public SSOController(ILogger<SSOController> logger, ISessionManager sessionManager, IUserManager userManager, IAuthorizationContext authContext, ICryptoProvider cryptoProvider)
{ {
_sessionManager = sessionManager; _sessionManager = sessionManager;
_userManager = userManager; _userManager = userManager;
_authContext = authContext; _authContext = authContext;
_cryptoProvider = cryptoProvider;
_logger = logger; _logger = logger;
_logger.LogInformation("SSO Controller initialized"); _logger.LogInformation("SSO Controller initialized");
} }
@@ -85,6 +90,7 @@ public class SSOController : ControllerBase
Scope = string.Join(" ", config.OidScopes.Prepend("openid profile")), Scope = string.Join(" ", config.OidScopes.Prepend("openid profile")),
}; };
options.Policy.Discovery.ValidateEndpoints = false; // For Google and other providers with different endpoints options.Policy.Discovery.ValidateEndpoints = false; // For Google and other providers with different endpoints
options.Policy.Discovery.RequireHttps = config.RequireHttps || true;
var oidcClient = new OidcClient(options); var oidcClient = new OidcClient(options);
var currentState = StateManager[state].State; var currentState = StateManager[state].State;
var result = oidcClient.ProcessResponseAsync(Request.QueryString.Value, currentState).Result; var result = oidcClient.ProcessResponseAsync(Request.QueryString.Value, currentState).Result;
@@ -104,7 +110,7 @@ public class SSOController : ControllerBase
foreach (var claim in result.User.Claims) foreach (var claim in result.User.Claims)
{ {
if (claim.Type == "preferred_username") if (claim.Type == (config.DefaultUsernameClaim ?? "preferred_username"))
{ {
StateManager[state].Username = claim.Value; StateManager[state].Username = claim.Value;
if (config.Roles.Length == 0) if (config.Roles.Length == 0)
@@ -186,7 +192,7 @@ public class SSOController : ControllerBase
} }
} }
// If the provider doesn't support preferred_username, then use sub // If the provider doesn't support the preferred username claim, then use the sub claim
if (!StateManager[state].Valid) if (!StateManager[state].Valid)
{ {
foreach (var claim in result.User.Claims) foreach (var claim in result.User.Claims)
@@ -624,6 +630,22 @@ public class SSOController : ControllerBase
private async Task<Guid> CreateCanonicalLinkAndUserIfNotExist(string mode, string provider, string canonicalName) private async Task<Guid> CreateCanonicalLinkAndUserIfNotExist(string mode, string provider, string canonicalName)
{ {
User user = null;
user = _userManager.GetUserByName(canonicalName);
if (user == null)
{
_logger.LogInformation($"SSO user {canonicalName} doesn't exist, creating...");
user = await _userManager.CreateUserAsync(canonicalName).ConfigureAwait(false);
user.AuthenticationProviderId = GetType().FullName;
// https://jonathancrozier.com/blog/how-to-generate-a-cryptographically-secure-random-string-in-dot-net-with-c-sharp
user.Password = _cryptoProvider.CreatePasswordHash(Convert.ToBase64String(RandomNumberGenerator.GetBytes(64))).ToString();
// Make sure there aren't any trailing existing links
var links = GetCanonicalLinks(mode, provider);
links.Remove(canonicalName);
UpdateCanonicalLinkConfig(links, mode, provider);
}
Guid userId = Guid.Empty; Guid userId = Guid.Empty;
try try
{ {
@@ -637,18 +659,7 @@ public class SSOController : ControllerBase
if (userId == Guid.Empty) if (userId == Guid.Empty)
{ {
_logger.LogInformation("SSO user link doesn't exist, creating..."); _logger.LogInformation("SSO user link doesn't exist, creating...");
User user = null;
user = _userManager.GetUserByName(canonicalName);
if (user == null)
{
_logger.LogInformation($"SSO user {canonicalName} doesn't exist, creating...");
user = await _userManager.CreateUserAsync(canonicalName).ConfigureAwait(false);
user.AuthenticationProviderId = GetType().FullName;
}
userId = user.Id; userId = user.Id;
CreateCanonicalLink(mode, provider, userId, canonicalName); CreateCanonicalLink(mode, provider, userId, canonicalName);
} }
@@ -948,7 +959,19 @@ public class SSOController : ControllerBase
private string GetRequestBase() private string GetRequestBase()
{ {
return Request.Scheme + "://" + Request.Host + Request.PathBase; int requestPort = Request.Host.Port ?? -1;
if ((requestPort == 80 && string.Equals(Request.Scheme, "http", StringComparison.OrdinalIgnoreCase)) || (requestPort == 443 && string.Equals(Request.Scheme, "https", StringComparison.OrdinalIgnoreCase)))
{
requestPort = -1;
}
return new UriBuilder
{
Scheme = Request.Scheme,
Host = Request.Host.Host,
Port = requestPort,
Path = Request.PathBase
}.ToString().TrimEnd('/');
} }
private ContentResult ReturnError(int code, string message) private ContentResult ReturnError(int code, string message)
+10
View File
@@ -217,6 +217,16 @@ public class OidConfig
} }
set => _canonicalLinks = value; set => _canonicalLinks = value;
} }
/// <summary>
/// Gets or sets the default username claim when creating new accounts.
/// </summary>
public string DefaultUsernameClaim { get; set; }
/// <summary>
/// Gets or sets a value indicating whether HTTPS in the discovery endpoint is required.
/// </summary>
public bool RequireHttps { get; set; }
} }
/// <summary> /// <summary>
+19
View File
@@ -437,6 +437,25 @@
</div> </div>
</div> </div>
<div class="inputContainer">
<label
class="inputLabel inputLabelUnfocused"
for="DefaultUsernameClaim"
>Set default username claim:</label
>
<input
is="emby-input"
id="DefaultUsernameClaim"
type="text"
class="sso-text"
/>
<div class="fieldDescription">
The default username claim to use from OpenID by default. If
it is not set, it defaults to
<code>preferred_username</code>.
</div>
</div>
<button <button
id="SaveProvider" id="SaveProvider"
is="emby-button" is="emby-button"