using System;
using System.Security.Cryptography;
using System.Threading;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace Unity.Pipeline.Security
{
///
/// Manages the security token used to authorize Pipeline server requests. In the Editor the token
/// is persisted in SessionState so it survives domain reloads (but dies with the editor process),
/// keeping long-lived clients (MCP/IDE) authenticated instead of hitting 401 after every reload.
/// Player builds use a per-process in-memory token. Published to the instance descriptor (port
/// file) for CLI discovery; never written to a separate file.
///
public static class SecurityTokenManager
{
// In-editor persistence key. SessionState is session-scoped editor state (wiped on editor exit).
private const string SessionStateKey = "Unity.Pipeline.SecurityToken";
// Fast in-memory cache, warmed on the main thread at server start (see BasePipelineServer.Start)
// so background auth reads a populated static instead of touching SessionState off-thread.
private static string s_CachedToken;
// Guards the check-and-set so two threads can't race to generate a token on first access.
private static readonly object s_Lock = new object();
// Thread that first touches this class (the main thread, since the server warms on it at start).
// SessionState is main-thread-only, so it is only touched when running on this thread.
private static readonly int s_MainThreadId = Thread.CurrentThread.ManagedThreadId;
///
/// Get or create the session's security token. In the Editor it is persisted in SessionState so
/// it survives domain reloads (regenerated only on editor restart or via
/// / ); player builds generate it once per process.
///
public static string GetOrCreateToken()
{
// Fast path: plain read of the warmed cache — no lock, no SessionState.
var cached = s_CachedToken;
if (!string.IsNullOrEmpty(cached))
return cached;
lock (s_Lock)
{
// Re-check: another thread may have generated the token while we waited on the lock;
// regenerating here would overwrite (and invalidate) the token it just handed out.
if (!string.IsNullOrEmpty(s_CachedToken))
return s_CachedToken;
#if UNITY_EDITOR
// Main thread only: safe to touch SessionState (the warm path).
if (Thread.CurrentThread.ManagedThreadId == s_MainThreadId)
{
// Rehydrate a token from earlier this session (survives domain reloads).
var persisted = SessionState.GetString(SessionStateKey, string.Empty);
var mainToken = string.IsNullOrEmpty(persisted) ? GenerateSecureToken() : persisted;
if (string.IsNullOrEmpty(persisted))
SessionState.SetString(SessionStateKey, mainToken);
// Publish last so a fast-path reader sees either null (and locks) or the persisted token.
s_CachedToken = mainToken;
return s_CachedToken;
}
#endif
// Player builds; or Editor off the main thread with a cold cache, where we generate
// in-memory rather than touch SessionState off-thread (which would throw/500).
s_CachedToken = GenerateSecureToken();
return s_CachedToken;
}
}
///
/// Compare two tokens in length-independent constant time to avoid leaking the expected
/// token through comparison timing.
///
public static bool ConstantTimeEquals(string a, string b)
{
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b))
return false;
var diff = a.Length ^ b.Length;
for (int i = 0; i < a.Length && i < b.Length; i++)
diff |= a[i] ^ b[i];
return diff == 0;
}
///
/// Generate a cryptographically secure token.
///
private static string GenerateSecureToken()
{
using (var rng = new RNGCryptoServiceProvider())
{
var bytes = new byte[32]; // 256 bits
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes);
}
}
///
/// Clear the cached token (and the persisted SessionState copy) so the next call generates a
/// fresh one. Used by tests and by . Call on the main thread (touches
/// SessionState).
///
public static void ClearCache()
{
lock (s_Lock)
{
s_CachedToken = null;
#if UNITY_EDITOR
SessionState.EraseString(SessionStateKey);
#endif
}
}
///
/// Force a new token and return it. Old-token revocation is immediate for request auth
/// (GetToken validates live); the port file refreshes on the next heartbeat. Call on the main
/// thread; a command exposing this must be MainThreadRequired and republish the descriptor.
///
public static string RotateToken()
{
ClearCache();
return GetOrCreateToken();
}
///
/// Test-only: drop the in-memory cache but keep SessionState, to simulate a domain reload
/// (statics cleared, SessionState survives).
///
internal static void ResetInMemoryCacheForTests()
{
s_CachedToken = null;
}
}
}