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((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() .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(Assert.IsType(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(await lookup.GetCachedLoginsForPlatformId(Platforms.Standalone, request.platform_id)); Assert.Empty(Assert.IsType>(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() .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(Assert.IsType( (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(result); else { var response = Assert.IsType(Assert.IsType(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(result.Result); Assert.Equal(400, refusal.StatusCode); var body = Assert.IsType(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(result.Result); Assert.Equal(400, refusalResult.StatusCode); var refusal = Assert.IsType(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(unknownResult.Result); Assert.Equal(400, unknownRefusal.StatusCode); Assert.Equal("invalid_grant", Assert.IsType(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 AuthenticateSteamTicketAsync(string ticket, string appId, CancellationToken cancellationToken) { Assert.Equal("140test-ticket", ticket); Assert.Equal("471710", appId); return Task.FromResult(authenticatedSteamId); } } /// /// 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. /// private sealed class RealSteamTicketAuthAPI : AuthAPI { public RealSteamTicketAuthAPI(RecDBContext db) : base(db) { } } private sealed class TestDbContextFactory : IDbContextFactory { private readonly DbContextOptions 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( await lookupApi.GetCachedLoginsForPlatformId(Platforms.Steam, PlatformId)); Assert.Empty(Assert.IsAssignableFrom>(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(refused.Result); Assert.Equal(400, refusalResult.StatusCode); var refusal = Assert.IsType(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(Assert.IsType(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( await lookupApi.GetCachedLoginsForPlatformId(Platforms.Steam, PlatformId)); Assert.Single(Assert.IsAssignableFrom>(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(Assert.IsType(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(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(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(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(Assert.IsType(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( await lookupApi.GetCachedLoginsForPlatformId(Platforms.Steam, PlatformId)); var links = Assert.IsAssignableFrom>(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(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 { ["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(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; } } /// /// 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. /// [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(result.Result); Assert.Equal(400, refusalResult.StatusCode); var refusal = Assert.IsType(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(Assert.IsType(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(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(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(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( await api.GetCachedLoginsForPlatformId(Platforms.Steam, PlatformId)); var singleLinks = Assert.IsAssignableFrom>(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(Assert.IsType(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; } } /// /// 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. /// [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(Assert.IsType(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; } } /// /// 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. /// [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(Assert.IsType(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; } } /// /// 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. /// [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(Assert.IsType(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; } } /// /// [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(Assert.IsType(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(Assert.IsType(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)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(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)generate.Invoke(null, new object[] { db, 17L, "20230831", DeviceClass.Screen, Platforms.Steam, "keepsake:17" })!; var jwt = new JwtSecurityTokenHandler().ReadJwtToken(Assert.IsType(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((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( await lookupApi.GetCachedLoginsForPlatformId(Platforms.Steam, PlatformId)); var links = Assert.IsAssignableFrom>(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(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(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(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(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(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(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(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 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 { ["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 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(); /// /// 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. /// [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(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((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 respond) : HttpMessageHandler { public List Requests { get; } = []; protected override Task 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(); } } /// /// 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. /// [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 }; } }