forked from mirrors/jellyfin-plugin-sso
Back the SSO OAuth state with the shared cache #3
Reference in New Issue
Block a user
Delete Branch "benvin/valkey-oauth-state"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The OAuth state lived in a per-process dictionary, so a login whose provider callback landed on a different replica than its
/sso/OID/startfailed with "Invalid or expired state".ISsoStateStore, backed by the server'sIConnectionMultiplexerwith a bounded in-memory fallbackjellyfin:sso:oid:state:<state>under a 300s TTL, redeemed once on auth/sso/OID/Statestests/SSO-Auth.TestsPart of unkin/jellyfin-ha-src#27
SSO-Auth/State/InMemorySsoStateStore.cs:16— entries expire only when that exact key is next Get/Consume/List'd andInvalidate()was deleted, so abandoned flows from the unauthenticated/sso/OID/start/{p}accumulate forever in the fallback store → add a sweep (or a cap) so abandoned states are reclaimed without a later lookup.SSO-Auth/SSO-Auth.csproj:37—ExcludeAssets="runtime"binds to whatever StackExchange.Redis the server's ALC holds, but this compiles against 2.13.17:StringGetDeleteAsyncneeds >=2.6.66 and thekeepTtlStringSetAsyncoverload >=2.5, so an older server assembly is a runtimeMissingMethodException→ pin to the version jellyfin-ha-src actually ships.SSO-Auth/State/RedisSsoStateStore.cs:105—Valid/Admin/Usernameplus the PKCE verifier now sit in a passwordless, TLS-less, shared valkey while/sso/OID/Auth/{provider}is unauthenticated, so anything that can read the key can mint a session including an admin one → require AUTH+TLS on that valkey, or state the accepted risk in the PR body.SSO-Auth/Api/SSOController.cs:127,343,433,507,535,1133— no store call is guarded, so a valkey outage or timeout surfaces as an unhandled exception on every SSO leg instead of the plugin'sReturnError→ catchRedisException/TimeoutExceptionand fail closed with a logged message.SSO-Auth/Api/SSOController.cs:343— the write-back runs before theValidcheck, so a state that just failed the role check is rewritten with a fresh 300s TTL → move it into theValidbranch and drop the state on the 401 path.SSO-Auth/SsoAuthServiceRegistrator.cs:30— the in-memory fallback silently re-creates the multi-replica bug this PR fixes; log it atLogWarning..woodpecker/ci.yaml:16— "No test project exists yet; this is a no-op until one lands" is stale now thattests/SSO-Auth.Testsexists.SSO-Auth/State/InMemorySsoStateStore.cs:123—_entries.OrderBy(...).Take(excess)buffers theConcurrentDictionarythroughICollection.Count+CopyTo, which is not atomic: a concurrent write makesEvict()throwNullReferenceException(dictionary shrank, trailing defaultKeyValuePairs) orArgumentException(it grew). Reproduced with two writers once the store is atCapacity;Evict()runs on everyCreateAsync/UpdateAsync, so concurrent logins past the cap fail with the 503 page → take_entries.ToArray()(the dictionary's own atomic snapshot, not the LINQ extension) and order that.SSO-Auth/State/InMemorySsoStateStore.cs:123— every state carries the same 300s TTL, so "nearest to expiry" is "oldest": 1025 anonymousGET /sso/OID/start/{p}calls evict every live in-flight login, which then fails with "Invalid or expired state". Reject the new write at the cap instead, or record in the class doc that the cap trades a memory DoS for a login DoS.tests/SSO-Auth.Tests/InMemorySsoStateStoreTests.cs:96—Writing_BoundsTheStoreAtItsCapacitywrites serially and never exercisesEvict()under concurrency, which is how the first finding got through → add a test that writes from several tasks with the store overCapacity.No findings.