diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 134a6b5..facb161 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -22,14 +22,21 @@ jobs:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Set Docker tags
+ id: docker_meta
+ run: |
+ if [[ $GITHUB_REF == refs/heads/develop ]]; then
+ echo "tags=ghcr.io/pridevrinc/vrcauthproxy:latest" >> $GITHUB_OUTPUT
+ elif [[ $GITHUB_REF == refs/tags/* ]]; then
+ VERSION=${GITHUB_REF#refs/tags/}
+ echo "tags=ghcr.io/pridevrinc/vrcauthproxy:${VERSION},ghcr.io/pridevrinc/vrcauthproxy:latest" >> $GITHUB_OUTPUT
+ fi
- name: Build and push
uses: docker/build-push-action@v5
with:
push: true
file: ./Dockerfile
platforms: linux/amd64
- tags: |
- ghcr.io/pridevrinc/vrcauthproxy:latest
- ghcr.io/pridevrinc/vrcauthproxy:${{ github.ref }} # Push the tagged version
+ tags: ${{ steps.docker_meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 9737851..b20efef 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,8 +1,8 @@
-FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base
+FROM mcr.microsoft.com/dotnet/runtime:9.0 AS base
USER $APP_UID
WORKDIR /app
-FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["VRCAuthProxy/VRCAuthProxy.csproj", "VRCAuthProxy/"]
@@ -15,7 +15,7 @@ FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "VRCAuthProxy.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
-FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
+FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
RUN apt update && \
apt install -y curl
WORKDIR /app
diff --git a/Tests/Helpers/RedisFixture.cs b/Tests/Helpers/RedisFixture.cs
new file mode 100644
index 0000000..f22ee72
--- /dev/null
+++ b/Tests/Helpers/RedisFixture.cs
@@ -0,0 +1,78 @@
+using System.Diagnostics;
+using Xunit;
+
+namespace VRCAuthProxy.Tests.Helpers
+{
+ ///
+ /// Collection definition for Redis tests
+ ///
+ [CollectionDefinition("Redis")]
+ public class RedisCollection : ICollectionFixture
+ {
+ // This class has no code, and is never created. Its purpose is simply
+ // to be the place to apply [CollectionDefinition] and all the
+ // ICollectionFixture<> interfaces.
+ }
+
+ ///
+ /// Redis test fixture that ensures a Redis instance is available for tests
+ ///
+ public class RedisFixture : IDisposable
+ {
+ private Process? _redisProcess; // Kept for future implementation where we might start Redis
+ private bool _disposedValue;
+
+ public RedisFixture()
+ {
+ // For CI environments, this would start Redis using Docker
+ // For local development, we assume Redis is already running
+
+ // Check if Redis is available
+ if (!IsRedisRunning())
+ {
+ // In a real implementation, we could start Redis here if needed
+ Console.WriteLine("Warning: Redis is not running. Redis integration tests will be skipped.");
+ }
+ }
+
+ ///
+ /// Check if Redis is available on localhost:6379
+ ///
+ private bool IsRedisRunning()
+ {
+ try
+ {
+ using var client = new System.Net.Sockets.TcpClient();
+ var result = client.BeginConnect("localhost", 6379, null, null);
+ var success = result.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(1));
+ client.EndConnect(result);
+ return success;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!_disposedValue)
+ {
+ if (disposing)
+ {
+ // Stop Redis if we started it
+ _redisProcess?.Kill();
+ _redisProcess?.Dispose();
+ }
+
+ _disposedValue = true;
+ }
+ }
+
+ public void Dispose()
+ {
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/Integration/ProgramRedisIntegrationTests.cs b/Tests/Integration/ProgramRedisIntegrationTests.cs
new file mode 100644
index 0000000..c2f9304
--- /dev/null
+++ b/Tests/Integration/ProgramRedisIntegrationTests.cs
@@ -0,0 +1,143 @@
+using System.Net;
+using System.Text;
+using FluentAssertions;
+using VRCAuthProxy.Services;
+using VRCAuthProxy.Tests.Helpers;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+using Xunit;
+
+namespace VRCAuthProxy.Tests.Integration
+{
+ [Trait("Category", "Integration")]
+ [Collection("Redis")]
+ public class ProgramRedisIntegrationTests : IClassFixture
+ {
+ private readonly TestSetup _testSetup;
+ private readonly RedisService _redisService;
+
+ public ProgramRedisIntegrationTests(TestSetup testSetup)
+ {
+ _testSetup = testSetup;
+ _redisService = new RedisService("localhost:6379");
+ }
+
+ [Fact(Skip = "Requires Redis instance - for manual integration testing")]
+ public async Task LoginShouldStoreAuthTokenInRedis()
+ {
+ // Arrange
+ var mockServer = _testSetup.MockVRChatApi;
+ var testUsername = "redis-test-user";
+ var testPassword = "redis-test-password";
+
+ // Clean up any existing tokens
+ await _redisService.RemoveAuthToken(testUsername);
+
+ // Mock the authentication endpoint
+ mockServer.Given(Request.Create()
+ .WithPath("/api/1/auth/user")
+ .UsingGet()
+ .WithHeader("Authorization", "*"))
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithHeader("Set-Cookie", "auth=test-redis-auth-token; path=/; secure; httponly")
+ .WithBodyAsJson(new VRCAuthProxy.types.User { displayName = "RedisTestUser" }));
+
+ var mockServerUrl = mockServer.Urls.First();
+
+ // Create a config with test account
+ var config = TestSetup.CreateTestConfig();
+ config.Accounts.Clear();
+ config.Accounts.Add(new ConfigAccount
+ {
+ username = testUsername,
+ password = testPassword
+ });
+
+ // Act - Call the login method (simplified version of what Program.cs does)
+ var handler = new HttpClientHandler { UseCookies = true };
+ var cookieContainer = handler.CookieContainer;
+ var httpClient = new HttpClientCookieContainer(handler)
+ {
+ BaseAddress = new Uri(mockServerUrl),
+ Username = testUsername
+ };
+
+ // Simulate login process
+ string authString = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{testUsername}:{testPassword}"));
+ var request = new HttpRequestMessage(HttpMethod.Get, "/api/1/auth/user");
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", authString);
+ var response = await httpClient.SendAsync(request);
+
+ // Store cookies in Redis (like in Program.cs)
+ var cookies = cookieContainer.GetAllCookies().Cast()
+ .ToDictionary(c => c.Name, c => c.Value);
+ await _redisService.SaveAuthToken(testUsername, cookies);
+
+ // Assert
+ var storedToken = await _redisService.GetAuthToken(testUsername);
+ storedToken.Should().NotBeNull();
+ if (storedToken != null)
+ {
+ storedToken.Should().ContainKey("auth");
+ storedToken["auth"].Should().Be("test-redis-auth-token");
+ }
+
+ // Clean up
+ await _redisService.RemoveAuthToken(testUsername);
+ }
+
+ [Fact(Skip = "Requires Redis instance - for manual integration testing")]
+ public async Task LoginWithStoredTokenShouldReuseRedisToken()
+ {
+ // Arrange
+ var mockServer = _testSetup.MockVRChatApi;
+ var testUsername = "redis-existing-user";
+
+ // Setup stored token in Redis
+ var existingCookies = new Dictionary
+ {
+ { "auth", "existing-redis-token" }
+ };
+ await _redisService.SaveAuthToken(testUsername, existingCookies);
+
+ // Mock the user endpoint for a successful request with the token
+ mockServer.Given(Request.Create()
+ .WithPath("/api/1/auth/user")
+ .UsingGet()
+ .WithCookie("auth", "existing-redis-token"))
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBodyAsJson(new VRCAuthProxy.types.User { displayName = "ExistingRedisUser" }));
+
+ var mockServerUrl = mockServer.Urls.First();
+
+ // Act - Simulate restoring cookies and making request
+ var handler = new HttpClientHandler { UseCookies = true };
+ var cookieContainer = handler.CookieContainer;
+
+ // Restore cookies from Redis
+ var storedCookies = await _redisService.GetAuthToken(testUsername);
+ if (storedCookies != null)
+ {
+ foreach (var cookie in storedCookies)
+ {
+ cookieContainer.Add(new Uri(mockServerUrl), new Cookie(cookie.Key, cookie.Value));
+ }
+ }
+
+ var httpClient = new HttpClient(handler);
+ var response = await httpClient.GetAsync($"{mockServerUrl}/api/1/auth/user");
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var responseContent = await response.Content.ReadAsStringAsync();
+ responseContent.Should().Contain("ExistingRedisUser");
+
+ // Clean up
+ await _redisService.RemoveAuthToken(testUsername);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/Integration/RedisIntegrationTests.cs b/Tests/Integration/RedisIntegrationTests.cs
new file mode 100644
index 0000000..279b361
--- /dev/null
+++ b/Tests/Integration/RedisIntegrationTests.cs
@@ -0,0 +1,104 @@
+using System.Text.Json;
+using VRCAuthProxy.Services;
+using Xunit;
+using FluentAssertions;
+
+namespace VRCAuthProxy.Tests.Integration
+{
+ [Trait("Category", "Integration")]
+ [Collection("Redis")]
+ public class RedisIntegrationTests
+ {
+ private readonly RedisService _redisService;
+ private const string TEST_USERNAME = "testuser";
+
+ public RedisIntegrationTests()
+ {
+ // For integration tests, use the actual Redis service
+ // This assumes Redis is running locally or in a Docker container
+ // as configured in docker-compose.yml (accessible at localhost:6379)
+ _redisService = new RedisService("localhost:6379");
+
+ // Clean up any previous test data
+ _redisService.RemoveAuthToken(TEST_USERNAME).GetAwaiter().GetResult();
+ }
+
+ [Fact(Skip = "Requires Redis instance - for manual integration testing")]
+ public async Task SaveAndGetAuthToken_ShouldStoreAndRetrieveTokens()
+ {
+ // Arrange
+ var cookies = new Dictionary
+ {
+ { "auth", "integration-test-token" },
+ { "session", "integration-test-session" }
+ };
+
+ // Act - Save token
+ await _redisService.SaveAuthToken(TEST_USERNAME, cookies);
+
+ // Act - Retrieve token
+ var retrievedCookies = await _redisService.GetAuthToken(TEST_USERNAME);
+
+ // Assert
+ retrievedCookies.Should().NotBeNull();
+ if (retrievedCookies != null)
+ {
+ retrievedCookies.Should().ContainKey("auth");
+ retrievedCookies.Should().ContainKey("session");
+ retrievedCookies["auth"].Should().Be("integration-test-token");
+ retrievedCookies["session"].Should().Be("integration-test-session");
+ }
+ }
+
+ [Fact(Skip = "Requires Redis instance - for manual integration testing")]
+ public async Task RemoveAuthToken_ShouldDeleteTokenFromRedis()
+ {
+ // Arrange
+ var cookies = new Dictionary
+ {
+ { "auth", "token-to-delete" }
+ };
+ await _redisService.SaveAuthToken(TEST_USERNAME, cookies);
+
+ // Verify token exists
+ var tokenBeforeDelete = await _redisService.GetAuthToken(TEST_USERNAME);
+ tokenBeforeDelete.Should().NotBeNull();
+
+ // Act
+ await _redisService.RemoveAuthToken(TEST_USERNAME);
+
+ // Assert
+ var tokenAfterDelete = await _redisService.GetAuthToken(TEST_USERNAME);
+ tokenAfterDelete.Should().BeNull();
+ }
+
+ [Fact(Skip = "Requires Redis instance - for manual integration testing")]
+ public async Task GetAllAuthTokens_ShouldReturnAllStoredTokens()
+ {
+ // Arrange
+ var user1 = "testuser1";
+ var user2 = "testuser2";
+
+ // Clean up
+ await _redisService.RemoveAuthToken(user1);
+ await _redisService.RemoveAuthToken(user2);
+
+ // Add test data
+ await _redisService.SaveAuthToken(user1, new Dictionary { { "auth", "token1" } });
+ await _redisService.SaveAuthToken(user2, new Dictionary { { "auth", "token2" } });
+
+ // Act
+ var allTokens = await _redisService.GetAllAuthTokens();
+
+ // Assert
+ allTokens.Should().ContainKey(user1);
+ allTokens.Should().ContainKey(user2);
+ allTokens[user1]["auth"].Should().Be("token1");
+ allTokens[user2]["auth"].Should().Be("token2");
+
+ // Cleanup
+ await _redisService.RemoveAuthToken(user1);
+ await _redisService.RemoveAuthToken(user2);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/Unit/RedisServiceTests.cs b/Tests/Unit/RedisServiceTests.cs
new file mode 100644
index 0000000..140e47c
--- /dev/null
+++ b/Tests/Unit/RedisServiceTests.cs
@@ -0,0 +1,144 @@
+using System.Text.Json;
+using FluentAssertions;
+using VRCAuthProxy.Services;
+using Xunit;
+
+namespace VRCAuthProxy.Tests.Unit
+{
+ public class RedisServiceTests
+ {
+ // Using a minimal implementation for testing
+ private class InMemoryRedisService
+ {
+ private readonly Dictionary> _store = new();
+
+ public Task SaveAuthToken(string username, Dictionary cookies)
+ {
+ _store[username] = new Dictionary(cookies);
+ return Task.CompletedTask;
+ }
+
+ public Task?> GetAuthToken(string username)
+ {
+ if (_store.TryGetValue(username, out var cookies))
+ return Task.FromResult?>(new Dictionary(cookies));
+
+ return Task.FromResult?>(null);
+ }
+
+ public Task>> GetAllAuthTokens()
+ {
+ var result = new Dictionary>();
+
+ foreach (var pair in _store)
+ {
+ result[pair.Key] = new Dictionary(pair.Value);
+ }
+
+ return Task.FromResult(result);
+ }
+
+ public Task RemoveAuthToken(string username)
+ {
+ _store.Remove(username);
+ return Task.CompletedTask;
+ }
+ }
+
+ private readonly InMemoryRedisService _redisService;
+
+ public RedisServiceTests()
+ {
+ _redisService = new InMemoryRedisService();
+ }
+
+ [Fact]
+ public async Task SaveAndGetAuthToken_ShouldStoreAndRetrieveTokens()
+ {
+ // Arrange
+ var username = "testuser";
+ var cookies = new Dictionary
+ {
+ { "auth", "test-auth-token" },
+ { "session", "test-session-token" }
+ };
+
+ // Act - Save token
+ await _redisService.SaveAuthToken(username, cookies);
+
+ // Act - Retrieve token
+ var retrievedCookies = await _redisService.GetAuthToken(username);
+
+ // Assert
+ retrievedCookies.Should().NotBeNull();
+ if (retrievedCookies != null)
+ {
+ retrievedCookies.Should().ContainKey("auth");
+ retrievedCookies.Should().ContainKey("session");
+ retrievedCookies["auth"].Should().Be("test-auth-token");
+ retrievedCookies["session"].Should().Be("test-session-token");
+ }
+ }
+
+ [Fact]
+ public async Task GetAuthToken_WhenTokenDoesNotExist_ShouldReturnNull()
+ {
+ // Arrange
+ var username = "nonexistentuser";
+
+ // Act
+ var result = await _redisService.GetAuthToken(username);
+
+ // Assert
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task GetAllAuthTokens_ShouldReturnAllStoredTokens()
+ {
+ // Arrange
+ var user1 = "testuser1";
+ var user2 = "testuser2";
+
+ // Clear any existing tokens
+ await _redisService.RemoveAuthToken(user1);
+ await _redisService.RemoveAuthToken(user2);
+
+ // Add test data
+ await _redisService.SaveAuthToken(user1, new Dictionary { { "auth", "token1" } });
+ await _redisService.SaveAuthToken(user2, new Dictionary { { "auth", "token2" } });
+
+ // Act
+ var allTokens = await _redisService.GetAllAuthTokens();
+
+ // Assert
+ allTokens.Should().ContainKey(user1);
+ allTokens.Should().ContainKey(user2);
+ allTokens[user1]["auth"].Should().Be("token1");
+ allTokens[user2]["auth"].Should().Be("token2");
+ }
+
+ [Fact]
+ public async Task RemoveAuthToken_ShouldDeleteToken()
+ {
+ // Arrange
+ var username = "testuser";
+ var cookies = new Dictionary
+ {
+ { "auth", "token-to-delete" }
+ };
+ await _redisService.SaveAuthToken(username, cookies);
+
+ // Verify token exists
+ var tokenBeforeDelete = await _redisService.GetAuthToken(username);
+ tokenBeforeDelete.Should().NotBeNull();
+
+ // Act
+ await _redisService.RemoveAuthToken(username);
+
+ // Assert
+ var tokenAfterDelete = await _redisService.GetAuthToken(username);
+ tokenAfterDelete.Should().BeNull();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/VRCAuthProxy.Tests.csproj b/Tests/VRCAuthProxy.Tests.csproj
index 75e082d..d7eae3e 100644
--- a/Tests/VRCAuthProxy.Tests.csproj
+++ b/Tests/VRCAuthProxy.Tests.csproj
@@ -9,9 +9,9 @@
-
-
-
+
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
@@ -22,7 +22,8 @@
-
+
+
diff --git a/VRCAuthProxy/HttpClientCookieContainer.cs b/VRCAuthProxy/HttpClientCookieContainer.cs
index 31203fa..e986781 100644
--- a/VRCAuthProxy/HttpClientCookieContainer.cs
+++ b/VRCAuthProxy/HttpClientCookieContainer.cs
@@ -2,7 +2,13 @@
namespace VRCAuthProxy;
-public class HttpClientCookieContainer(HttpClientHandler handler) : HttpClient(handler)
+public class HttpClientCookieContainer : HttpClient
{
- public CookieContainer CookieContainer => handler.CookieContainer;
+ public CookieContainer CookieContainer { get; }
+ public string Username { get; set; } = string.Empty;
+
+ public HttpClientCookieContainer(HttpClientHandler handler) : base(handler)
+ {
+ CookieContainer = handler.CookieContainer;
+ }
}
diff --git a/VRCAuthProxy/Program.cs b/VRCAuthProxy/Program.cs
index 0e5af6e..73dfb41 100644
--- a/VRCAuthProxy/Program.cs
+++ b/VRCAuthProxy/Program.cs
@@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using OtpNet;
using VRCAuthProxy;
+using VRCAuthProxy.Services;
using VRCAuthProxy.types;
using HttpMethod = System.Net.Http.HttpMethod;
using User = VRCAuthProxy.types.User;
@@ -18,102 +19,134 @@ using User = VRCAuthProxy.types.User;
string userAgent = "VRCAuthProxy V1.0.0 (https://github.com/PrideVRCommunity/VRCAuthProxy)";
var apiAccounts = new List();
-
-
+var redisConnection = Environment.GetEnvironmentVariable("REDIS_CONNECTION") ?? "localhost:6379";
+var redisService = new RedisService(redisConnection);
// Push the first account to the end of the list
-void RotateAccount()
+async Task RotateAccount()
{
var account = apiAccounts.First();
apiAccounts.Remove(account);
apiAccounts.Add(account);
+
+ // Store updated cookies in Redis
+ var cookies = account.CookieContainer.GetAllCookies().Cast()
+ .ToDictionary(c => c.Name, c => c.Value);
+ await redisService.SaveAuthToken(account.Username, cookies);
}
-
-
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseWebSockets();
-void LogInAllAccounts()
+async Task LogInAllAccounts()
{
- Config.Instance.Accounts.ForEach(async account =>
+ foreach (var account in Config.Instance.Accounts)
{
try
{
var cookieContainer = new CookieContainer();
+
+ // Try to restore cookies from Redis
+ var storedCookies = await redisService.GetAuthToken(account.username);
+ if (storedCookies != null)
+ {
+ foreach (var cookie in storedCookies)
+ {
+ cookieContainer.Add(new Uri("https://api.vrchat.cloud"), new Cookie(cookie.Key, cookie.Value));
+ }
+ }
+
var handler = new HttpClientHandler
{
CookieContainer = cookieContainer
};
var httpClient = new HttpClientCookieContainer(handler)
{
- BaseAddress = new Uri("https://api.vrchat.cloud/api/1")
-
+ BaseAddress = new Uri("https://api.vrchat.cloud/api/1"),
+ Username = account.username
};
httpClient.DefaultRequestHeaders.Add("User-Agent", userAgent);
Console.WriteLine($"Creating API for {account.username}");
- string encodedUsername = HttpUtility.UrlEncode(account.username);
- string encodedPassword = HttpUtility.UrlEncode(account.password);
-
- // Create Basic auth string
- string authString = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{encodedUsername}:{encodedPassword}"));
-
- // Add basic auth header
- var request = new HttpRequestMessage(HttpMethod.Get, "/api/1/auth/user");
- request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", authString);
- var authResp = await httpClient.SendAsync(request);
- if ((await authResp.Content.ReadAsStringAsync()).Contains("totp"))
+ // If we don't have stored cookies or they're invalid, perform login
+ var curUserResp = await httpClient.GetAsync("/api/1/auth/user");
+ if (!curUserResp.IsSuccessStatusCode)
{
- Console.WriteLine($"TOTP required for {account.username}");
- if (account.totpSecret == null)
+ Console.WriteLine($"Unable to use cached cookies for {account.username}. Getting new ones...");
+
+ string encodedUsername = HttpUtility.UrlEncode(account.username);
+ string encodedPassword = HttpUtility.UrlEncode(account.password);
+
+ // Create Basic auth string
+ string authString = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{encodedUsername}:{encodedPassword}"));
+
+ // Add basic auth header
+ var request = new HttpRequestMessage(HttpMethod.Get, "/api/1/auth/user");
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", authString);
+ var authResp = await httpClient.SendAsync(request);
+
+ if ((await authResp.Content.ReadAsStringAsync()).Contains("totp"))
{
- Console.WriteLine($"No TOTP secret found for {account.username}");
- return;
- }
-
- // totp constructor needs a byte array decoded from the base32 secret
- var totp = new Totp(Base32Encoding.ToBytes(account.totpSecret.Replace(" ", "")));
- var code = totp.ComputeTotp();
- if (code == null)
- {
- Console.WriteLine($"Failed to generate TOTP for {account.username}");
- return;
- }
-
- var verifyReq = new HttpRequestMessage(HttpMethod.Post, "/api/1/auth/twofactorauth/totp/verify");
- // set content type
- verifyReq.Content = new StringContent($"{{\"code\":\"{code}\"}}", Encoding.UTF8, "application/json");
- var verifyResp = await httpClient.SendAsync(verifyReq);
- var verifyRes = await verifyResp.Content.ReadFromJsonAsync();
-
- if (verifyRes.verified == false)
- {
- Console.WriteLine($"Failed to verify TOTP for {account.username}");
- return;
+ Console.WriteLine($"TOTP required for {account.username}");
+ if (account.totpSecret == null)
+ {
+ Console.WriteLine($"No TOTP secret found for {account.username}");
+ continue;
+ }
+
+ var totp = new Totp(Base32Encoding.ToBytes(account.totpSecret.Replace(" ", "")));
+ var code = totp.ComputeTotp();
+ if (code == null)
+ {
+ Console.WriteLine($"Failed to generate TOTP for {account.username}");
+ continue;
+ }
+
+ var verifyReq = new HttpRequestMessage(HttpMethod.Post, "/api/1/auth/twofactorauth/totp/verify");
+ verifyReq.Content = new StringContent($"{{\"code\":\"{code}\"}}", Encoding.UTF8, "application/json");
+ var verifyResp = await httpClient.SendAsync(verifyReq);
+ var verifyRes = await verifyResp.Content.ReadFromJsonAsync();
+
+ if (verifyRes?.verified != true)
+ {
+ Console.WriteLine($"Failed to verify TOTP for {account.username}");
+ continue;
+ }
}
+ curUserResp = await httpClient.GetAsync("/api/1/auth/user");
+ }
+ else
+ {
+ Console.WriteLine($"Using cached cookies for {account.username}");
}
- var curUserResp = await httpClient.GetAsync("/api/1/auth/user");
var curUser = await curUserResp.Content.ReadFromJsonAsync();
- Console.WriteLine($"Logged in as {curUser.displayName}");
+ Console.WriteLine($"Logged in as {curUser?.displayName}");
+
+ // Store cookies in Redis
+ var cookies = cookieContainer.GetAllCookies().Cast()
+ .ToDictionary(c => c.Name, c => c.Value);
+ await redisService.SaveAuthToken(account.username, cookies);
+
apiAccounts.Add(httpClient);
}
catch (HttpRequestException e)
{
Console.WriteLine($"Failed to create API for {account.username}: {e.Message}, {e.StatusCode}, {e}");
}
- });
+ }
}
-LogInAllAccounts();
+
+// Initialize services
+await LogInAllAccounts();
app.MapGet("/", () => $"Logged in with {apiAccounts.Count} accounts");
-app.MapGet("/rotate", () =>
+app.MapGet("/rotate", async () =>
{
- RotateAccount();
+ await RotateAccount();
return "Rotated account";
});
@@ -243,12 +276,12 @@ async Task DoRequest(HttpContext context, bool retriedAlready = false, bool reAu
return;
}
// re-login all accounts and try again
- LogInAllAccounts();
+ await LogInAllAccounts();
await DoRequest(context, true, true);
return;
}
Console.Error.WriteLine($"Retrying request due to {response.StatusCode}");
- RotateAccount();
+ await RotateAccount();
await DoRequest(context, true);
return;
}
@@ -287,6 +320,4 @@ app.Use(async (context, next) =>
}
});
-
-
app.Run();
\ No newline at end of file
diff --git a/VRCAuthProxy/Services/RedisService.cs b/VRCAuthProxy/Services/RedisService.cs
new file mode 100644
index 0000000..347bcf8
--- /dev/null
+++ b/VRCAuthProxy/Services/RedisService.cs
@@ -0,0 +1,59 @@
+using System.Text.Json;
+using StackExchange.Redis;
+
+namespace VRCAuthProxy.Services;
+
+public class RedisService
+{
+ private readonly IConnectionMultiplexer _redis;
+ private readonly IDatabase _db;
+ private const string AUTH_TOKEN_KEY = "vrcauthproxy:tokens";
+
+ public RedisService(string connectionString)
+ {
+ _redis = ConnectionMultiplexer.Connect(connectionString);
+ _db = _redis.GetDatabase();
+ }
+
+ // Constructor for testing with mocked dependencies
+ internal RedisService(IConnectionMultiplexer redis, IDatabase database)
+ {
+ _redis = redis;
+ _db = database;
+ }
+
+ public virtual async Task SaveAuthToken(string username, Dictionary cookies)
+ {
+ var serializedCookies = JsonSerializer.Serialize(cookies);
+ await _db.HashSetAsync(AUTH_TOKEN_KEY, username, serializedCookies);
+ }
+
+ public virtual async Task?> GetAuthToken(string username)
+ {
+ var value = await _db.HashGetAsync(AUTH_TOKEN_KEY, username);
+ if (!value.HasValue) return null;
+ return JsonSerializer.Deserialize>(value!);
+ }
+
+ public virtual async Task>> GetAllAuthTokens()
+ {
+ var entries = await _db.HashGetAllAsync(AUTH_TOKEN_KEY);
+ var result = new Dictionary>();
+
+ foreach (var entry in entries)
+ {
+ var cookies = JsonSerializer.Deserialize>(entry.Value!);
+ if (cookies != null)
+ {
+ result[entry.Name!] = cookies;
+ }
+ }
+
+ return result;
+ }
+
+ public virtual async Task RemoveAuthToken(string username)
+ {
+ await _db.HashDeleteAsync(AUTH_TOKEN_KEY, username);
+ }
+}
\ No newline at end of file
diff --git a/VRCAuthProxy/VRCAuthProxy.csproj b/VRCAuthProxy/VRCAuthProxy.csproj
index 31e4311..9d8de9e 100644
--- a/VRCAuthProxy/VRCAuthProxy.csproj
+++ b/VRCAuthProxy/VRCAuthProxy.csproj
@@ -16,7 +16,9 @@
-
+
+
+
diff --git a/VRCAuthProxy/types/API.cs b/VRCAuthProxy/types/API.cs
index 2c683f6..ad80ede 100644
--- a/VRCAuthProxy/types/API.cs
+++ b/VRCAuthProxy/types/API.cs
@@ -1,11 +1,13 @@
namespace VRCAuthProxy.types;
-public struct TotpVerifyResponse
+public class TotpVerifyResponse
{
public bool verified { get; set; }
}
-public struct User
+public class User
{
- public string displayName { get; set; }
+ public string? displayName { get; set; }
+ public bool? requiresTwoFactorAuth { get; set; }
+ public bool? totp { get; set; }
}
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..47b647e
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,32 @@
+version: '3.8'
+
+services:
+ redis:
+ image: redis:7-alpine
+ command: redis-server --save 60 1 --loglevel warning
+ volumes:
+ - redis_data:/data
+ ports:
+ - "6379:6379"
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 3
+
+ vrcauthproxy:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ ports:
+ - "8080:80"
+ environment:
+ - REDIS_CONNECTION=redis:6379
+ - ASPNETCORE_ENVIRONMENT=Production
+ depends_on:
+ redis:
+ condition: service_healthy
+
+volumes:
+ redis_data:
+ driver: local
\ No newline at end of file