Files
Radicade/ServerTests/AuthOnboardingFlowTests.cs
T
2026-10-16 23:33:13 -07:00

1817 lines
80 KiB
C#

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IdentityModel.Tokens;
using RadicadeServer;
using RadicadeServer.API.Accounts;
using RadicadeServer.Auth;
using RadicadeServer.API.Moderation;
using RadicadeServer.Auth.Cached;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Auth;
using RadicadeServer.RecNet.Auth.Cached;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Config;
using RadicadeServer.RecNet.Progression;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Accounts.Local;
using RadicadeServer.RecNet.Auth.Connection;
using RadicadeServer.RecNet.Enums.Players;
using RadicadeServer.RecNet.Moderation;
using RadicadeServer.RecNet.Responses;
using RadicadeServer.Utils;
using Xunit;
namespace RadicadeServer.Tests;
[Collection(DbFactoryCollection.Name)]
public class AuthOnboardingFlowTests
{
private const string PlatformId = "76561198000000000";
private const string DeviceId = "device-onboarding-flow";
[Theory]
[InlineData("cached_login")]
[InlineData("refresh_token")]
[InlineData("create_account")]
public async Task StandaloneCannotAuthenticateWithOnlyDeviceOrPlatformIdentity(string grant)
{
await using var db = CreateDb();
await SeedLinkedAccountAsync(db, true, true);
var request = RequestData();
request.platform = Platforms.Standalone;
request.grant_type = grant;
request.account_id = 17;
request.refresh_token = Guid.NewGuid().ToString();
var api = CreateApi(db);
api.Request.Headers.Authorization = "Bearer invalid";
Assert.IsType<UnauthorizedObjectResult>((await api.ConnectWithToken(request)).Result);
Assert.Single(await db.CachedLogins.ToListAsync());
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task StandalonePasswordResolvesAccountAndDoesNotTrustPostedIdentity(bool correctPassword)
{
var oldKey = DBConfig.JwtKey;
DBConfig.JwtKey = "standalone-test-signing-key-with-sufficient-length";
try
{
await using var db = CreateDb();
await SeedLinkedAccountAsync(db, true, true);
var player = await db.Players.SingleAsync();
player.Hashword = new Microsoft.AspNetCore.Identity.PasswordHasher<PlayerData>()
.HashPassword(player, "test-password-quest");
await db.SaveChangesAsync();
var request = RequestData();
request.platform = Platforms.Standalone;
request.grant_type = "password";
request.account_id = 123456;
request.username = "ExistingPlayer";
request.password = correctPassword ? "test-password-quest" : "incorrect-password";
var api = CreateApi(db);
api.Request.Headers.Authorization = "Bearer invalid";
var result = await api.ConnectWithToken(request);
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(result.Result).Value);
if (!correctPassword)
{
Assert.Null(response.access_token);
Assert.NotEmpty(response.error);
Assert.DoesNotContain(await db.CachedLogins.ToListAsync(), x => x.platform == Platforms.Standalone);
return;
}
Assert.Empty(response.error);
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
var token = new JwtSecurityTokenHandler().ReadJwtToken(response.access_token);
Assert.Equal("17", token.Claims.Single(x => x.Type == "userId").Value);
Assert.Equal("7", token.Claims.Single(x => x.Type == "rn.plat").Value);
Assert.Equal(17, request.account_id);
Assert.Equal("radicade-quest:" + DeviceId, request.platform_id);
Assert.Contains(await db.CachedLogins.ToListAsync(), x =>
x.accountId == 17 && x.platform == Platforms.Standalone && x.platformId == request.platform_id);
var lookup = new CachedLoginAPI(db);
var list = Assert.IsType<OkObjectResult>(await lookup.GetCachedLoginsForPlatformId(Platforms.Standalone, request.platform_id));
Assert.Empty(Assert.IsType<List<RadicadeServer.RecNet.Auth.Cached.CachedLogin>>(list.Value));
Assert.Empty(await lookup.GetCachedLoginsForPlatformIds(new[] { request.platform_id }));
}
finally { DBConfig.JwtKey = oldKey; }
}
[Theory]
[InlineData("valid", true)]
[InlineData("tampered", false)]
[InlineData("device", false)]
[InlineData("account", false)]
[InlineData("revoked", false)]
[InlineData("password_changed", false)]
[InlineData("expired", false)]
[InlineData("platform", false)]
public async Task QuestRememberedLoginRequiresBoundSignedSession(string scenario, bool accepted)
{
var oldKey = DBConfig.JwtKey;
DBConfig.JwtKey = "quest-remembered-test-signing-key-with-sufficient-length";
try
{
await using var db = CreateDb();
await SeedLinkedAccountAsync(db, true, true);
var player = await db.Players.SingleAsync();
player.Hashword = new Microsoft.AspNetCore.Identity.PasswordHasher<PlayerData>()
.HashPassword(player, "quest-session-test-password");
await db.SaveChangesAsync();
var request = RequestData();
request.platform = Platforms.Standalone;
request.grant_type = "password";
request.username = "ExistingPlayer";
request.password = "quest-session-test-password";
var login = CreateApi(db);
var initial = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(
(await login.ConnectWithToken(request)).Result).Value);
Assert.False(string.IsNullOrEmpty(initial.access_token));
var session = login.Response.Headers["X-Radicade-Quest-Session"].ToString();
Assert.Equal(initial.access_token, session);
if (scenario is "expired" or "platform")
{
var original = new JwtSecurityTokenHandler().ReadJwtToken(session);
var claims = original.Claims.Where(c => c.Type is not "exp" and not "nbf" and not "iat" and not "iss").ToList();
if (scenario == "platform")
{
claims.RemoveAll(c => c.Type == "rn.plat");
claims.Add(new Claim("rn.plat", "1"));
}
session = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
issuer: DBConfig.TokenIssuer, claims: claims,
notBefore: DateTime.UtcNow.AddDays(-2),
expires: scenario == "expired" ? DateTime.UtcNow.AddDays(-1) : DateTime.UtcNow.AddDays(1),
signingCredentials: new SigningCredentials(new SymmetricSecurityKey(Encoding.ASCII.GetBytes(DBConfig.JwtKey)), SecurityAlgorithms.HmacSha256)));
}
var resume = RequestData();
resume.platform = Platforms.Standalone;
resume.grant_type = "cached_login";
resume.account_id = scenario == "account" ? 999 : 0;
if (scenario == "device") resume.device_id = "different-headset";
if (scenario == "revoked") player.TokenVersion++;
if (scenario == "password_changed") player.Hashword = "different-password-hash";
await db.SaveChangesAsync();
var api = CreateApi(db);
api.Request.Headers["X-Radicade-Quest-Session"] = scenario == "tampered" ? "invalid." + session : session;
var result = (await api.ConnectWithToken(resume)).Result;
if (!accepted) Assert.IsType<UnauthorizedObjectResult>(result);
else
{
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(result).Value);
Assert.False(string.IsNullOrEmpty(response.access_token));
Assert.Equal(17, resume.account_id);
}
}
finally { DBConfig.JwtKey = oldKey; }
}
[Fact]
public async Task CachedLoginForUnknownPlatformIdentityIsNotCreatedByTheLoginAttempt()
{
var previousJwtKey = DBConfig.JwtKey;
var previousSteamApiKey = DBConfig.SteamApiKey;
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
DBConfig.SteamApiKey = "unit-test-steam-api-key";
try
{
await using var db = CreateDb();
await SeedLinkedAccountAsync(db, flowFinished: true, complete: true);
var request = RequestData();
request.grant_type = "cached_login";
request.account_id = 17;
request.platform_id = "76561198999999999";
var api = CreateApi(db, request.platform_id);
var result = await api.ConnectWithToken(request);
var refusal = Assert.IsType<ObjectResult>(result.Result);
Assert.Equal(400, refusal.StatusCode);
var body = Assert.IsType<AuthResponse>(refusal.Value);
Assert.Equal("invalid_grant", body.error);
Assert.Equal("no linked account for this platform identity", body.error_description);
Assert.Single(await db.CachedLogins.ToListAsync());
Assert.DoesNotContain(await db.CachedLogins.ToListAsync(), login =>
login.platform == Platforms.Steam && login.platformId == request.platform_id);
}
finally
{
DBConfig.JwtKey = previousJwtKey;
DBConfig.SteamApiKey = previousSteamApiKey;
}
}
[Fact]
public async Task TitleScreenLoginIsRefusedWhenItNamesNoAccount()
{
var previousJwtKey = DBConfig.JwtKey;
var previousSteamApiKey = DBConfig.SteamApiKey;
var previousPlayerFactory = PlayerUtils.DbFactory;
var previousRoomFactory = RoomUtils.DbFactory;
var factory = new TestDbContextFactory();
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
DBConfig.SteamApiKey = "unit-test-steam-api-key";
PlayerUtils.DbFactory = factory;
RoomUtils.DbFactory = factory;
try
{
await using var db = factory.CreateDbContext();
db.Profiles.Add(new Profile
{
accountId = 1,
profileImage = "default",
username = "SystemAccount",
displayName = "SystemAccount",
createdAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
var api = CreateApi(db, PlatformId);
var result = await api.ConnectWithToken(RequestData());
var refusalResult = Assert.IsType<ObjectResult>(result.Result);
Assert.Equal(400, refusalResult.StatusCode);
var refusal = Assert.IsType<AuthResponse>(refusalResult.Value);
Assert.Equal("invalid_request", refusal.error);
Assert.True(string.IsNullOrWhiteSpace(refusal.access_token));
var unknown = RequestData();
unknown.account_id = 4242;
var unknownResult = await api.ConnectWithToken(unknown);
var unknownRefusal = Assert.IsType<ObjectResult>(unknownResult.Result);
Assert.Equal(400, unknownRefusal.StatusCode);
Assert.Equal("invalid_grant", Assert.IsType<AuthResponse>(unknownRefusal.Value).error);
Assert.Empty(await db.Players.ToListAsync());
Assert.Empty(await db.CachedLogins.ToListAsync());
Assert.Single(await db.Profiles.ToListAsync());
}
finally
{
DBConfig.JwtKey = previousJwtKey;
DBConfig.SteamApiKey = previousSteamApiKey;
PlayerUtils.DbFactory = previousPlayerFactory;
RoomUtils.DbFactory = previousRoomFactory;
}
}
private sealed class TestAuthAPI : AuthAPI
{
private readonly string? authenticatedSteamId;
public TestAuthAPI(RecDBContext db, string? authenticatedSteamId)
: base(db)
{
this.authenticatedSteamId = authenticatedSteamId;
}
protected override Task<string?> AuthenticateSteamTicketAsync(string ticket, string appId, CancellationToken cancellationToken)
{
Assert.Equal("140test-ticket", ticket);
Assert.Equal("471710", appId);
return Task.FromResult(authenticatedSteamId);
}
}
/// <summary>
/// AuthAPI with no override of the ticket check, so a test exercises the retry the server
/// actually runs rather than the harness's fixed answer.
/// </summary>
private sealed class RealSteamTicketAuthAPI : AuthAPI
{
public RealSteamTicketAuthAPI(RecDBContext db)
: base(db)
{
}
}
private sealed class TestDbContextFactory : IDbContextFactory<RecDBContext>
{
private readonly DbContextOptions<RecDBContext> options = TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
public RecDBContext CreateDbContext() => new(options);
}
[Fact]
public async Task FirstRunGetsNoSessionUntilTheOnboardingFlowSignsUp()
{
var previousJwtKey = DBConfig.JwtKey;
var previousSteamApiKey = DBConfig.SteamApiKey;
var previousPlayerFactory = PlayerUtils.DbFactory;
var previousRoomFactory = RoomUtils.DbFactory;
var factory = new TestDbContextFactory();
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
DBConfig.SteamApiKey = "unit-test-steam-api-key";
PlayerUtils.DbFactory = factory;
RoomUtils.DbFactory = factory;
try
{
await using var db = factory.CreateDbContext();
db.Profiles.Add(new Profile
{
accountId = 1,
profileImage = "default",
username = "SystemAccount",
displayName = "SystemAccount",
createdAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
var lookupApi = new CachedLoginAPI(db)
{
ControllerContext = CreateControllerContext()
};
var lookup = Assert.IsType<OkObjectResult>(
await lookupApi.GetCachedLoginsForPlatformId(Platforms.Steam, PlatformId));
Assert.Empty(Assert.IsAssignableFrom<IEnumerable<CachedLogin>>(lookup.Value));
Assert.Empty(await db.Players.ToListAsync());
Assert.Empty(await db.CachedLogins.ToListAsync());
var authApi = CreateApi(db, PlatformId);
var refused = await authApi.ConnectWithToken(RequestData());
var refusalResult = Assert.IsType<ObjectResult>(refused.Result);
Assert.Equal(400, refusalResult.StatusCode);
var refusal = Assert.IsType<AuthResponse>(refusalResult.Value);
Assert.Equal("invalid_request", refusal.error);
Assert.True(string.IsNullOrWhiteSpace(refusal.access_token));
Assert.Empty(await db.Players.ToListAsync());
Assert.Empty(await db.CachedLogins.ToListAsync());
Assert.Single(await db.Profiles.ToListAsync());
// The sign-up the flow ends on is what provisions the identity, and it keeps what the
// player chose - the birthday, name and password its pages collected - rather than
// replacing them with a generated name and an unset birthday.
var createRequest = RequestData();
createRequest.grant_type = "create_account";
createRequest.username = "ChosenOnboardingName";
createRequest.password = "onboarding-password";
createRequest.birthday = new DateTime(1999, 5, 4);
var createResult = await authApi.ConnectWithToken(createRequest);
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(createResult.Result).Value);
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
var cachedLogin = await db.CachedLogins.SingleAsync();
var player = await db.Players.SingleAsync();
var profile = await db.Profiles.SingleAsync(candidate => candidate.accountId == cachedLogin.accountId);
var inventory = await db.Unlocks.SingleAsync(candidate => candidate.AccountId == player.AccountId);
Assert.NotEmpty(inventory.GetAvatarItems());
var starterDescriptions = RadicadeServer.RecNet.Avatar.AvatarUnlockCatalog.ParseCatalog(
RadicadeServer.RecNet.Avatar.AvatarUnlockCatalog.LoadStarterJson())
.Select(item => item.AvatarItemDesc).ToHashSet();
Assert.All(inventory.GetAvatarItems(), item => Assert.True(item.IsBaseAvatarItem ||
starterDescriptions.Contains(RadicadeServer.RecNet.Avatar.AvatarItemDescription.Normalize(item.AvatarItemDesc))));
Assert.Empty(inventory.GetEquipment());
Assert.Empty(inventory.GetConsumables());
Assert.Equal(PlatformId, cachedLogin.platformId);
Assert.Equal(Platforms.Steam, cachedLogin.platform);
Assert.Equal(cachedLogin.accountId, player.AccountId);
Assert.Equal(cachedLogin.accountId, profile.accountId);
Assert.Equal("ChosenOnboardingName", profile.username);
Assert.Equal("ChosenOnboardingName", profile.displayName);
Assert.Equal(new DateTime(1999, 5, 4), player.Birthday);
Assert.False(player.CodeOfConductAccepted);
Assert.True(PlayerUtils.BuildSelfAccount(profile, player).newPlayer);
// The flow's own settings have to say it is under way and how far along: not that it never
// began (the client would restart it) and not that it finished (the client would skip into
// the game). The avatar customization and Code of Conduct pages are still ahead of the
// player, so those stay false until the client itself records them.
var onboarding = (await db.PlayerSettings.SingleAsync()).DeserializeSettings();
string OnboardingSetting(string key) => onboarding.Single(setting => setting.Key == key).Value;
Assert.Equal("True", OnboardingSetting("Recroom.AccountCreation.HasStarted"));
Assert.Equal("True", OnboardingSetting("Recroom.AccountCreation.HasChosenUsername"));
Assert.Equal("True", OnboardingSetting("Recroom.AccountCreation.HasCreatedPassword"));
Assert.Equal("False", OnboardingSetting("Recroom.AccountCreation.HasFinished"));
Assert.Equal("False", OnboardingSetting("Recroom.AccountCreation.FinishedAvatarCustomization"));
Assert.Equal("False", OnboardingSetting("Recroom.AccountCreation.AcceptedCodeOfConduct"));
Assert.Equal("1", OnboardingSetting("Radicade.NeedsOrientation"));
// The preliminary GET stays read-only, and now reports the link the sign-up created.
lookup = Assert.IsType<OkObjectResult>(
await lookupApi.GetCachedLoginsForPlatformId(Platforms.Steam, PlatformId));
Assert.Single(Assert.IsAssignableFrom<IEnumerable<CachedLogin>>(lookup.Value));
// A sign-up retried after a dropped response has to resolve to the account it already made:
// a second account for one platform identity would let a player hold two, and a banned one
// hold a clean one.
var retryRequest = RequestData();
retryRequest.grant_type = "create_account";
var retry = await authApi.ConnectWithToken(retryRequest);
var retryResponse = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(retry.Result).Value);
Assert.True(string.IsNullOrEmpty(retryResponse.error), retryResponse.error_description);
Assert.False(string.IsNullOrWhiteSpace(retryResponse.access_token));
Assert.Single(await db.CachedLogins.ToListAsync());
Assert.Single(await db.Players.ToListAsync());
Assert.Equal(cachedLogin.accountId, (await db.Players.SingleAsync()).AccountId);
var tokenHandler = new JwtSecurityTokenHandler();
var principal = tokenHandler.ValidateToken(response.access_token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(DBConfig.JwtKey)),
ValidateIssuer = false,
ValidateAudience = false,
ClockSkew = TimeSpan.Zero
}, out _);
Assert.Equal(cachedLogin.accountId.ToString(), principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
var deviceIdMethod = typeof(PlayerReportingAPI).GetMethod(nameof(PlayerReportingAPI.PostDeviceId))!;
Assert.Contains(deviceIdMethod.GetCustomAttributes(inherit: true), attribute => attribute is AllowAnonymousAttribute);
var deviceApi = new PlayerReportingAPI(db)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = principal }
}
};
Assert.IsType<OkResult>(await deviceApi.PostDeviceId(new DeviceId
{
oldDeviceId = DeviceId,
newDeviceId = "device-after-authenticated-update"
}));
Assert.Equal("device-after-authenticated-update", (await db.Players.SingleAsync()).DeviceId);
var anonymousDeviceApi = new PlayerReportingAPI(db)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity()) }
}
};
Assert.IsType<OkResult>(await anonymousDeviceApi.PostDeviceId(new DeviceId
{
oldDeviceId = DeviceId,
newDeviceId = "must-not-overwrite"
}));
Assert.Equal("device-after-authenticated-update", (await db.Players.SingleAsync()).DeviceId);
var invalidBearerDeviceApi = new PlayerReportingAPI(db)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity())
}
}
};
invalidBearerDeviceApi.Request.Headers.Authorization = "Bearer invalid";
Assert.IsType<UnauthorizedResult>(await invalidBearerDeviceApi.PostDeviceId(new DeviceId
{
oldDeviceId = DeviceId,
newDeviceId = "invalid-bearer-must-not-overwrite"
}));
Assert.Equal("device-after-authenticated-update", (await db.Players.SingleAsync()).DeviceId);
}
finally
{
DBConfig.JwtKey = previousJwtKey;
DBConfig.SteamApiKey = previousSteamApiKey;
PlayerUtils.DbFactory = previousPlayerFactory;
RoomUtils.DbFactory = previousRoomFactory;
}
}
[Fact]
public async Task FirstBootGetsASessionForTheAccountItCreates()
{
var previousJwtKey = DBConfig.JwtKey;
var previousSteamApiKey = DBConfig.SteamApiKey;
var previousPlayerFactory = PlayerUtils.DbFactory;
var previousRoomFactory = RoomUtils.DbFactory;
var factory = new TestDbContextFactory();
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
DBConfig.SteamApiKey = "unit-test-steam-api-key";
PlayerUtils.DbFactory = factory;
RoomUtils.DbFactory = factory;
try
{
await using var db = factory.CreateDbContext();
db.Profiles.Add(new Profile
{
accountId = 17,
profileImage = "default",
username = "ReservedAccount",
displayName = "ReservedAccount",
createdAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
var api = CreateApi(db, PlatformId);
var boot = RequestData();
boot.grant_type = "create_account";
var first = await api.ConnectWithToken(boot);
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(first.Result).Value);
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
var bootedId = boot.account_id;
Assert.NotEqual(17, bootedId);
Assert.NotEqual("ReservedAccount", (await db.Profiles.SingleAsync(x => x.accountId == bootedId)).username);
var cachedLogin = await db.CachedLogins.SingleAsync();
Assert.Equal(bootedId, cachedLogin.accountId);
Assert.Equal(PlatformId, cachedLogin.platformId);
var lookupApi = new CachedLoginAPI(db) { ControllerContext = CreateControllerContext() };
var lookup = Assert.IsType<OkObjectResult>(
await lookupApi.GetCachedLoginsForPlatformId(Platforms.Steam, PlatformId));
var links = Assert.IsAssignableFrom<IEnumerable<CachedLogin>>(lookup.Value).ToList();
Assert.Single(links);
Assert.Equal(bootedId, links[0].accountId);
var onboarding = (await db.PlayerSettings.SingleAsync(x => x.AccountId == bootedId)).DeserializeSettings();
string BootSetting(string key) => onboarding.Single(setting => setting.Key == key).Value;
// The client creates the account here and then walks its creation pages, so this marker
// has to read true: it is what puts the client into account creation instead of the
// cached-account route. False here is the state that skipped the welcome screen, the
// username and every other onboarding step.
Assert.Equal("True", BootSetting("Recroom.AccountCreation.HasStarted"));
Assert.Equal("False", BootSetting("Recroom.AccountCreation.HasFinished"));
Assert.Equal("0", BootSetting("Recroom.OOBE"));
var retry = RequestData();
retry.grant_type = "create_account";
var again = await api.ConnectWithToken(retry);
Assert.IsType<OkObjectResult>(again.Result);
Assert.Equal(bootedId, retry.account_id);
Assert.Single(await db.Profiles.Where(x => x.accountId != 17).ToListAsync());
Assert.Single(await db.CachedLogins.ToListAsync());
}
finally
{
DBConfig.JwtKey = previousJwtKey;
DBConfig.SteamApiKey = previousSteamApiKey;
PlayerUtils.DbFactory = previousPlayerFactory;
RoomUtils.DbFactory = previousRoomFactory;
}
}
[Fact]
public void SteamApiKeyLoadsFromRadicadeConfigurationAndTrimsQuotes()
{
var previousSteamApiKey = DBConfig.SteamApiKey;
var previousAdminAccounts = DBConfig.AdminAccounts;
var previousInfluencerIds = DBConfig.TestInfluencerAccountIds;
var previousCoOwnerIds = DBConfig.TestCoOwnerAccountIds;
try
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Radicade:SteamApiKey"] = " \"steam-test-key\" "
})
.Build();
DBConfig.LoadConfiguration(configuration);
Assert.Equal("steam-test-key", DBConfig.SteamApiKey);
}
finally
{
DBConfig.SteamApiKey = previousSteamApiKey;
DBConfig.AdminAccounts = previousAdminAccounts;
DBConfig.TestInfluencerAccountIds = previousInfluencerIds;
DBConfig.TestCoOwnerAccountIds = previousCoOwnerIds;
}
}
[Fact]
public async Task CachedLoginFailsClosedWithoutConfiguredSteamApiKey()
{
var previousSteamApiKey = DBConfig.SteamApiKey;
var previousTrust = DBConfig.TrustUnverifiedSteamIdentity;
DBConfig.SteamApiKey = "";
DBConfig.TrustUnverifiedSteamIdentity = false;
try
{
await using var db = CreateDb();
var api = new TestAuthAPI(db, PlatformId)
{
ControllerContext = CreateControllerContext()
};
var result = await api.ConnectWithToken(RequestData());
Assert.IsType<UnauthorizedResult>(result.Result);
Assert.Empty(await db.Profiles.ToListAsync());
Assert.Empty(await db.Players.ToListAsync());
Assert.Empty(await db.CachedLogins.ToListAsync());
}
finally
{
DBConfig.SteamApiKey = previousSteamApiKey;
DBConfig.TrustUnverifiedSteamIdentity = previousTrust;
}
}
/// <summary>
/// A sign-up the server refuses has to be a FAILURE the client can see. Answering 200 puts
/// `access_token: null` in a body the client reads as a finished login: it holds no token, makes
/// no further request at all, resets its scene and retries - the exact shape of the 2026-09-30
/// log from a machine with no account on this server. The client's own onboarding trace read that
/// same 200 + null token as "Login token captured", so the server was the only place the reason
/// existed. HTTP 400 with `error`/`error_description` is the shape the unlinked-identity refusal
/// already uses and the one the client acts on.
/// </summary>
[Fact]
public async Task RefusedSignUpIsABadRequest_NotA200WithANullToken()
{
var previousJwtKey = DBConfig.JwtKey;
var previousSteamApiKey = DBConfig.SteamApiKey;
var previousPlayerFactory = PlayerUtils.DbFactory;
var previousRoomFactory = RoomUtils.DbFactory;
var factory = new TestDbContextFactory();
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
DBConfig.SteamApiKey = "unit-test-steam-api-key";
PlayerUtils.DbFactory = factory;
RoomUtils.DbFactory = factory;
try
{
await using var db = factory.CreateDbContext();
db.Profiles.Add(new Profile
{
accountId = 1,
profileImage = "default",
username = "SystemAccount",
displayName = "SystemAccount",
createdAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
var api = CreateApi(db, PlatformId);
var request = RequestData();
request.grant_type = "create_account";
request.username = "RefusedSignUpName";
request.password = "refused-sign-up-password";
request.birthday = new DateTime(1999, 5, 4);
request.device_id = "";
var result = await api.ConnectWithToken(request);
var refusalResult = Assert.IsType<ObjectResult>(result.Result);
Assert.Equal(400, refusalResult.StatusCode);
var refusal = Assert.IsType<AuthResponse>(refusalResult.Value);
Assert.False(string.IsNullOrWhiteSpace(refusal.error_description));
Assert.True(string.IsNullOrWhiteSpace(refusal.access_token));
Assert.Empty(await db.Players.ToListAsync());
Assert.Empty(await db.CachedLogins.ToListAsync());
}
finally
{
DBConfig.JwtKey = previousJwtKey;
DBConfig.SteamApiKey = previousSteamApiKey;
PlayerUtils.DbFactory = previousPlayerFactory;
RoomUtils.DbFactory = previousRoomFactory;
}
}
[Fact]
public async Task DevelopmentBootWithoutASteamKeyStillLetsANewPlayerSignUp()
{
// What a local server runs on: no publisher key, so no ticket can be verified - and with the
// ticket required, a Steam client could neither sign up nor log in at all. The fallback only
// exists for that case (a production boot throws on the missing key), and the identity it binds
// is still the posted one, so this pins that the sign-up completes and nothing else loosens.
var previousJwtKey = DBConfig.JwtKey;
var previousSteamApiKey = DBConfig.SteamApiKey;
var previousTrust = DBConfig.TrustUnverifiedSteamIdentity;
var previousPlayerFactory = PlayerUtils.DbFactory;
var previousRoomFactory = RoomUtils.DbFactory;
var factory = new TestDbContextFactory();
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
DBConfig.SteamApiKey = "";
DBConfig.TrustUnverifiedSteamIdentity = true;
PlayerUtils.DbFactory = factory;
RoomUtils.DbFactory = factory;
try
{
await using var db = factory.CreateDbContext();
// Account 1 is the system account, which must never be handed a client token - so the
// generated ids have to start after it, exactly as they do on a seeded server.
db.Profiles.Add(new Profile
{
accountId = 1,
profileImage = "default",
username = "SystemAccount",
displayName = "SystemAccount",
createdAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
// No TestAuthAPI ticket stub: the whole point is that nothing verifies a ticket here.
var api = new AuthAPI(db)
{
ControllerContext = CreateControllerContext()
};
var request = RequestData();
request.grant_type = "create_account";
request.username = "LocalDevPlayer";
var result = await api.ConnectWithToken(request);
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(result.Result).Value);
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
var cachedLogin = await db.CachedLogins.SingleAsync();
Assert.Equal(PlatformId, cachedLogin.platformId);
var profile = await db.Profiles.SingleAsync(candidate => candidate.accountId == cachedLogin.accountId);
Assert.Equal("LocalDevPlayer", profile.username);
}
finally
{
DBConfig.JwtKey = previousJwtKey;
DBConfig.SteamApiKey = previousSteamApiKey;
DBConfig.TrustUnverifiedSteamIdentity = previousTrust;
PlayerUtils.DbFactory = previousPlayerFactory;
RoomUtils.DbFactory = previousRoomFactory;
}
}
[Fact]
public async Task CachedLoginRequiresAValidSteamOwnershipTicketBeforeProvisioning()
{
var previousSteamApiKey = DBConfig.SteamApiKey;
DBConfig.SteamApiKey = "unit-test-steam-api-key";
try
{
await using var db = CreateDb();
var api = CreateApi(db);
var result = await api.ConnectWithToken(RequestData());
Assert.IsType<UnauthorizedResult>(result.Result);
Assert.Empty(await db.Profiles.ToListAsync());
Assert.Empty(await db.Players.ToListAsync());
Assert.Empty(await db.CachedLogins.ToListAsync());
}
finally
{
DBConfig.SteamApiKey = previousSteamApiKey;
}
}
[Fact]
public async Task CachedLoginRejectsSteamTicketForDifferentPlatformIdentity()
{
var previousSteamApiKey = DBConfig.SteamApiKey;
DBConfig.SteamApiKey = "unit-test-steam-api-key";
try
{
await using var db = CreateDb();
var api = CreateApi(db, "76561198000000001");
var result = await api.ConnectWithToken(RequestData());
Assert.IsType<UnauthorizedResult>(result.Result);
Assert.Empty(await db.Profiles.ToListAsync());
Assert.Empty(await db.Players.ToListAsync());
Assert.Empty(await db.CachedLogins.ToListAsync());
}
finally
{
DBConfig.SteamApiKey = previousSteamApiKey;
}
}
[Fact]
public async Task CreateAccountGrantRequiresAVerifiedPlatformTicketAndProvisionsNothing()
{
var previousJwtKey = DBConfig.JwtKey;
var previousSteamApiKey = DBConfig.SteamApiKey;
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
DBConfig.SteamApiKey = "unit-test-steam-api-key";
try
{
await using var db = CreateDb();
// Same request as the flow's sign-up, but with no ticket the server has verified: a sign-up
// binds a platform identity, so it cannot run on an identity nobody proved.
var api = CreateApi(db);
var request = RequestData();
request.grant_type = "create_account";
var result = await api.ConnectWithToken(request);
Assert.IsType<UnauthorizedResult>(result.Result);
Assert.Empty(await db.Profiles.ToListAsync());
Assert.Empty(await db.Players.ToListAsync());
Assert.Empty(await db.CachedLogins.ToListAsync());
}
finally
{
DBConfig.JwtKey = previousJwtKey;
DBConfig.SteamApiKey = previousSteamApiKey;
}
}
[Fact]
public async Task CachedLoginLookupDoesNotOfferOrphanedOrIncompleteAccountLinks()
{
await using var db = CreateDb();
db.Profiles.Add(new Profile
{
accountId = 17,
profileImage = "default",
username = "CompleteAccount",
displayName = "CompleteAccount",
createdAt = DateTime.UtcNow
});
db.Players.Add(new PlayerData { AccountId = 17, DeviceId = DeviceId });
db.Presences.Add(new RealPresence { playerId = 17, deviceClass = DeviceClass.Screen });
db.Profiles.Add(new Profile
{
accountId = 18,
profileImage = "default",
username = "MissingPlayer",
displayName = "MissingPlayer",
createdAt = DateTime.UtcNow
});
db.Players.Add(new PlayerData { AccountId = 19, DeviceId = DeviceId });
db.CachedLogins.AddRange(
new CachedLogin { accountId = 17, platform = Platforms.Steam, platformId = PlatformId, lastLoginTime = DateTime.UtcNow },
new CachedLogin { accountId = 18, platform = Platforms.Steam, platformId = PlatformId, lastLoginTime = DateTime.UtcNow.AddMinutes(1) },
new CachedLogin { accountId = 19, platform = Platforms.Steam, platformId = PlatformId, lastLoginTime = DateTime.UtcNow.AddMinutes(2) },
new CachedLogin { accountId = 20, platform = Platforms.Steam, platformId = PlatformId, lastLoginTime = DateTime.UtcNow.AddMinutes(3) });
db.Presences.Add(new RealPresence { playerId = 19, deviceClass = DeviceClass.Screen });
await db.SaveChangesAsync();
var api = new CachedLoginAPI(db) { ControllerContext = CreateControllerContext() };
var singleLookup = Assert.IsType<OkObjectResult>(
await api.GetCachedLoginsForPlatformId(Platforms.Steam, PlatformId));
var singleLinks = Assert.IsAssignableFrom<IEnumerable<CachedLogin>>(singleLookup.Value).ToList();
Assert.Single(singleLinks);
Assert.Equal(17, singleLinks[0].accountId);
var bulkLinks = await api.GetCachedLoginsForPlatformIds(new[] { PlatformId, PlatformId });
Assert.Single(bulkLinks);
Assert.Equal(17, bulkLinks[0].accountId);
}
[Fact]
public async Task CachedLoginForLinkedPlatformStillIssuesTokenWithoutCreatingAnotherIdentity()
{
var previousJwtKey = DBConfig.JwtKey;
var previousSteamApiKey = DBConfig.SteamApiKey;
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
DBConfig.SteamApiKey = "unit-test-steam-api-key";
try
{
await using var db = CreateDb();
db.Profiles.Add(new Profile
{
accountId = 17,
profileImage = "default",
username = "ExistingUser",
displayName = "ExistingUser",
createdAt = DateTime.UtcNow
});
db.Players.Add(new PlayerData
{
AccountId = 17,
DeviceId = DeviceId,
DeviceClass = DeviceClass.Screen,
AppVersion = "20230831"
});
db.CachedLogins.Add(new CachedLogin
{
accountId = 17,
platform = Platforms.Steam,
platformId = PlatformId,
lastLoginTime = DateTime.UtcNow
});
db.Presences.Add(new RealPresence
{
playerId = 17,
deviceClass = DeviceClass.Screen
});
await db.SaveChangesAsync();
var api = CreateApi(db, PlatformId);
var request = RequestData();
request.account_id = 17;
var result = await api.ConnectWithToken(request);
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(result.Result).Value);
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
Assert.Single(await db.Profiles.ToListAsync());
Assert.Single(await db.CachedLogins.ToListAsync());
}
finally
{
DBConfig.JwtKey = previousJwtKey;
DBConfig.SteamApiKey = previousSteamApiKey;
}
}
/// <summary>
/// The player's own case: the identity they sign up with already owns an account (their Steam
/// account was linked on an earlier boot), so the sign-up resumes THAT account - and the name,
/// birthday and password they typed on the flow's pages have to land on it. This is what used to
/// be dropped: the grant matched the existing link, issued a token for the account, and threw the
/// payload away, so the player finished the flow looking at a name they never chose.
/// </summary>
[Theory]
[InlineData("TheNameIChose", false)]
[InlineData("ExistingPlayer", false)]
[InlineData("TheNameIChose", true)]
public async Task CreateAccountOnALinkedIdentityFinishesThatAccountWithTheNameTheFlowCollected(string chosenName, bool missingSettings)
{
var previousJwtKey = DBConfig.JwtKey;
var previousSteamApiKey = DBConfig.SteamApiKey;
var previousPlayerFactory = PlayerUtils.DbFactory;
var previousRoomFactory = RoomUtils.DbFactory;
var factory = new TestDbContextFactory();
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
DBConfig.SteamApiKey = "unit-test-steam-api-key";
PlayerUtils.DbFactory = factory;
RoomUtils.DbFactory = factory;
try
{
await using var db = factory.CreateDbContext();
await SeedLinkedAccountAsync(db, flowFinished: false, complete: false);
if (missingSettings)
{
db.PlayerSettings.Remove(await db.PlayerSettings.SingleAsync());
await db.SaveChangesAsync();
}
var api = CreateApi(db, PlatformId);
var request = RequestData();
request.grant_type = "create_account";
request.username = chosenName;
if (missingSettings) request.account_id = 17;
request.password = "the-password-from-the-flow";
request.birthday = new DateTime(2011, 8, 30);
var result = await api.ConnectWithToken(request);
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(result.Result).Value);
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
// One identity, one account: the sign-up finished the account it was resuming rather than
// minting a second one behind it.
var cachedLogin = await db.CachedLogins.SingleAsync();
Assert.Equal(17, cachedLogin.accountId);
Assert.Single(await db.Profiles.ToListAsync());
var profile = await db.Profiles.SingleAsync();
Assert.Equal(chosenName, profile.username);
Assert.Equal(chosenName, profile.displayName);
var player = await db.Players.SingleAsync();
Assert.Equal(new DateTime(2011, 8, 30), player.Birthday);
Assert.False(player.IsJunior);
Assert.False(string.IsNullOrWhiteSpace(player.Hashword));
var settings = (await db.PlayerSettings.SingleAsync()).DeserializeSettings();
Assert.Contains(settings, setting => setting.Key == "Recroom.AccountCreation.HasChosenUsername" && setting.Value == "True");
Assert.Contains(settings, setting => setting.Key == "Recroom.AccountCreation.HasCreatedPassword" && setting.Value == "True");
Assert.DoesNotContain(settings, setting => setting.Key == "Recroom.AccountCreation.HasFinished" && setting.Value == "True");
}
finally
{
DBConfig.JwtKey = previousJwtKey;
DBConfig.SteamApiKey = previousSteamApiKey;
PlayerUtils.DbFactory = previousPlayerFactory;
RoomUtils.DbFactory = previousRoomFactory;
}
}
/// <summary>
/// A finished account can still lack required details. A new-account request must not
/// reuse it, even when the client carries the previously selected account ID.
/// </summary>
[Fact]
public async Task CreateAccountDoesNotReuseAFinishedAccountWithMissingDetails()
{
var previousJwtKey = DBConfig.JwtKey;
var previousSteamApiKey = DBConfig.SteamApiKey;
var previousPlayerFactory = PlayerUtils.DbFactory;
var previousRoomFactory = RoomUtils.DbFactory;
var factory = new TestDbContextFactory();
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
DBConfig.SteamApiKey = "unit-test-steam-api-key";
PlayerUtils.DbFactory = factory;
RoomUtils.DbFactory = factory;
try
{
await using var db = factory.CreateDbContext();
await SeedLinkedAccountAsync(db, flowFinished: true, complete: false);
var player = await db.Players.SingleAsync();
Assert.True(PlayerUtils.IsNewPlayer(player), "the fixture has to be an account the client still shows the flow for");
var api = CreateApi(db, PlatformId);
var request = RequestData();
request.grant_type = "create_account";
request.username = "NamedOnAResumedFlow";
request.account_id = 17;
request.password = "the-password-from-the-flow";
var result = await api.ConnectWithToken(request);
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(result.Result).Value);
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
Assert.Equal(2, await db.CachedLogins.CountAsync());
Assert.NotEqual(17, request.account_id);
Assert.Equal("NamedOnAResumedFlow", (await db.Profiles.SingleAsync(x => x.accountId == request.account_id)).username);
Assert.Equal("ExistingPlayer", (await db.Profiles.SingleAsync(x => x.accountId == 17)).username);
Assert.False(string.IsNullOrWhiteSpace((await db.Players.SingleAsync(x => x.AccountId == request.account_id)).Hashword));
}
finally
{
DBConfig.JwtKey = previousJwtKey;
DBConfig.SteamApiKey = previousSteamApiKey;
PlayerUtils.DbFactory = previousPlayerFactory;
RoomUtils.DbFactory = previousRoomFactory;
}
}
/// <summary>
/// The other half of the rule: an account that is complete - birthday, password and accepted Code
/// of Conduct - is not renamed or re-keyed by a sign-up grant that arrives later.
/// The new sign-up receives its own account.
/// </summary>
[Fact]
public async Task CreateAccountLeavesACompleteAccountsNameAndPasswordAlone()
{
var previousJwtKey = DBConfig.JwtKey;
var previousSteamApiKey = DBConfig.SteamApiKey;
var previousPlayerFactory = PlayerUtils.DbFactory;
var previousRoomFactory = RoomUtils.DbFactory;
var factory = new TestDbContextFactory();
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
DBConfig.SteamApiKey = "unit-test-steam-api-key";
PlayerUtils.DbFactory = factory;
RoomUtils.DbFactory = factory;
try
{
await using var db = factory.CreateDbContext();
await SeedLinkedAccountAsync(db, flowFinished: true, complete: true);
var api = CreateApi(db, PlatformId);
var request = RequestData();
request.grant_type = "create_account";
request.username = "SomebodyElsesTurn";
request.account_id = 17;
request.password = "a-password-from-a-later-flow";
request.birthday = new DateTime(1999, 5, 4);
var result = await api.ConnectWithToken(request);
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(result.Result).Value);
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
Assert.NotEqual(17, request.account_id);
var profile = await db.Profiles.SingleAsync(x => x.accountId == 17);
Assert.Equal("ExistingPlayer", profile.username);
Assert.Equal("ExistingPlayer", profile.displayName);
var player = await db.Players.SingleAsync(x => x.AccountId == 17);
Assert.Equal(new DateTime(2000, 1, 1), player.Birthday);
Assert.Equal("seeded-hash", player.Hashword);
}
finally
{
DBConfig.JwtKey = previousJwtKey;
DBConfig.SteamApiKey = previousSteamApiKey;
PlayerUtils.DbFactory = previousPlayerFactory;
RoomUtils.DbFactory = previousRoomFactory;
}
}
/// <summary>
/// </summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task BareCreateAccountResolvesToTheLinkedAccountAndNeverMintsASecond(bool complete)
{
var previousJwtKey = DBConfig.JwtKey;
var previousSteamApiKey = DBConfig.SteamApiKey;
var previousPlayerFactory = PlayerUtils.DbFactory;
var previousRoomFactory = RoomUtils.DbFactory;
var factory = new TestDbContextFactory();
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
DBConfig.SteamApiKey = "unit-test-steam-api-key";
PlayerUtils.DbFactory = factory;
RoomUtils.DbFactory = factory;
try
{
await using var db = factory.CreateDbContext();
await SeedLinkedAccountAsync(db, flowFinished: true, complete: complete);
var api = CreateApi(db, PlatformId);
var request = RequestData();
request.grant_type = "create_account";
var result = await api.ConnectWithToken(request);
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(result.Result).Value);
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
Assert.Equal(17, request.account_id);
Assert.Single(await db.Profiles.ToListAsync());
Assert.Single(await db.CachedLogins.ToListAsync());
Assert.Single(await db.Players.ToListAsync());
Assert.Equal("ExistingPlayer", (await db.Profiles.SingleAsync(x => x.accountId == 17)).username);
var retry = RequestData();
retry.grant_type = "create_account";
await api.ConnectWithToken(retry);
Assert.Equal(17, retry.account_id);
Assert.Single(await db.Profiles.ToListAsync());
var login = RequestData();
login.account_id = 17;
var loginResult = await api.ConnectWithToken(login);
var loginResponse = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(loginResult.Result).Value);
Assert.False(string.IsNullOrWhiteSpace(loginResponse.access_token));
Assert.Equal(17, login.account_id);
Assert.Equal("ExistingPlayer", (await db.Profiles.SingleAsync(x => x.accountId == 17)).username);
}
finally
{
DBConfig.JwtKey = previousJwtKey;
DBConfig.SteamApiKey = previousSteamApiKey;
PlayerUtils.DbFactory = previousPlayerFactory;
RoomUtils.DbFactory = previousRoomFactory;
}
}
[Theory]
[InlineData("developer,influencer,betastudio", true)]
[InlineData("developer,influencer", false)]
[InlineData(" BETASTUDIO ", true)]
public async Task StudioRoleIsIncludedInTokensOnlyWhenAssigned(string roles, bool expected)
{
var previousKey = DBConfig.JwtKey;
var previousFactory = RoomUtils.DbFactory;
var factory = new TestDbContextFactory();
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
RoomUtils.DbFactory = factory;
try
{
using var db = factory.CreateDbContext();
await SeedLinkedAccountAsync(db, true, true);
var profile = await db.Profiles.SingleAsync();
profile.role = roles;
await db.SaveChangesAsync();
var generate = typeof(AuthAPI).GetMethod("GenerateToken",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;
var result = await (Task<Success>)generate.Invoke(null, new object[] {
db, 17L, "20230831", DeviceClass.Screen, Platforms.Steam, "studio:17" })!;
Assert.True(result.success);
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(Assert.IsType<string>(result.value));
Assert.Equal(expected, jwt.Claims.Any(c => c.Type == "role" && c.Value == "betastudio"));
}
finally
{
DBConfig.JwtKey = previousKey;
RoomUtils.DbFactory = previousFactory;
}
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task KeepsakeTokenRolesFollowFeatureWithoutGrantingDeveloperAccess(bool enabled)
{
var previousKey = DBConfig.JwtKey;
var previousFactory = RoomUtils.DbFactory;
var previousFeature = RadicadeServer.RecNet.Keepsakes.KeepsakeFeature.Override;
var factory = new TestDbContextFactory();
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
RoomUtils.DbFactory = factory;
RadicadeServer.RecNet.Keepsakes.KeepsakeFeature.Override = enabled;
try
{
using var db = factory.CreateDbContext();
await SeedLinkedAccountAsync(db, true, true);
var profile = await db.Profiles.SingleAsync();
profile.role = "";
await db.SaveChangesAsync();
var generate = typeof(AuthAPI).GetMethod("GenerateToken",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;
var result = await (Task<Success>)generate.Invoke(null, new object[] {
db, 17L, "20230831", DeviceClass.Screen, Platforms.Steam, "keepsake:17" })!;
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(Assert.IsType<string>(result.value));
var roles = jwt.Claims.Where(c => c.Type == "role").Select(c => c.Value).ToArray();
Assert.Equal(enabled, roles.Contains("keepsake"));
Assert.Equal(enabled, roles.Contains("livekeepsakeeventroomsaveoverride"));
Assert.DoesNotContain("developer", roles);
Assert.DoesNotContain("moderator", roles);
}
finally
{
DBConfig.JwtKey = previousKey;
RoomUtils.DbFactory = previousFactory;
RadicadeServer.RecNet.Keepsakes.KeepsakeFeature.Override = previousFeature;
}
}
[Fact]
public async Task CreateAccountRouteAdoptsTheAccountTheBootWasHanded()
{
var previousJwtKey = DBConfig.JwtKey;
var previousSteamApiKey = DBConfig.SteamApiKey;
var previousPlayerFactory = PlayerUtils.DbFactory;
var previousRoomFactory = RoomUtils.DbFactory;
var factory = new TestDbContextFactory();
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
DBConfig.SteamApiKey = "unit-test-steam-api-key";
PlayerUtils.DbFactory = factory;
RoomUtils.DbFactory = factory;
try
{
await using var db = factory.CreateDbContext();
var bootApi = CreateApi(db, PlatformId);
var boot = RequestData();
boot.grant_type = "create_account";
Assert.IsType<OkObjectResult>((await bootApi.ConnectWithToken(boot)).Result);
var bootedId = boot.account_id;
Assert.Equal(bootedId, (await db.CachedLogins.SingleAsync()).accountId);
using var steam = new SteamStub(StubSteamTicket(PlatformId));
var context = CreateAccountContext(PlatformId, steamTicket: "140test-ticket");
var api = new AccountsAPI(db) { ControllerContext = context };
var result = await api.CreateAccount(new AccountCreationData { platformId = PlatformId });
Assert.True(result.success, result.error);
Assert.NotNull(result.value);
Assert.Single(await db.Profiles.ToListAsync());
Assert.Single(await db.Players.ToListAsync());
var cachedLogin = await db.CachedLogins.SingleAsync();
Assert.Equal(bootedId, cachedLogin.accountId);
Assert.Equal(PlatformId, cachedLogin.platformId);
Assert.Equal(DeviceId, (await db.Players.SingleAsync()).DeviceId);
var onboarding = (await db.PlayerSettings.SingleAsync()).DeserializeSettings();
Assert.Equal("True", onboarding.Single(x => x.Key == "Recroom.AccountCreation.HasStarted").Value);
Assert.Equal("False", onboarding.Single(x => x.Key == "Recroom.AccountCreation.HasFinished").Value);
var lookupApi = new CachedLoginAPI(db) { ControllerContext = CreateControllerContext() };
var lookup = Assert.IsType<OkObjectResult>(
await lookupApi.GetCachedLoginsForPlatformId(Platforms.Steam, PlatformId));
var links = Assert.IsAssignableFrom<IEnumerable<CachedLogin>>(lookup.Value).ToList();
Assert.Single(links);
Assert.Equal(bootedId, links[0].accountId);
}
finally
{
DBConfig.JwtKey = previousJwtKey;
DBConfig.SteamApiKey = previousSteamApiKey;
PlayerUtils.DbFactory = previousPlayerFactory;
RoomUtils.DbFactory = previousRoomFactory;
}
}
[Fact]
public async Task CreateAccountRouteMakesASecondAccountWhenTheDevicesAccountIsFinished()
{
var previousJwtKey = DBConfig.JwtKey;
var previousSteamApiKey = DBConfig.SteamApiKey;
var previousPlayerFactory = PlayerUtils.DbFactory;
var previousRoomFactory = RoomUtils.DbFactory;
var factory = new TestDbContextFactory();
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
DBConfig.SteamApiKey = "unit-test-steam-api-key";
PlayerUtils.DbFactory = factory;
RoomUtils.DbFactory = factory;
try
{
await using var db = factory.CreateDbContext();
await SeedLinkedAccountAsync(db, flowFinished: true, complete: true);
using var steam = new SteamStub(StubSteamTicket(PlatformId));
var context = CreateAccountContext(PlatformId, steamTicket: "140test-ticket");
var api = new AccountsAPI(db) { ControllerContext = context };
var result = await api.CreateAccount(new AccountCreationData { platformId = PlatformId });
Assert.True(result.success, result.error);
Assert.Equal(2, (await db.Profiles.ToListAsync()).Count);
Assert.NotEqual(17, (await db.Profiles.SingleAsync(x => x.accountId != 17)).accountId);
}
finally
{
DBConfig.JwtKey = previousJwtKey;
DBConfig.SteamApiKey = previousSteamApiKey;
PlayerUtils.DbFactory = previousPlayerFactory;
RoomUtils.DbFactory = previousRoomFactory;
}
}
[Fact]
public async Task CreateAccountRouteEstablishesTheAccountOnlyForAProvedSteamIdentity() =>
await RunCreateAccountTest(async db =>
{
using var steam = new SteamStub(StubSteamTicket(PlatformId));
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140test-ticket");
Assert.True(result.success, result.error);
Assert.Single(await db.Profiles.ToListAsync());
Assert.Single(await db.Players.ToListAsync());
Assert.Contains(steam.Handler.Requests, url => url.Contains("AuthenticateUserTicket"));
});
[Fact]
public async Task CreateAccountRouteRefusesAnIdentityWithNoSteamTicket() =>
await RunCreateAccountTest(async db =>
{
using var steam = new SteamStub(StubSteamTicket(PlatformId));
var result = await PostCreateAccount(db, PlatformId);
Assert.False(result.success);
Assert.Contains("No Steam ticket", Assert.IsType<string>(result.value));
Assert.Empty(await db.Profiles.ToListAsync());
Assert.Empty(await db.Players.ToListAsync());
Assert.Empty(steam.Handler.Requests);
});
[Fact]
public async Task CreateAccountRouteRefusesATicketForAnotherSteamAccount() =>
await RunCreateAccountTest(async db =>
{
using var steam = new SteamStub(StubSteamTicket("76561198000000001"));
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140someone-elses-ticket");
Assert.False(result.success);
Assert.Contains("76561198000000001", Assert.IsType<string>(result.value));
Assert.Empty(await db.Profiles.ToListAsync());
});
[Fact]
public async Task CreateAccountRouteRefusesATicketSteamWillNotAccept() =>
await RunCreateAccountTest(async db =>
{
using var steam = new SteamStub(StubRejectedSteamTicket(101, "Invalid ticket"));
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140forged-ticket");
Assert.False(result.success);
Assert.Contains("Invalid ticket", Assert.IsType<string>(result.value));
Assert.Empty(await db.Profiles.ToListAsync());
});
[Fact]
public async Task CreateAccountRouteRefusesATicketIssuedForAnotherApp() =>
await RunCreateAccountTest(async db =>
{
using var steam = new SteamStub(StubRejectedSteamTicket(102, "The ticket is for another app"));
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140other-app-ticket");
Assert.False(result.success);
Assert.Contains("not for an app this server accepts", Assert.IsType<string>(result.value));
Assert.Empty(await db.Profiles.ToListAsync());
});
[Fact]
public async Task CreateAccountRouteAcceptsATicketCarriedInPlatformAuth() =>
await RunCreateAccountTest(async db =>
{
using var steam = new SteamStub(StubSteamTicket(PlatformId));
var result = await PostCreateAccount(db, PlatformId,
platformAuth: "{\"Ticket\":\"140test-ticket\",\"AppId\":\"471710\"}");
Assert.True(result.success, result.error);
Assert.Single(await db.Profiles.ToListAsync());
});
[Fact]
public async Task CreateAccountRouteRefusesASteamAccountWithACommunityBan() =>
await RunCreateAccountTest(async db =>
{
using var steam = new SteamStub(StubSteamTicket(PlatformId, communityBanned: true));
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140test-ticket");
Assert.False(result.success);
Assert.Contains("community ban", Assert.IsType<string>(result.value));
Assert.Empty(await db.Profiles.ToListAsync());
});
[Theory]
[InlineData(0)]
[InlineData(3)]
[InlineData(3650)]
public async Task CreateAccountRouteAcceptsASteamAccountOfAnyAge(int daysOld) =>
await RunCreateAccountTest(async db =>
{
using var steam = new SteamStub(StubSteamTicket(PlatformId,
timeCreated: DateTimeOffset.UtcNow.AddDays(-daysOld).ToUnixTimeSeconds()));
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140test-ticket");
Assert.True(result.success, result.error);
Assert.Single(await db.Profiles.ToListAsync());
});
[Fact]
public async Task CreateAccountRouteRefusesASteamProfileThatIsNotSetUp() =>
await RunCreateAccountTest(async db =>
{
using var steam = new SteamStub(StubSteamTicket(PlatformId, profileState: 0));
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140test-ticket");
Assert.False(result.success);
Assert.Contains("set up", Assert.IsType<string>(result.value));
Assert.Empty(await db.Profiles.ToListAsync());
});
[Fact]
public async Task CreateAccountRouteAllowsTheAccountWhenSteamCannotAnswerTheStandingCheck() =>
await RunCreateAccountTest(async db =>
{
using var steam = new SteamStub(StubSteamTicket(PlatformId, summariesUnavailable: true));
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140test-ticket");
Assert.True(result.success, result.error);
Assert.Single(await db.Profiles.ToListAsync());
});
[Fact]
public async Task CreateAccountRouteRefusesSteamWhenThereIsNoKeyAndUnverifiedIdentitiesAreNotTrusted() =>
await RunCreateAccountTest(async db =>
{
using var steam = new SteamStub(StubSteamTicket(PlatformId));
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140test-ticket");
Assert.False(result.success);
Assert.Contains("no SteamApiKey", Assert.IsType<string>(result.value));
Assert.Empty(await db.Profiles.ToListAsync());
Assert.Empty(steam.Handler.Requests);
}, steamApiKey: "");
[Fact]
public async Task CreateAccountRouteTrustsAnUnverifiedSteamIdentityOnlyWhenToldTo() =>
await RunCreateAccountTest(async db =>
{
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140test-ticket");
Assert.True(result.success, result.error);
Assert.Single(await db.Profiles.ToListAsync());
}, steamApiKey: "", trustUnverified: true);
private static async Task RunCreateAccountTest(Func<RecDBContext, Task> body,
string steamApiKey = "unit-test-steam-api-key", bool trustUnverified = false)
{
var previousJwtKey = DBConfig.JwtKey;
var previousSteamApiKey = DBConfig.SteamApiKey;
var previousTrust = DBConfig.TrustUnverifiedSteamIdentity;
var previousPlayerFactory = PlayerUtils.DbFactory;
var previousRoomFactory = RoomUtils.DbFactory;
var factory = new TestDbContextFactory();
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
DBConfig.SteamApiKey = steamApiKey;
DBConfig.TrustUnverifiedSteamIdentity = trustUnverified;
PlayerUtils.DbFactory = factory;
RoomUtils.DbFactory = factory;
try
{
await using var db = factory.CreateDbContext();
await body(db);
}
finally
{
DBConfig.JwtKey = previousJwtKey;
DBConfig.SteamApiKey = previousSteamApiKey;
DBConfig.TrustUnverifiedSteamIdentity = previousTrust;
PlayerUtils.DbFactory = previousPlayerFactory;
RoomUtils.DbFactory = previousRoomFactory;
}
}
private static ControllerContext CreateAccountContext(string platformId, string? steamTicket = null, string? platformAuth = null)
{
var context = CreateControllerContext();
context.HttpContext.Request.ContentType = "application/x-www-form-urlencoded";
var form = new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["platform"] = "0",
["platformId"] = platformId,
["device_id"] = DeviceId,
["device_class"] = "0"
};
if (platformAuth != null) form["platform_auth"] = platformAuth;
context.HttpContext.Request.Form = new FormCollection(form);
if (steamTicket != null) context.HttpContext.Request.Headers["x-steam-ticket"] = steamTicket;
return context;
}
private static Task<Success> PostCreateAccount(RecDBContext db, string platformId,
string? steamTicket = null, string? platformAuth = null)
{
var api = new AccountsAPI(db) { ControllerContext = CreateAccountContext(platformId, steamTicket, platformAuth) };
return api.CreateAccount(new AccountCreationData { platformId = platformId });
}
private static readonly long LongStandingSteamAccount =
DateTimeOffset.UtcNow.AddYears(-5).ToUnixTimeSeconds();
/// <summary>
/// The accepted-app-id retry on the token grant. TestAuthAPI overrides the ticket check, which is
/// exactly where that retry lives, so anything asserted through it would pass either way - these
/// two run the production implementation instead. The 2023 client identifies as app 480 while
/// platform_auth can claim 471710 (or the reverse), and Valve answers 102 for the mismatch.
/// </summary>
[Fact]
public async Task CreateAccountGrantRetriesAnAcceptedAppIdWhenValveSaysTheTicketIsForAnotherApp()
{
var previousJwtKey = DBConfig.JwtKey;
var previousSteamApiKey = DBConfig.SteamApiKey;
var previousPlayerFactory = PlayerUtils.DbFactory;
var previousRoomFactory = RoomUtils.DbFactory;
var factory = new TestDbContextFactory();
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
DBConfig.SteamApiKey = "unit-test-steam-api-key";
PlayerUtils.DbFactory = factory;
RoomUtils.DbFactory = factory;
try
{
using var steam = new SteamStub(new SteamStubHandler(url =>
url.Contains("appid=471710", StringComparison.Ordinal)
? SteamJson("{\"response\":{\"error\":{\"errorcode\":102,\"errordesc\":\"The ticket is for another app\"}}}")
: SteamJson($"{{\"response\":{{\"params\":{{\"result\":\"OK\",\"steamid\":\"{PlatformId}\"}}}}}}")));
await using var db = factory.CreateDbContext();
var api = new RealSteamTicketAuthAPI(db) { ControllerContext = CreateControllerContext() };
var request = RequestData();
request.grant_type = "create_account";
request.device_id = "device-steam-appid-retry";
var result = await api.ConnectWithToken(request);
Assert.IsType<OkObjectResult>(result.Result);
Assert.Equal(2, steam.Handler.Requests.Count);
Assert.Contains("appid=480", steam.Handler.Requests[1], StringComparison.Ordinal);
Assert.Single(await db.Profiles.ToListAsync());
}
finally
{
DBConfig.JwtKey = previousJwtKey;
DBConfig.SteamApiKey = previousSteamApiKey;
PlayerUtils.DbFactory = previousPlayerFactory;
RoomUtils.DbFactory = previousRoomFactory;
}
}
[Fact]
public async Task CreateAccountGrantStillRefusesATicketNoAcceptedAppIdConfirms()
{
var previousSteamApiKey = DBConfig.SteamApiKey;
DBConfig.SteamApiKey = "unit-test-steam-api-key";
try
{
using var steam = new SteamStub(StubRejectedSteamTicket(102, "The ticket is for another app"));
await using var db = CreateDb();
var api = new RealSteamTicketAuthAPI(db) { ControllerContext = CreateControllerContext() };
var request = RequestData();
request.grant_type = "create_account";
request.device_id = "device-steam-appid-refused";
Assert.IsType<UnauthorizedResult>((await api.ConnectWithToken(request)).Result);
Assert.Empty(await db.Profiles.ToListAsync());
}
finally
{
DBConfig.SteamApiKey = previousSteamApiKey;
}
}
private static SteamStubHandler StubSteamTicket(string steamId, long? timeCreated = null,
bool communityBanned = false, int profileState = 1, bool summariesUnavailable = false)
{
var created = timeCreated ?? LongStandingSteamAccount;
return new SteamStubHandler(url =>
{
if (url.Contains("AuthenticateUserTicket"))
return SteamJson($"{{\"response\":{{\"params\":{{\"result\":\"OK\",\"steamid\":\"{steamId}\"}}}}}}");
if (url.Contains("GetPlayerSummaries"))
return summariesUnavailable
? new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError)
: SteamJson($"{{\"response\":{{\"players\":[{{\"steamid\":\"{steamId}\"," +
$"\"timecreated\":{created},\"profilestate\":{profileState}}}]}}}}");
if (url.Contains("GetPlayerBans"))
return SteamJson($"{{\"players\":[{{\"SteamId\":\"{steamId}\",\"CommunityBanned\":" +
$"{(communityBanned ? "true" : "false")}}}]}}");
return new HttpResponseMessage(System.Net.HttpStatusCode.NotFound);
});
}
private static SteamStubHandler StubRejectedSteamTicket(int errorCode, string description) =>
new(url => url.Contains("AuthenticateUserTicket")
? SteamJson($"{{\"response\":{{\"error\":{{\"errorcode\":{errorCode},\"errordesc\":\"{description}\"}}}}}}")
: new HttpResponseMessage(System.Net.HttpStatusCode.NotFound));
private static HttpResponseMessage SteamJson(string body) =>
new(System.Net.HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
private sealed class SteamStubHandler(Func<string, HttpResponseMessage> respond) : HttpMessageHandler
{
public List<string> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri?.ToString() ?? string.Empty;
Requests.Add(url);
return Task.FromResult(respond(url));
}
}
private sealed class SteamStub : IDisposable
{
private readonly HttpClient previous;
private readonly HttpClient created;
public SteamStub(SteamStubHandler handler)
{
Handler = handler;
previous = PlatformIdentity.Client;
created = new HttpClient(handler);
PlatformIdentity.Client = created;
}
public SteamStubHandler Handler { get; }
public void Dispose()
{
PlatformIdentity.Client = previous;
created.Dispose();
}
}
/// <summary>
/// The client asks for an age only when the server says the age is unsettled, and it picks its
/// junior or non-junior username page from that same answer. pendingJunior was hardcoded false
/// next to a computed HasBirthday, so an account with no birthday still read as settled: the 2023
/// title screen took the non-junior username page and skipped the BIRTHDAY page of its new-user
/// flow, which is why no account on this server ever ended up with a birthday.
/// </summary>
[Fact]
public async Task AnAccountWithoutABirthdayReportsAPendingJuniorSoTheClientAsksForAnAge()
{
await using var db = CreateDb();
await SeedLinkedAccountAsync(db, flowFinished: false, complete: false);
await SeedReputationAndProgressionAsync(db);
var player = await db.GetPlayer(17);
Assert.NotNull(player);
Assert.False(player!.HasBirthday);
Assert.True(player.PendingJunior);
Assert.False(player.JuniorProfile);
}
[Fact]
public async Task AnAccountWithABirthdayIsNotPendingSoTheAgePageIsNotOfferedAgain()
{
await using var db = CreateDb();
await SeedLinkedAccountAsync(db, flowFinished: true, complete: true);
await SeedReputationAndProgressionAsync(db);
var player = await db.GetPlayer(17);
Assert.NotNull(player);
Assert.True(player!.HasBirthday);
Assert.False(player.PendingJunior);
}
private static async Task SeedReputationAndProgressionAsync(RecDBContext db)
{
db.Reputations.Add(new Reputation { AccountId = 17 });
db.Progression.Add(new ProgressionData
{
PlayerId = 17,
XP = 0,
Level = 1,
Progress = new ProgressionJson().ToJson()
});
await db.SaveChangesAsync();
}
private static async Task SeedLinkedAccountAsync(RecDBContext db, bool flowFinished, bool complete)
{
db.Profiles.Add(new Profile
{
accountId = 17,
profileImage = "default",
username = "ExistingPlayer",
displayName = "ExistingPlayer",
createdAt = DateTime.UtcNow
});
db.Players.Add(new PlayerData
{
AccountId = 17,
DeviceId = DeviceId,
DeviceClass = DeviceClass.Screen,
AppVersion = "20230831",
Birthday = complete ? new DateTime(2000, 1, 1) : DateTime.MinValue,
Hashword = complete ? "seeded-hash" : "",
CodeOfConductAccepted = complete
});
db.CachedLogins.Add(new CachedLogin
{
accountId = 17,
platform = Platforms.Steam,
platformId = PlatformId,
lastLoginTime = DateTime.UtcNow
});
db.PlayerSettings.Add(new PlayerSettings
{
AccountId = 17,
Settings =
"[{\"Key\":\"Recroom.AccountCreation.HasStarted\",\"Value\":\"true\"}," +
$"{{\"Key\":\"Recroom.AccountCreation.HasFinished\",\"Value\":\"{(flowFinished ? "true" : "false")}\"}}]"
});
db.Presences.Add(new RealPresence
{
playerId = 17,
deviceClass = DeviceClass.Screen
});
await db.SaveChangesAsync();
}
private static TokenConnectData RequestData() => new()
{
grant_type = "cached_login",
account_id = 0,
ver = "20230831",
device_class = DeviceClass.Screen,
device_id = DeviceId,
platform = Platforms.Steam,
platform_id = PlatformId,
client_id = "recroom",
platform_auth = "{\"Ticket\":\"140test-ticket\",\"AppId\":\"471710\"}"
};
private static RecDBContext CreateDb()
{
var options = TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new RecDBContext(options);
}
private static AuthAPI CreateApi(RecDBContext db, string? authenticatedSteamId = null) => new TestAuthAPI(db, authenticatedSteamId)
{
ControllerContext = CreateControllerContext()
};
private static ControllerContext CreateControllerContext()
{
var httpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity())
};
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
return new ControllerContext { HttpContext = httpContext };
}
}