Files
unkin-agent a167d00b73
ci/woodpecker/pr/ci Pipeline was successful
store OAuth state in the shared cache
Keys each in-flight OpenID flow in the server's Valkey/Redis connection so a login
can start on one replica and finish on another. Falls back to an in-memory store
when no multiplexer is registered.
2026-09-19 22:47:43 +10:00

220 lines
8.0 KiB
C#

using System;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using Duende.IdentityModel.OidcClient;
using Jellyfin.Plugin.SSO_Auth.Api;
using Jellyfin.Plugin.SSO_Auth.State;
using Moq;
using StackExchange.Redis;
using Xunit;
namespace Jellyfin.Plugin.SSO_Auth.Tests;
public class RedisSsoStateStoreTests
{
[Fact]
public async Task CreateAsync_WritesPrefixedKeyWithExpiry()
{
var db = new Mock<IDatabase>();
RedisKey key = default;
RedisValue value = default;
TimeSpan? expiry = null;
SetupStringSet(db, (k, v, e) =>
{
key = k;
value = v;
expiry = e;
});
var store = new RedisSsoStateStore(CreateMultiplexer(db.Object));
await store.CreateAsync(NewState("abc123"), TimeSpan.FromSeconds(300));
Assert.Equal("jellyfin:sso:oid:state:abc123", key.ToString());
Assert.Equal(TimeSpan.FromSeconds(300), expiry);
Assert.Contains("abc123", value.ToString(), StringComparison.Ordinal);
}
[Fact]
public async Task UpdateAsync_RefreshesExpiry()
{
var db = new Mock<IDatabase>();
TimeSpan? expiry = null;
SetupStringSet(db, (k, v, e) => expiry = e);
var store = new RedisSsoStateStore(CreateMultiplexer(db.Object));
await store.UpdateAsync(NewState("abc123"), TimeSpan.FromSeconds(120));
Assert.Equal(TimeSpan.FromSeconds(120), expiry);
}
[Fact]
public async Task GetAsync_WhenKeyMissing_ReturnsNull()
{
var db = new Mock<IDatabase>();
db.Setup(d => d.StringGetAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync(RedisValue.Null);
var store = new RedisSsoStateStore(CreateMultiplexer(db.Object));
Assert.Null(await store.GetAsync("missing"));
}
[Fact]
public async Task GetAsync_WithoutState_DoesNotTouchRedis()
{
var db = new Mock<IDatabase>(MockBehavior.Strict);
var store = new RedisSsoStateStore(CreateMultiplexer(db.Object));
Assert.Null(await store.GetAsync(null));
Assert.Null(await store.GetAsync(string.Empty));
}
[Fact]
public async Task Mutations_WrittenByTheCallback_AreVisibleToTheAuthLeg()
{
var db = new Mock<IDatabase>();
string stored = null;
SetupStringSet(db, (k, v, e) => stored = v.ToString());
db.Setup(d => d.StringGetAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync(() => stored is null ? RedisValue.Null : (RedisValue)stored);
db.Setup(d => d.StringGetDeleteAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync(() =>
{
var payload = stored;
stored = null;
return payload is null ? RedisValue.Null : (RedisValue)payload;
});
var store = new RedisSsoStateStore(CreateMultiplexer(db.Object));
var expiry = TimeSpan.FromSeconds(300);
// Leg 1: the start endpoint stores a freshly prepared state.
await store.CreateAsync(NewState("abc123"), expiry);
// Leg 2: the callback endpoint validates the response and writes its decision back.
var callbackState = await store.GetAsync("abc123");
Assert.NotNull(callbackState);
Assert.False(callbackState.Valid);
callbackState.Valid = true;
callbackState.Username = "alice";
callbackState.Admin = true;
callbackState.AvatarURL = "https://idp.example/alice.png";
await store.UpdateAsync(callbackState, expiry);
// Leg 3: the auth endpoint redeems the state and sees the callback's decision.
var authState = await store.ConsumeAsync("abc123");
Assert.NotNull(authState);
Assert.True(authState.Valid);
Assert.Equal("alice", authState.Username);
Assert.True(authState.Admin);
Assert.Equal("https://idp.example/alice.png", authState.AvatarURL);
Assert.Equal("code-verifier-secret", authState.State.CodeVerifier);
// A replay of the same response finds nothing.
Assert.Null(await store.ConsumeAsync("abc123"));
}
[Fact]
public async Task ConsumeAsync_UsesAnAtomicGetAndDelete()
{
var db = new Mock<IDatabase>(MockBehavior.Strict);
db.Setup(d => d.StringGetDeleteAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync(RedisValue.Null);
var store = new RedisSsoStateStore(CreateMultiplexer(db.Object));
Assert.Null(await store.ConsumeAsync("abc123"));
db.Verify(
d => d.StringGetDeleteAsync(
It.Is<RedisKey>(k => k.ToString() == "jellyfin:sso:oid:state:abc123"),
It.IsAny<CommandFlags>()),
Times.Once);
}
[Fact]
public async Task ListAsync_ScansPrimariesOnly()
{
var db = new Mock<IDatabase>();
db.Setup(d => d.StringGetAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync((RedisValue)SsoStateSerializer.Serialize(NewState("abc123")));
var primary = CreateServer(isReplica: false, keys: new[] { (RedisKey)"jellyfin:sso:oid:state:abc123" });
var replica = CreateServer(isReplica: true, keys: new[] { (RedisKey)"jellyfin:sso:oid:state:abc123" });
var primaryEndpoint = new DnsEndPoint("primary", 6379);
var replicaEndpoint = new DnsEndPoint("replica", 6379);
var mux = new Mock<IConnectionMultiplexer>();
mux.Setup(m => m.GetDatabase(It.IsAny<int>(), It.IsAny<object>())).Returns(db.Object);
mux.Setup(m => m.GetEndPoints(It.IsAny<bool>())).Returns(new EndPoint[] { primaryEndpoint, replicaEndpoint });
mux.Setup(m => m.GetServer(primaryEndpoint, It.IsAny<object>())).Returns(primary);
mux.Setup(m => m.GetServer(replicaEndpoint, It.IsAny<object>())).Returns(replica);
var states = await new RedisSsoStateStore(mux.Object).ListAsync();
Assert.Single(states);
Assert.Equal("abc123", states[0].State.State);
}
private static TimedAuthorizeState NewState(string state)
{
return new TimedAuthorizeState(
new AuthorizeState
{
State = state,
StartUrl = "https://idp.example/authorize",
RedirectUri = "https://jellyfin.example/sso/OID/redirect/authentik",
CodeVerifier = "code-verifier-secret"
},
DateTime.UtcNow);
}
private static void SetupStringSet(Mock<IDatabase> db, Action<RedisKey, RedisValue, TimeSpan?> capture)
{
db.Setup(d => d.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
It.IsAny<When>(),
It.IsAny<CommandFlags>()))
.Callback<RedisKey, RedisValue, TimeSpan?, bool, When, CommandFlags>(
(key, value, expiry, keepTtl, when, flags) => capture(key, value, expiry))
.ReturnsAsync(true);
}
private static IServer CreateServer(bool isReplica, IReadOnlyList<RedisKey> keys)
{
var server = new Mock<IServer>();
server.SetupGet(s => s.IsConnected).Returns(true);
server.SetupGet(s => s.IsReplica).Returns(isReplica);
server.Setup(s => s.KeysAsync(
It.IsAny<int>(),
It.IsAny<RedisValue>(),
It.IsAny<int>(),
It.IsAny<long>(),
It.IsAny<int>(),
It.IsAny<CommandFlags>()))
.Returns(ToAsyncEnumerable(keys));
return server.Object;
}
private static async IAsyncEnumerable<RedisKey> ToAsyncEnumerable(IReadOnlyList<RedisKey> keys)
{
foreach (var key in keys)
{
yield return key;
}
await Task.CompletedTask;
}
private static IConnectionMultiplexer CreateMultiplexer(IDatabase database)
{
var mux = new Mock<IConnectionMultiplexer>();
mux.Setup(m => m.GetDatabase(It.IsAny<int>(), It.IsAny<object>())).Returns(database);
return mux.Object;
}
}