令牌存储策略
启动器要保管三种东西:微软账户的 OAuth 令牌、第三方账户的 Yggdrasil 令牌、以及启动器自己的星云 ID 会话。三者的存储策略不一样,理由也不一样。
access token 一律只存内存
AccountStore 里有一个纯内存的会话表:
private readonly Dictionary<string, AccountSession> _sessions = []; // uuid → 会话凭证AccountSession 是个 record,从不参与序列化:
public sealed record AccountSession(string AccessToken, string? RefreshToken = null, string? Xuid = null);进程退出,这些就没了。落盘的 accounts.json 里只有 uuid、username、type 这些身份字段。
这是合规要求,不是偷懒
access token 等同于账号密码。任何能读到你磁盘的程序——备份软件、同步盘、杀软、别的用户、恢复出来的旧盘——都能拿去登你的号。放进内存至少还需要先拿到你的进程内存。
代价很实在:启动器重启后,第一次启动游戏需要重新登录一次。第三方账户尤其如此,因为它的 access token 既不落盘也没有 refresh_token 可换。
refresh_token 是唯一的例外
微软的 refresh_token 允许 30 天免登录,所以它必须落盘。文件是 %APPDATA%/starcloud-launcher/microsoft.tokens.json:
{
"<uuid>": {
"token": "dpapi:Base64...",
"saved_at": "2026-09-01T12:00:00Z"
}
}Windows 上用 DPAPI(DataProtectionScope.CurrentUser)加密:
private static string Protect(string plain)
{
var bytes = Encoding.UTF8.GetBytes(plain);
if (OperatingSystem.IsWindows())
{
var encrypted = ProtectedData.Protect(bytes, null, DataProtectionScope.CurrentUser);
return "dpapi:" + Convert.ToBase64String(encrypted);
}
return "plain:" + Convert.ToBase64String(bytes);
}CurrentUser 作用域意味着只有当前 Windows 用户的登录凭据能解开——别人拿到文件也读不出来。
非 Windows 上是明文
注意那个 plain: 前缀:只是 Base64,不是加密。桌面端主战场是 Windows,所以非 Windows 分支的定位是「保证跨平台能编译能跑」,而不是「也做到同等安全」。
如果你的数据目录在共享盘、网络盘,或者你会把数据根同步到别的机器上,这一点必须清楚:那份 microsoft.tokens.json 里的 refresh_token 等于明文密码。Base64 只是换了个编码,挡不住任何有心人。
30 天从哪算起
MicrosoftTokenStore 有两个写入口,语义完全不同:
/// 手动登录成功:写入并重新开始 30 天计时。
public static void SaveNew(string uuid, string refreshToken) { ... SavedAt = UtcNow(); }
/// 自动刷新成功:只轮换 token,保留原来的 30 天起点。
public static void Update(string uuid, string refreshToken)
{
// 命中已有条目:只换 Token,不动 SavedAt
}30 天是从最后一次手动登录算起,不是从最后一次使用算起。自动续期只轮换令牌内容,不重置计时器——否则一个自动刷新脚本就能让一个过期账号无限续命。
Update 在条目不存在时会退化成新建(此时才写 SavedAt = UtcNow()),这是防御性的,正常流程下条目一定存在。
过期条目在读取时惰性清理:TryGet 发现 UtcNow() - entry.SavedAt > Lifetime 就顺手删掉并返回 false。
恢复流程
启动游戏时如果内存里没有会话,第三方账户直接抛「需要重新登录」,微软账户则尝试用本地 refresh_token 换:
if (result.RefreshToken is { Length: > 0 } rotated)
MicrosoftTokenStore.Update(acc.Uuid, rotated);注意这里用的是 Update 而不是 SaveNew——启动时自动续期不该重置 30 天计时。
刷新失败(过期、被吊销、账号改了密码)时立刻删掉本地凭证:
catch (Exception ex)
{
LauncherLog.Write("auth", $"本地凭证恢复失败,需要重新登录:{ex.Message}");
MicrosoftTokenStore.Remove(acc.Uuid);
return null;
}留着废 token 的唯一后果是每次启动都拿它去撞一次微软的 invalid_grant,然后失败重来。
第三方令牌没有落盘这条路
第三方账户的 access token 只有内存份。Yggdrasil 协议本身也不提供 refresh_token——换票是一次性的,除非服务端自己扩展。所以第三方账户在启动器重启后必须重新输一次密码。
这不是我们偷懒,是协议没有这个能力。
删除账户会一并清凭证
AccountStore.Remove(uuid) 里调用了 MicrosoftTokenStore.Remove(uuid),删账户时把落盘的 refresh_token 一起带走,不留孤儿条目。
相关链接:微软设备码登录 · 第三方认证 · 离线账户与 UUID 算法 · 皮肤库与换肤