Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17bb9a1e8a | ||
|
|
44ab98e082 | ||
|
|
bb8814644c | ||
|
|
69f7a87cef |
@@ -1,7 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.0.3.0</Version>
|
<Version>0.0.4.0</Version>
|
||||||
<AssemblyVersion>0.0.3.0</AssemblyVersion>
|
<AssemblyVersion>0.0.4.0</AssemblyVersion>
|
||||||
<FileVersion>0.0.3.0</FileVersion>
|
<FileVersion>0.0.4.0</FileVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using Jellyfin.Plugin.WatchedTogether.Services;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.WatchedTogether.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers shared-account provisioning against a user manager that dispatches password changes the
|
||||||
|
/// way Jellyfin's real one does.
|
||||||
|
/// </summary>
|
||||||
|
[Collection(nameof(PluginTestContext))]
|
||||||
|
public class ProvisioningTests
|
||||||
|
{
|
||||||
|
private static readonly string AuthProviderId =
|
||||||
|
typeof(Auth.SharedAccountAuthenticationProvider).FullName!;
|
||||||
|
|
||||||
|
private static User MakeUser(string name) => new(name, "Prov", "ResetProv");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a user manager that mimics the one behaviour that matters here: ChangePassword is
|
||||||
|
/// routed to the provider named by the user's AuthenticationProviderId, so an account already
|
||||||
|
/// claimed by us lands in our provider and is refused.
|
||||||
|
/// </summary>
|
||||||
|
private static Mock<IUserManager> MakeUserManager(List<User> users, List<string> callLog)
|
||||||
|
{
|
||||||
|
var userManager = new Mock<IUserManager>();
|
||||||
|
|
||||||
|
userManager.Setup(m => m.GetUserById(It.IsAny<Guid>()))
|
||||||
|
.Returns((Guid id) => users.Find(u => u.Id == id));
|
||||||
|
|
||||||
|
userManager.Setup(m => m.CreateUserAsync(It.IsAny<string>()))
|
||||||
|
.ReturnsAsync((string name) =>
|
||||||
|
{
|
||||||
|
var created = MakeUser(name);
|
||||||
|
users.Add(created);
|
||||||
|
callLog.Add("CreateUser");
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
userManager.Setup(m => m.ChangePassword(It.IsAny<User>(), It.IsAny<string>()))
|
||||||
|
.Returns((User user, string password) =>
|
||||||
|
{
|
||||||
|
callLog.Add($"ChangePassword(provider={user.AuthenticationProviderId})");
|
||||||
|
|
||||||
|
// This is the dispatch that made provisioning fail in 0.0.3: once the account is
|
||||||
|
// claimed, the call reaches our provider, which refuses it by design.
|
||||||
|
if (string.Equals(user.AuthenticationProviderId, AuthProviderId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return new Auth.SharedAccountAuthenticationProvider(
|
||||||
|
Mock.Of<MediaBrowser.Model.Cryptography.ICryptoProvider>(),
|
||||||
|
new Lazy<IGroupService>(() => Mock.Of<IGroupService>()),
|
||||||
|
new Lazy<IDynamicGroupService>(() => Mock.Of<IDynamicGroupService>()),
|
||||||
|
NullLogger<Auth.SharedAccountAuthenticationProvider>.Instance)
|
||||||
|
.ChangePassword(user, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Password = password;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
userManager.Setup(m => m.UpdateUserAsync(It.IsAny<User>()))
|
||||||
|
.Returns((User user) =>
|
||||||
|
{
|
||||||
|
callLog.Add($"UpdateUser(provider={user.AuthenticationProviderId})");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
return userManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProvisioningService MakeService(Mock<IUserManager> userManager)
|
||||||
|
=> new(
|
||||||
|
userManager.Object,
|
||||||
|
Mock.Of<ILibraryAccessService>(),
|
||||||
|
NullLogger<ProvisioningService>.Instance);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateGroupAsync_SetsPasswordBeforeClaimingTheAccount()
|
||||||
|
{
|
||||||
|
using var context = PluginTestContext.Create();
|
||||||
|
|
||||||
|
var alice = MakeUser("alice");
|
||||||
|
var bob = MakeUser("bob");
|
||||||
|
var users = new List<User> { alice, bob };
|
||||||
|
var callLog = new List<string>();
|
||||||
|
|
||||||
|
var service = MakeService(MakeUserManager(users, callLog));
|
||||||
|
|
||||||
|
// Before the fix this threw NotSupportedException from our own ChangePassword.
|
||||||
|
var group = await service.CreateGroupAsync([alice.Id, bob.Id], null);
|
||||||
|
|
||||||
|
Assert.NotEqual(Guid.Empty, group.SharedUserId);
|
||||||
|
|
||||||
|
// The password must be set while the account is still on Jellyfin's default provider.
|
||||||
|
var changeIndex = callLog.FindIndex(c => c.StartsWith("ChangePassword", StringComparison.Ordinal));
|
||||||
|
var claimIndex = callLog.FindIndex(c => c.Contains(AuthProviderId, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
Assert.True(changeIndex >= 0, "provisioning should set a password on the shared account");
|
||||||
|
Assert.True(claimIndex >= 0, "provisioning should claim the account for our provider");
|
||||||
|
Assert.True(
|
||||||
|
changeIndex < claimIndex,
|
||||||
|
$"password must be set before the account is claimed, but call order was: {string.Join(" -> ", callLog)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateGroupAsync_LeavesTheAccountClaimedByOurProvider()
|
||||||
|
{
|
||||||
|
using var context = PluginTestContext.Create();
|
||||||
|
|
||||||
|
var alice = MakeUser("alice");
|
||||||
|
var bob = MakeUser("bob");
|
||||||
|
var users = new List<User> { alice, bob };
|
||||||
|
var callLog = new List<string>();
|
||||||
|
|
||||||
|
var service = MakeService(MakeUserManager(users, callLog));
|
||||||
|
|
||||||
|
var group = await service.CreateGroupAsync([alice.Id, bob.Id], null);
|
||||||
|
|
||||||
|
// Claiming the account is what routes its logins to us; provisioning is useless without it.
|
||||||
|
var sharedUser = users.Find(u => u.Id == group.SharedUserId);
|
||||||
|
Assert.NotNull(sharedUser);
|
||||||
|
Assert.Equal(AuthProviderId, sharedUser!.AuthenticationProviderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateGroupAsync_GivesTheSharedAccountANonEmptyPassword()
|
||||||
|
{
|
||||||
|
using var context = PluginTestContext.Create();
|
||||||
|
|
||||||
|
var alice = MakeUser("alice");
|
||||||
|
var bob = MakeUser("bob");
|
||||||
|
var users = new List<User> { alice, bob };
|
||||||
|
var callLog = new List<string>();
|
||||||
|
|
||||||
|
var service = MakeService(MakeUserManager(users, callLog));
|
||||||
|
|
||||||
|
var group = await service.CreateGroupAsync([alice.Id, bob.Id], null);
|
||||||
|
|
||||||
|
// A passwordless shared account would be directly loginable if the provider were ever
|
||||||
|
// unassigned, which is the reason provisioning sets one at all.
|
||||||
|
var sharedUser = users.Find(u => u.Id == group.SharedUserId);
|
||||||
|
Assert.NotNull(sharedUser);
|
||||||
|
Assert.False(string.IsNullOrEmpty(sharedUser!.Password));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using Jellyfin.Plugin.WatchedTogether.Auth;
|
||||||
|
using Jellyfin.Plugin.WatchedTogether.Services;
|
||||||
|
using MediaBrowser.Controller.Authentication;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.WatchedTogether.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exercises the whole path a real login takes: a group created on demand by typing "alice+bob",
|
||||||
|
/// then logged into again afterwards by each member in turn.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The other suites mock <see cref="IProvisioningService"/>, which is why an ordering bug inside the
|
||||||
|
/// real provisioning code reached a release. These tests wire the real services together and only
|
||||||
|
/// stub the host's user manager and crypto.
|
||||||
|
/// </remarks>
|
||||||
|
[Collection(nameof(PluginTestContext))]
|
||||||
|
public class SharedAccountEndToEndTests
|
||||||
|
{
|
||||||
|
private const string AliceHash = "$PBKDF2-SHA512$iterations=210000$A1A1A1A1$AAAAAAAABBBBBBBB";
|
||||||
|
private const string BobHash = "$PBKDF2-SHA512$iterations=210000$B2B2B2B2$CCCCCCCCDDDDDDDD";
|
||||||
|
|
||||||
|
private static readonly string AuthProviderId =
|
||||||
|
typeof(SharedAccountAuthenticationProvider).FullName!;
|
||||||
|
|
||||||
|
private sealed record Harness(
|
||||||
|
SharedAccountAuthenticationProvider Provider,
|
||||||
|
List<User> Users);
|
||||||
|
|
||||||
|
private static User MakeUser(string name, string? password = null)
|
||||||
|
=> new(name, "Prov", "ResetProv") { Password = password! };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wires the real provisioning, group, dynamic-group and authentication services over a user
|
||||||
|
/// manager that behaves like Jellyfin's: password changes dispatch to the user's assigned
|
||||||
|
/// provider, and users resolve by both name and id.
|
||||||
|
/// </summary>
|
||||||
|
private static Harness MakeHarness(List<User> users, params (string Hash, string Password)[] validPairs)
|
||||||
|
{
|
||||||
|
var crypto = new StubCryptoProvider(validPairs);
|
||||||
|
var userManager = new Mock<IUserManager>();
|
||||||
|
|
||||||
|
userManager.Setup(m => m.GetUserById(It.IsAny<Guid>()))
|
||||||
|
.Returns((Guid id) => users.Find(u => u.Id == id)!);
|
||||||
|
|
||||||
|
userManager.Setup(m => m.GetUserByName(It.IsAny<string>()))
|
||||||
|
.Returns((string n) => users.Find(
|
||||||
|
u => string.Equals(u.Username, n, StringComparison.OrdinalIgnoreCase))!);
|
||||||
|
|
||||||
|
userManager.Setup(m => m.CreateUserAsync(It.IsAny<string>()))
|
||||||
|
.ReturnsAsync((string name) =>
|
||||||
|
{
|
||||||
|
var created = MakeUser(name);
|
||||||
|
users.Add(created);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
userManager.Setup(m => m.UpdateUserAsync(It.IsAny<User>())).Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
userManager.Setup(m => m.ChangePassword(It.IsAny<User>(), It.IsAny<string>()))
|
||||||
|
.Returns((User user, string password) =>
|
||||||
|
{
|
||||||
|
// Jellyfin routes this to the user's assigned provider; ours refuses by design.
|
||||||
|
if (string.Equals(user.AuthenticationProviderId, AuthProviderId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new NotSupportedException(
|
||||||
|
"A Watched Together shared account has no password of its own.");
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Password = password;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
var groupService = new GroupService(
|
||||||
|
userManager.Object,
|
||||||
|
NullLogger<GroupService>.Instance);
|
||||||
|
|
||||||
|
var provisioning = new ProvisioningService(
|
||||||
|
userManager.Object,
|
||||||
|
Mock.Of<ILibraryAccessService>(),
|
||||||
|
NullLogger<ProvisioningService>.Instance);
|
||||||
|
|
||||||
|
var dynamicGroups = new DynamicGroupService(
|
||||||
|
userManager.Object,
|
||||||
|
provisioning,
|
||||||
|
crypto,
|
||||||
|
NullLogger<DynamicGroupService>.Instance);
|
||||||
|
|
||||||
|
var provider = new SharedAccountAuthenticationProvider(
|
||||||
|
crypto,
|
||||||
|
new Lazy<IGroupService>(() => groupService),
|
||||||
|
new Lazy<IDynamicGroupService>(() => dynamicGroups),
|
||||||
|
NullLogger<SharedAccountAuthenticationProvider>.Instance);
|
||||||
|
|
||||||
|
return new Harness(provider, users);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("alice-pw")]
|
||||||
|
[InlineData("bob-pw")]
|
||||||
|
public async Task GroupCreatedOnDemand_ThenUnlockedByEitherMemberPassword(string creatingPassword)
|
||||||
|
{
|
||||||
|
using var ctx = PluginTestContext.Create();
|
||||||
|
|
||||||
|
var alice = MakeUser("alice", AliceHash);
|
||||||
|
var bob = MakeUser("bob", BobHash);
|
||||||
|
var users = new List<User> { alice, bob };
|
||||||
|
|
||||||
|
var h = MakeHarness(users, (AliceHash, "alice-pw"), (BobHash, "bob-pw"));
|
||||||
|
|
||||||
|
// Creating the group by typing both names. Whichever member types their own password, the
|
||||||
|
// resulting account must behave identically.
|
||||||
|
var created = await h.Provider.Authenticate("alice+bob", creatingPassword, null);
|
||||||
|
Assert.Equal("alice+bob", created.Username);
|
||||||
|
|
||||||
|
var shared = h.Users.Find(u => u.Username == "alice+bob");
|
||||||
|
Assert.NotNull(shared);
|
||||||
|
|
||||||
|
// The account exists now, so Jellyfin resolves it and hands it to us as resolvedUser. Both
|
||||||
|
// members must be able to unlock it, regardless of who created it.
|
||||||
|
var asAlice = await h.Provider.Authenticate("alice+bob", "alice-pw", shared);
|
||||||
|
Assert.Equal("alice+bob", asAlice.Username);
|
||||||
|
|
||||||
|
var asBob = await h.Provider.Authenticate("alice+bob", "bob-pw", shared);
|
||||||
|
Assert.Equal("alice+bob", asBob.Username);
|
||||||
|
|
||||||
|
// And an outsider's password still must not.
|
||||||
|
await Assert.ThrowsAsync<AuthenticationException>(
|
||||||
|
() => h.Provider.Authenticate("alice+bob", "not-a-member-pw", shared!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GroupCreatedOnDemand_ClaimsTheAccountAndKeepsAPassword()
|
||||||
|
{
|
||||||
|
using var ctx = PluginTestContext.Create();
|
||||||
|
|
||||||
|
var alice = MakeUser("alice", AliceHash);
|
||||||
|
var bob = MakeUser("bob", BobHash);
|
||||||
|
var users = new List<User> { alice, bob };
|
||||||
|
|
||||||
|
var h = MakeHarness(users, (AliceHash, "alice-pw"), (BobHash, "bob-pw"));
|
||||||
|
|
||||||
|
await h.Provider.Authenticate("alice+bob", "alice-pw", null);
|
||||||
|
|
||||||
|
var shared = h.Users.Find(u => u.Username == "alice+bob");
|
||||||
|
Assert.NotNull(shared);
|
||||||
|
|
||||||
|
// Claimed by us, so future logins route here, and holding a password of its own so it is
|
||||||
|
// not directly loginable if the provider is ever unassigned.
|
||||||
|
Assert.Equal(AuthProviderId, shared!.AuthenticationProviderId);
|
||||||
|
Assert.False(string.IsNullOrEmpty(shared.Password));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,15 +100,20 @@ public class ProvisioningService : IProvisioningService
|
|||||||
|
|
||||||
var sharedUser = await _userManager.CreateUserAsync(accountName).ConfigureAwait(false);
|
var sharedUser = await _userManager.CreateUserAsync(accountName).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// The shared account never authenticates against its own password - our provider checks
|
||||||
|
// member hashes instead. Setting a random one avoids leaving a passwordless account behind
|
||||||
|
// if the provider is ever unassigned.
|
||||||
|
//
|
||||||
|
// This must happen before the account is claimed below. IUserManager.ChangePassword
|
||||||
|
// dispatches to the provider the user is currently assigned to, and ours refuses the call
|
||||||
|
// by design, so claiming first would make provisioning throw NotSupportedException. A
|
||||||
|
// freshly created user is still on Jellyfin's default provider, which stores the hash.
|
||||||
|
await _userManager.ChangePassword(sharedUser, GenerateUnusedPassword()).ConfigureAwait(false);
|
||||||
|
|
||||||
// Route this account's logins through our provider. Jellyfin matches providers by
|
// Route this account's logins through our provider. Jellyfin matches providers by
|
||||||
// GetType().FullName, the same key the SSO plugin uses, and the assignment only sticks
|
// GetType().FullName, the same key the SSO plugin uses, and the assignment only sticks
|
||||||
// once the user is updated.
|
// once the user is updated.
|
||||||
sharedUser.AuthenticationProviderId = AuthProviderId;
|
sharedUser.AuthenticationProviderId = AuthProviderId;
|
||||||
|
|
||||||
// The shared account never authenticates against its own password - our provider checks
|
|
||||||
// member hashes instead. Setting a random one avoids leaving a passwordless account behind
|
|
||||||
// if the provider is ever unassigned.
|
|
||||||
await _userManager.ChangePassword(sharedUser, GenerateUnusedPassword()).ConfigureAwait(false);
|
|
||||||
await _userManager.UpdateUserAsync(sharedUser).ConfigureAwait(false);
|
await _userManager.UpdateUserAsync(sharedUser).ConfigureAwait(false);
|
||||||
|
|
||||||
await ApplyLibraryAccessAsync(sharedUser.Id, distinctIds).ConfigureAwait(false);
|
await ApplyLibraryAccessAsync(sharedUser.Id, distinctIds).ConfigureAwait(false);
|
||||||
|
|||||||
+7
-6
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: "Watched Together"
|
name: "Watched Together"
|
||||||
guid: "aa3288a0-e8c1-43e2-8045-8c3411142a5b"
|
guid: "aa3288a0-e8c1-43e2-8045-8c3411142a5b"
|
||||||
version: "0.0.3.0"
|
version: "0.0.4.0"
|
||||||
targetAbi: "10.11.0.0"
|
targetAbi: "10.11.0.0"
|
||||||
framework: "net9.0"
|
framework: "net9.0"
|
||||||
overview: "One shared login for several people; watched state flows back to each member's own account"
|
overview: "One shared login for several people; watched state flows back to each member's own account"
|
||||||
@@ -26,8 +26,9 @@ dotnet_framework: "net9.0"
|
|||||||
# Point at the plugin project rather than the solution so the test project is not packaged.
|
# Point at the plugin project rather than the solution so the test project is not packaged.
|
||||||
project: "Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj"
|
project: "Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj"
|
||||||
changelog: >
|
changelog: >
|
||||||
Fixes a startup crash: installing 0.0.2 left the server unable to boot with
|
Fixes creating a group by typing "alice+bob" at the login screen, which failed
|
||||||
"a circular dependency was detected for the service of type IUserManager".
|
with "a Watched Together shared account has no password of its own".
|
||||||
Jellyfin builds every authentication provider while constructing the user
|
Provisioning set the new account's placeholder password after routing it
|
||||||
manager, so the plugin's provider now resolves its group services on first
|
through this plugin's authentication provider, which refuses password changes
|
||||||
login instead of at construction time.
|
by design; the password is now set while the account is still on Jellyfin's
|
||||||
|
default provider.
|
||||||
|
|||||||
@@ -7,6 +7,22 @@
|
|||||||
"owner": "dtourolle",
|
"owner": "dtourolle",
|
||||||
"category": "General",
|
"category": "General",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.0.4.0",
|
||||||
|
"changelog": "Release 0.0.4.0",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/WatchedTogether/releases/download/v0.0.4.0/watched-together_0.0.4.0.zip",
|
||||||
|
"checksum": "f537c5305ac6fcb19024471968759bb5",
|
||||||
|
"timestamp": "2026-08-09T08:59:11Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.0.3.0",
|
||||||
|
"changelog": "Release 0.0.3.0",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/WatchedTogether/releases/download/v0.0.3.0/watched-together_0.0.3.0.zip",
|
||||||
|
"checksum": "56aa6c2e8f12d7404889de8bc253eae3",
|
||||||
|
"timestamp": "2026-08-09T08:49:27Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"changelog": "Release 0.0.2",
|
"changelog": "Release 0.0.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user