Compare commits

...
Sign in to create a new pull request.

26 commits

Author SHA1 Message Date
86948c6f07 Update .forgejo/workflows/build-latest.yml
Some checks failed
Build Docker Image / docker (push) Failing after 27s
2025-05-22 14:56:26 -04:00
87b18c5008 Update .forgejo/workflows/build-latest.yml
Some checks failed
Build Docker Image / docker (push) Failing after 3m33s
2025-05-22 14:39:23 -04:00
765730993f hate 2025-05-22 14:23:54 -04:00
9b043f8720 set an endpoint? 2025-05-22 14:21:58 -04:00
5b037a589d i fucked up the workflow file 2025-05-22 14:20:35 -04:00
a9c3d82108 maybe skip docker cli install, docker in docker should work with the actions 2025-05-22 14:19:45 -04:00
460e4344a2 fix containerd install 2025-05-22 14:18:01 -04:00
581ffff661 run docker as a service 2025-05-22 14:16:16 -04:00
8fc00ab762 Merge branch 'develop' of git.gay:pupsie/vrcauthproxy into develop 2025-05-22 14:12:24 -04:00
ce4679b9f8 move around c/i and add publish script 2025-05-22 14:11:52 -04:00
MiscFrizzy
6da03c2920 refactor: setup a docs directory for different readme's 2025-05-22 14:06:01 -04:00
MiscFrizzy
032a0f17f6 Updates LICENSE for active mainter org 2025-05-22 14:04:07 -04:00
dotfrizzy
fadc13b6e6
Merge pull request 'feat/redis-session-store' (#2) from feat/redis-session-store into develop
Reviewed-on: https://git.gay/Pupsie/VRCAuthProxy/pulls/2
2025-05-20 22:12:00 +02:00
Cyan Kneelawk
312e94aaf3
Log whether cookies could be reused 2025-04-28 13:35:26 -07:00
Cyan Kneelawk
972493ace8
Update docker dotnet to 9.0 to match csproj 2025-04-25 16:48:53 -07:00
MiscFrizzy
cb8718c9b8 feat(build): builds develop branch to latest docker tag in workflow 2025-04-07 16:48:17 -04:00
MiscFrizzy
4936481ffc ### Commit Message Summary
**Enhancements to Redis Integration and Testing**

- **RedisService.cs**:
  - Implemented a Redis service for storing and retrieving authentication tokens.
  - Added methods for saving, retrieving, and removing tokens using Redis.
  - Introduced a constructor for dependency injection to facilitate testing.

- **RedisFixture.cs**:
  - Created a test fixture to ensure a Redis instance is available for integration tests.
  - Implemented a check to verify if Redis is running before executing tests.

- **ProgramRedisIntegrationTests.cs**:
  - Added integration tests to validate the login process and token storage in Redis.
  - Implemented tests to check the reuse of stored tokens and the correct handling of authentication.

- **RedisIntegrationTests.cs**:
  - Developed integration tests for saving, retrieving, and deleting authentication tokens in Redis.
  - Ensured that all tokens are correctly stored and can be retrieved as expected.

- **RedisServiceTests.cs**:
  - Created unit tests using an in-memory implementation of the Redis service for isolated testing.
  - Validated the functionality of saving, retrieving, and removing tokens without a real Redis connection.

### Notes
- All tests are designed to ensure the reliability of the Redis integration and the overall functionality of the VRCAuthProxy service.
- Integration tests are marked to skip execution unless a Redis instance is available.
2025-04-07 08:04:52 -04:00
MiscFrizzy
30d631d246 feat: An Initias Redis Store Implementation
- **docker-compose.yml**
  - Added Redis service configuration to support token storage.
  - Set up health checks and volume for Redis persistence.
  - Configured VRCAuthProxy service to depend on Redis.

- **HttpClientCookieContainer.cs**
  - Added `Username` property to support user-specific token management.

- **Program.cs**
  - Integrated Redis for storing and retrieving authentication tokens.
  - Updated login and token rotation logic to utilize Redis.
  - Improved async/await usage for better reliability.

- **VRCAuthProxy.csproj**
  - Added `StackExchange.Redis` package for Redis connectivity.
  - Corrected `Otp.NET` package reference.

- **API.cs**
  - Updated `TotpVerifyResponse` and `User` classes to be nullable-aware.

- **RedisService.cs**
  - Implemented Redis service for managing authentication tokens.
  - Added methods for saving, retrieving, and deleting tokens.
2025-04-07 07:47:27 -04:00
MiscFrizzy
eb4349031b ### Commit Summary
- **TestSetup.cs**
  - Updated `CreateTestConfig` method to initialize `Config` with required properties using object initializer syntax.

- **ProxyIntegrationTests.cs**
  - Added null checks for `mockServer.Urls` before accessing it to prevent potential null reference exceptions.
  - Improved error handling for mock server URL access.

- **VRChatAuthenticationTests.cs**
  - Added null checks for `mockServer.Urls` before accessing it to prevent potential null reference exceptions.
  - Enhanced the mock server setup to include null checks for request body content.

- **Config.cs**
  - Added the `required` modifier to non-nullable properties in `ConfigAccount` and `iConfig` classes.
  - Updated the `Load` method to initialize the `Config` instance with required properties using object initializer syntax.

- **Program.cs**
  - Added a null check for `result.CloseStatus` in WebSocket handling to prevent potential null reference exceptions.
2025-04-07 07:30:34 -04:00
MiscFrizzy
861bedcf43 feat(ci): Add support for version tagging in Docker build workflow
- Updated the build workflow to trigger on version tags (e.g., v1.0.0) pushed to the main branch.
- Configured the Docker image to be tagged with the version and pushed to the GitHub Container Registry.
- Ensured that the workflow continues to build on pushes to the develop branch and supports manual triggering.
2025-04-07 07:06:15 -04:00
MiscFrizzy
6ea5de613c
Update README.md
grrr this one badge
2025-04-07 06:57:17 -04:00
MiscFrizzy
fb46a1185e
Update README.md
formating syntax
2025-04-07 06:49:05 -04:00
MiscFrizzy
a5eb2cfe6b Fix forgot badge deets 2025-04-07 06:47:16 -04:00
MiscFrizzy
8fdc1523ab Updates dependencies badge 2025-04-07 06:44:48 -04:00
MiscFrizzy
24d0601168 feat(docs): Add badges for version, dependencies, and license to README.md
- Added a version badge to indicate the current version of the project.
- Included a dependencies badge to show the status of project dependencies.
- Added a license badge to clarify the licensing of the project.

chore(ci): Update test workflow to improve coverage reporting

- Updated the test.yml workflow to include coverage reporting using Codecov.
- Ensured that the workflow runs on pushes and pull requests to main and develop branches.
2025-04-07 06:38:22 -04:00
MiscFrizzy
319f1071bf feat(ci): Add GitHub Actions workflows for test automation and status badges
Add comprehensive test automation setup with GitHub Actions:
- Create test.yml for running tests on main/develop branches
- Add pr-test.yml for PR validation with test results comments
- Add update-badges.yml for dynamic test status badge updates
- Configure code coverage reporting with Codecov integration

Documentation:
- Add BADGE_SETUP.md with instructions for configuring test status badges
- Add WORKFLOWS_GUIDE.md explaining CI/CD workflow setup
- Update README.md with build and test status badges

Test Framework:
- Configure test project to use .NET 9.0
- Set up test coverage reporting with coverlet
- Add integration tests with WireMock for API mocking
- Add unit tests for configuration and HTTP client components
- Document testing strategy in TestingStrategy.md

Build:
- Add Dockerfile.test for containerized testing
- Update .gitignore for test artifacts
- Configure test dependencies in VRCAuthProxy.Tests.csproj

This change enables automated testing on PRs and branches, with visual status indicators and detailed test results in PR comments.
2025-04-07 06:30:31 -04:00
30 changed files with 1627 additions and 115 deletions

View file

@ -0,0 +1,38 @@
name: Build Docker Image
on:
push:
branches:
- develop
workflow_dispatch:
jobs:
docker:
runs-on: docker
env:
DOCKER_HOST: tcp://docker-in-docker:2375
steps:
- uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
env:
DOCKER_HOST: tcp://docker-in-docker:2375
- name: Login to Docker Hub
uses: docker/login-action@v2
env:
DOCKER_HOST: tcp://docker-in-docker:2375
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
env:
DOCKER_HOST: tcp://docker-in-docker:2375
with:
push: true
file: ./Dockerfile
platforms: linux/amd64
tags: pupsiehub/vrcauthproxy:latest
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -0,0 +1,37 @@
name: Build Docker Image
on:
push:
tags:
- '*'
workflow_dispatch:
jobs:
docker:
runs-on: docker
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: pupsiehub/vrcauthproxy
tags: |
type=ref,event=tag
- name: Build and push
uses: docker/build-push-action@v5
with:
push: true
file: ./Dockerfile
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -0,0 +1,35 @@
name: Run Tests
on:
# push:
# branches: [ main, develop ]
# pull_request:
# branches: [ main, develop ]
workflow_dispatch:
jobs:
test:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Test with coverage
run: dotnet test --no-build --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=lcov /p:CoverletOutput=./lcov.info
# - name: Upload coverage to Codecov
# uses: codecov/codecov-action@v3
# with:
# file: ./lcov.info
# fail_ci_if_error: false

View file

@ -1,31 +0,0 @@
name: Build Docker Image
on:
push:
branches:
- main
workflow_dispatch:
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
push: true
file: ./Dockerfile
platforms: linux/amd64
tags: ghcr.io/pridevrinc/vrcauthproxy:latest
cache-from: type=gha
cache-to: type=gha,mode=max

2
.gitignore vendored
View file

@ -43,3 +43,5 @@ msbuild.err
msbuild.wrn
VRCAuthProxy/appsettings.json
authproxy.json
creds.env

View file

@ -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

15
Dockerfile.test Normal file
View file

@ -0,0 +1,15 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /app
# Copy csproj and restore dependencies
COPY *.sln ./
COPY VRCAuthProxy/*.csproj ./VRCAuthProxy/
COPY Tests/*.csproj ./Tests/
RUN dotnet restore
# Copy the remaining files
COPY . ./
# Run tests
ENTRYPOINT ["dotnet", "test", "--logger:console"]

View file

@ -83,4 +83,4 @@ You may add additional accurate notices of copyright ownership.
# Addendum to MPL 2.0 License Aggrement
**1.** Follow VRChat [Terms of Service](https://hello.vrchat.com/legal) and [Creator Guidelines](https://hello.vrchat.com/creator-guidelines#:~:text=or%20suggestive%20content.-,API%20Usage%20/%20Bots) when using this software with the VRChat API.
**2.** You are resonsible for any use of this software that results in VRChat Trust & Safety actioning you. You release PrideVR, INC, and any of their volunteers, of any and all liability.
**2.** You are resonsible for any use of this software that results in VRChat Trust & Safety actioning you. You release Pupsie Co., and any of their staff, of any and all liability.

View file

@ -1,6 +1,12 @@
# VRCAuthProxy
#### A VRChat API Authorization Proxy Service
[![Build](https://github.com/PrideVRInc/VRCAuthProxy/actions/workflows/build.yml/badge.svg)](https://github.com/PrideVRInc/VRCAuthProxy/actions/workflows/build.yml)
[![Tests](https://github.com/PrideVRInc/VRCAuthProxy/actions/workflows/test.yml/badge.svg)](https://github.com/PrideVRInc/VRCAuthProxy/actions/workflows/test.yml)
[![Version](https://img.shields.io/badge/version-1.0.0-blue.svg)](https://github.com/PrideVRInc/VRCAuthProxy/releases)
![Libraries.io dependency status for GitHub repo](https://img.shields.io/librariesio/github/PrideVRINC/VRCAuthProxy)
[![License](https://img.shields.io/badge/license-MPL--2.0-brightgreen.svg)](https://opensource.org/licenses/MPL-2.0)
This authorization proxy service is for consuming the VRChat API in a multi-application / microservice architecture. Configure the proxy with the credentials for accounts you use to make API calls and direct your API clients to the proxy service instead of the VRChat API. The proxy server will handle the authorization call flow and caching of the authorization tokens for subsequent authorized calls.
## Build Steps
@ -49,6 +55,72 @@ services:
timeout: 10s
```
## Testing
This section provides instructions on how to run the unit and integration tests for the VRCAuthProxy project.
### Running Tests Locally
To run the tests locally, ensure you have the .NET SDK installed. You can execute the tests using the following command:
```bash
dotnet test
```
For code coverage reports, you can use:
```bash
dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=lcov /p:CoverletOutput=./lcov.info
```
### Running Tests in a Docker Container
To run the tests in a Docker container, follow these steps:
1. **Build the Docker Image**: Ensure you have the Dockerfile set up correctly in your project root. Build the Docker image using the following command:
```bash
docker build -t vrcauthproxy-test -f Dockerfile.test .
```
2. **Run the Tests**: Execute the tests inside the Docker container with the following command:
```bash
docker run --rm vrcauthproxy-test dotnet test
```
This command will run the tests in the container and output the results to your terminal.
### Creating a Dockerfile.test
You can create a dedicated Dockerfile for testing:
```dockerfile
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /app
# Copy csproj and restore dependencies
COPY *.sln ./
COPY VRCAuthProxy/*.csproj ./VRCAuthProxy/
COPY Tests/*.csproj ./Tests/
RUN dotnet restore
# Copy the remaining files
COPY . ./
# Run tests
ENTRYPOINT ["dotnet", "test", "--logger:console"]
```
### Continuous Integration
The test suite is designed to be run in CI/CD pipelines. Include the following in your GitHub workflow:
```yaml
- name: Test
run: dotnet test --no-restore --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=lcov /p:CoverletOutput=./lcov.info
```
## LICENSE
MPL-2.0 with Addendum
@ -64,6 +136,4 @@ MPL-2.0 with Addendum
Contributors list made with [contrib.rocks](https://contrib.rocks).
© 2025 [PrideVR, INC](https://pridevr.org)
A VR Pride Organization
© 2025 [Pupsie, CO](https://pupsie.co)

View file

@ -0,0 +1,78 @@
using System.Diagnostics;
using Xunit;
namespace VRCAuthProxy.Tests.Helpers
{
/// <summary>
/// Collection definition for Redis tests
/// </summary>
[CollectionDefinition("Redis")]
public class RedisCollection : ICollectionFixture<RedisFixture>
{
// 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.
}
/// <summary>
/// Redis test fixture that ensures a Redis instance is available for tests
/// </summary>
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.");
}
}
/// <summary>
/// Check if Redis is available on localhost:6379
/// </summary>
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);
}
}
}

View file

@ -0,0 +1,45 @@
using System.Net;
using WireMock.Server;
namespace VRCAuthProxy.Tests.Helpers
{
public class TestSetup : IDisposable
{
public WireMockServer MockVRChatApi { get; private set; }
public TestSetup()
{
// Start WireMock server to mock VRChat API
MockVRChatApi = WireMockServer.Start();
}
public void Dispose()
{
MockVRChatApi?.Dispose();
}
/// <summary>
/// Creates a test configuration with mock accounts
/// </summary>
public static Config CreateTestConfig()
{
return new Config
{
Accounts = new List<ConfigAccount>
{
new ConfigAccount
{
username = "testuser1",
password = "testpassword1",
totpSecret = "TESTSECRET1"
},
new ConfigAccount
{
username = "testuser2",
password = "testpassword2"
}
}
};
}
}
}

View file

@ -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<TestSetup>
{
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<Cookie>()
.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<string, string>
{
{ "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);
}
}
}

View file

@ -0,0 +1,129 @@
using System.Net;
using System.Net.Http.Json;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using VRCAuthProxy.Tests.Helpers;
using VRCAuthProxy.types;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using Xunit;
namespace VRCAuthProxy.Tests.Integration
{
public class ProxyIntegrationTests : IClassFixture<TestSetup>
{
private readonly TestSetup _testSetup;
public ProxyIntegrationTests(TestSetup testSetup)
{
_testSetup = testSetup;
}
[Fact(Skip = "Requires running application - for manual testing only")]
public void ProxyAPI_ShouldForwardRequests_ToVRChatAPI()
{
// This test requires a running application instance
// Skipping for automated test runs
}
[Fact]
public async Task API_ReturnsMockDataForAuthorizedUser()
{
// Arrange
var mockServer = _testSetup.MockVRChatApi;
// Mock a successful authentication
mockServer.Given(Request.Create()
.WithPath("/api/1/auth/user")
.UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
.WithBodyAsJson(new User { displayName = "TestUser" }));
// Mock an API endpoint that will be called with auth token
mockServer.Given(Request.Create()
.WithPath("/api/1/users")
.UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
.WithBody(@"[{""id"":""usr_test1"",""displayName"":""TestUser1""},{""id"":""usr_test2"",""displayName"":""TestUser2""}]"));
// Create a client with the test setup
var handler = new HttpClientHandler { UseCookies = true };
var mockServerUrls = mockServer.Urls;
if (mockServerUrls == null || !mockServerUrls.Any())
{
throw new InvalidOperationException("Mock server URLs not available");
}
var baseUri = mockServerUrls.First();
// Call the users endpoint directly
var httpClient = new HttpClient(handler);
var response = await httpClient.GetAsync($"{baseUri}/api/1/users");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var responseContent = await response.Content.ReadAsStringAsync();
responseContent.Should().Contain("TestUser1");
responseContent.Should().Contain("TestUser2");
}
[Fact]
public async Task API_CanHandleAuthTokenBasedRequests()
{
// Arrange
var mockServer = _testSetup.MockVRChatApi;
// Mock authentication responses
mockServer.Given(Request.Create()
.WithPath("/api/1/auth/user")
.UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
.WithBodyAsJson(new User { displayName = "Account1" }));
// Mock the worlds endpoint
mockServer.Given(Request.Create()
.WithPath("/api/1/worlds")
.UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
.WithBody(@"[{""id"":""wrld_test1"",""name"":""TestWorld1""},{""id"":""wrld_test2"",""name"":""TestWorld2""}]"));
// Create a client with the test setup
var handler = new HttpClientHandler { UseCookies = true };
var mockServerUrls = mockServer.Urls;
if (mockServerUrls == null || !mockServerUrls.Any())
{
throw new InvalidOperationException("Mock server URLs not available");
}
var baseUri = mockServerUrls.First();
// First authenticate
var httpClient = new HttpClient(handler);
await httpClient.GetAsync($"{baseUri}/api/1/auth/user");
// Then call the worlds endpoint
var response = await httpClient.GetAsync($"{baseUri}/api/1/worlds");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var responseContent = await response.Content.ReadAsStringAsync();
responseContent.Should().Contain("TestWorld1");
responseContent.Should().Contain("TestWorld2");
}
[Fact(Skip = "Requires running application - for manual testing only")]
public void WebSocketProxy_ShouldPassThrough_WebSocketMessages()
{
// This test requires a running application instance
// Skipping for automated test runs
}
}
}

View file

@ -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<string, string>
{
{ "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<string, string>
{
{ "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<string, string> { { "auth", "token1" } });
await _redisService.SaveAuthToken(user2, new Dictionary<string, string> { { "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);
}
}
}

View file

@ -0,0 +1,146 @@
using System.Net;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using VRCAuthProxy.Tests.Helpers;
using VRCAuthProxy.types;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using Xunit;
namespace VRCAuthProxy.Tests.Integration
{
public class VRChatAuthenticationTests : IClassFixture<TestSetup>
{
private readonly TestSetup _testSetup;
public VRChatAuthenticationTests(TestSetup testSetup)
{
_testSetup = testSetup;
}
[Fact]
public async Task Authentication_WithValidCredentials_ShouldReturnUserInfo()
{
// Arrange
var mockServer = _testSetup.MockVRChatApi;
// Mock the authentication endpoint
mockServer.Given(Request.Create()
.WithPath("/api/1/auth/user")
.UsingGet()
.WithHeader("Authorization", "*")) // Accept any authorization header
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
.WithHeader("Set-Cookie", "auth=test-auth-token; path=/; secure; httponly")
.WithBodyAsJson(new User { displayName = "TestUser" }));
// Create a client with the test setup
var handler = new HttpClientHandler { UseCookies = true };
var mockServerUrls = mockServer.Urls;
if (mockServerUrls == null || !mockServerUrls.Any())
{
throw new InvalidOperationException("Mock server URLs not available");
}
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri(mockServerUrls.First())
};
// Act
string username = "testuser";
string password = "testpassword";
string authString = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
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);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
// Verify the response can be parsed
var responseContent = await response.Content.ReadAsStringAsync();
var user = JsonSerializer.Deserialize<User>(responseContent);
user.Should().NotBeNull();
user!.displayName.Should().Be("TestUser");
}
[Fact]
public async Task Authentication_WithTOTP_ShouldVerifyAndProvideUserInfo()
{
// Arrange
var mockServer = _testSetup.MockVRChatApi;
// Mock API behavior when TOTP is required
mockServer.Given(Request.Create()
.WithPath("/api/1/auth/user")
.UsingGet()
.WithHeader("Authorization", "*"))
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
.WithBody(@"{""requiresTwoFactorAuth"":true,""totp"":true}"));
// Mock TOTP verification endpoint
mockServer.Given(Request.Create()
.WithPath("/api/1/auth/twofactorauth/totp/verify")
.UsingPost()
.WithBody(x => x != null && x.Contains("code")))
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
.WithHeader("Set-Cookie", "auth=totp-verified-token; path=/; secure; httponly")
.WithBodyAsJson(new TotpVerifyResponse { verified = true }));
// Mock user info after successful TOTP verification
mockServer.Given(Request.Create()
.WithPath("/api/1/auth/user")
.UsingGet()
.WithCookie("auth", "totp-verified-token"))
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
.WithBodyAsJson(new User { displayName = "TOTPVerifiedUser" }));
// Create a client with the test setup
var handler = new HttpClientHandler { UseCookies = true };
var mockServerUrls = mockServer.Urls;
if (mockServerUrls == null || !mockServerUrls.Any())
{
throw new InvalidOperationException("Mock server URLs not available");
}
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri(mockServerUrls.First())
};
// Act - First authenticate which will indicate TOTP is required
string username = "totpuser";
string password = "totppassword";
string authString = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
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);
// Assert initial response
response.StatusCode.Should().Be(HttpStatusCode.OK);
var initialContent = await response.Content.ReadAsStringAsync();
initialContent.Should().Contain("requiresTwoFactorAuth");
// Now verify with TOTP
var verifyReq = new HttpRequestMessage(HttpMethod.Post, "/api/1/auth/twofactorauth/totp/verify");
verifyReq.Content = new StringContent(@"{""code"":""123456""}", Encoding.UTF8, "application/json");
var verifyResp = await httpClient.SendAsync(verifyReq);
// Assert TOTP verification
verifyResp.StatusCode.Should().Be(HttpStatusCode.OK);
var verifyContent = await verifyResp.Content.ReadAsStringAsync();
var verifyResult = JsonSerializer.Deserialize<TotpVerifyResponse>(verifyContent);
verifyResult.Should().NotBeNull();
verifyResult!.verified.Should().BeTrue();
}
}
}

95
Tests/TestingStrategy.md Normal file
View file

@ -0,0 +1,95 @@
# VRCAuthProxy Testing Strategy
This document outlines the comprehensive testing strategy for VRCAuthProxy, a proxy service for interacting with the VRChat API.
## Overview
The testing strategy for VRCAuthProxy incorporates both unit tests and integration tests to ensure the reliability and robustness of the proxy service. The selected testing frameworks were chosen based on their active development status, community support, and compatibility with .NET 8.0.
## Test Project Structure
The test project is organized into several directories:
```
Tests/
├── Unit/ # Unit tests
│ ├── ConfigTests.cs
│ └── HttpClientCookieContainerTests.cs
├── Integration/ # Integration tests
│ ├── VRChatAuthenticationTests.cs
│ └── ProxyIntegrationTests.cs
├── Helpers/ # Test helpers and fixtures
│ └── TestSetup.cs
└── VRCAuthProxy.Tests.csproj
```
## Selected Testing Frameworks
The following frameworks were selected for their recent updates, robust feature sets, and compatibility with the VRCAuthProxy codebase:
1. **xUnit (v2.6.6)** - Modern test framework for .NET with excellent parallel test execution.
2. **Moq (v4.20.70)** - Popular mocking framework for isolating components in unit tests.
3. **FluentAssertions (v6.12.0)** - More readable assertions with excellent error messages.
4. **WireMock.NET (v1.5.44)** - HTTP mocking library for simulating the VRChat API.
5. **Microsoft.AspNetCore.Mvc.Testing (v8.0.3)** - Framework for testing ASP.NET Core applications without mocking the HTTP pipeline.
6. **coverlet.collector (v6.0.0)** - Code coverage tool for measuring test coverage.
## Testing Approach
### Unit Tests
Unit tests focus on testing individual components in isolation:
- Configuration loading and saving
- Cookie container handling
- Authentication flows
### Integration Tests
Integration tests verify the interaction between components:
- End-to-end proxy request handling
- Authentication with the VRChat API
- Rate limiting and account rotation
- WebSocket proxying
### Mocking Strategy
- The VRChat API is mocked using WireMock.NET to simulate various responses:
- Authentication success/failure
- TOTP verification
- Rate limiting
- Successful API responses
## Running Tests
Execute the tests using the following command:
```bash
dotnet test
```
For code coverage reports:
```bash
dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=lcov /p:CoverletOutput=./lcov.info
```
## Continuous Integration
The test suite is designed to be run in CI/CD pipelines. Include the following in your GitHub workflow:
```yaml
- name: Test
run: dotnet test --no-restore --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=lcov /p:CoverletOutput=./lcov.info
```
## VRChat API Integration
The tests are designed to work with the VRChat API by:
1. Mocking the API responses to simulate real-world scenarios
2. Testing authentication flows including two-factor authentication
3. Verifying that the proxy correctly handles rate limiting
4. Ensuring proper WebSocket proxying
## Areas for Future Improvement
1. **Load testing** - Add tests to verify performance under high load
2. **Security testing** - Add tests to verify that authentication tokens are properly handled
3. **Failure recovery testing** - Enhance tests for VRChat API outages
4. **Cross-platform testing** - Verify functionality across different operating systems

125
Tests/Unit/ConfigTests.cs Normal file
View file

@ -0,0 +1,125 @@
using System.Text.Json;
using FluentAssertions;
using Xunit;
namespace VRCAuthProxy.Tests.Unit
{
public class ConfigTests
{
[Fact]
public void Config_LoadsAccountsFromJsonFile()
{
// Arrange
var tempFile = Path.GetTempFileName();
var configData = new
{
accounts = new object[]
{
new { username = "user1", password = "pass1", totpSecret = "secret1" },
new { username = "user2", password = "pass2" }
}
};
File.WriteAllText(tempFile, JsonSerializer.Serialize(configData));
try
{
// Override the default config path for testing
string originalConfigPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
string backupPath = originalConfigPath + ".bak";
if (File.Exists(originalConfigPath))
{
File.Move(originalConfigPath, backupPath, true);
}
File.Copy(tempFile, originalConfigPath, true);
// Act
var config = Config.Load();
// Assert
config.Should().NotBeNull();
config.Accounts.Should().HaveCount(2);
config.Accounts[0].username.Should().Be("user1");
config.Accounts[0].password.Should().Be("pass1");
config.Accounts[0].totpSecret.Should().Be("secret1");
config.Accounts[1].username.Should().Be("user2");
config.Accounts[1].password.Should().Be("pass2");
config.Accounts[1].totpSecret.Should().BeNull();
// Restore original config
if (File.Exists(backupPath))
{
File.Move(backupPath, originalConfigPath, true);
}
}
finally
{
// Clean up the temp file
if (File.Exists(tempFile))
{
File.Delete(tempFile);
}
}
}
[Fact]
public void Config_Save_WritesConfigToFile()
{
// Arrange
var tempFile = Path.GetTempFileName();
var originalConfigPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
var backupPath = originalConfigPath + ".bak";
if (File.Exists(originalConfigPath))
{
File.Move(originalConfigPath, backupPath, true);
}
File.Copy(tempFile, originalConfigPath, true);
try
{
var config = new Config
{
Accounts = new List<ConfigAccount>
{
new ConfigAccount { username = "test1", password = "pass1", totpSecret = "secret1" }
}
};
// Act
config.Save();
// Assert
File.Exists(originalConfigPath).Should().BeTrue();
var loadedConfig = JsonSerializer.Deserialize<iConfig>(File.ReadAllText(originalConfigPath));
if (loadedConfig != null)
{
loadedConfig.accounts.Should().HaveCount(1);
loadedConfig.accounts[0].username.Should().Be("test1");
loadedConfig.accounts[0].password.Should().Be("pass1");
loadedConfig.accounts[0].totpSecret.Should().Be("secret1");
}
// Restore original config
if (File.Exists(backupPath))
{
File.Move(backupPath, originalConfigPath, true);
}
}
finally
{
// Clean up the temp file
if (File.Exists(tempFile))
{
File.Delete(tempFile);
}
}
}
}
}

View file

@ -0,0 +1,41 @@
using System.Net;
using FluentAssertions;
using Xunit;
namespace VRCAuthProxy.Tests.Unit
{
public class HttpClientCookieContainerTests
{
[Fact]
public void HttpClientCookieContainer_ExposesCookieContainer()
{
// Arrange
var cookieContainer = new CookieContainer();
var handler = new HttpClientHandler { CookieContainer = cookieContainer };
// Act
var client = new HttpClientCookieContainer(handler);
// Assert
client.CookieContainer.Should().BeSameAs(cookieContainer);
}
[Fact]
public void CookieContainer_CanStoreAndRetrieveCookies()
{
// Arrange
var cookieContainer = new CookieContainer();
var uri = new Uri("https://test.com");
// Act
cookieContainer.Add(uri, new Cookie("auth", "test-auth-token"));
// Assert
var cookies = cookieContainer.GetCookies(uri);
cookies.Count.Should().Be(1);
var authCookie = cookies["auth"];
authCookie.Should().NotBeNull();
authCookie!.Value.Should().Be("test-auth-token");
}
}
}

View file

@ -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<string, Dictionary<string, string>> _store = new();
public Task SaveAuthToken(string username, Dictionary<string, string> cookies)
{
_store[username] = new Dictionary<string, string>(cookies);
return Task.CompletedTask;
}
public Task<Dictionary<string, string>?> GetAuthToken(string username)
{
if (_store.TryGetValue(username, out var cookies))
return Task.FromResult<Dictionary<string, string>?>(new Dictionary<string, string>(cookies));
return Task.FromResult<Dictionary<string, string>?>(null);
}
public Task<Dictionary<string, Dictionary<string, string>>> GetAllAuthTokens()
{
var result = new Dictionary<string, Dictionary<string, string>>();
foreach (var pair in _store)
{
result[pair.Key] = new Dictionary<string, string>(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<string, string>
{
{ "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<string, string> { { "auth", "token1" } });
await _redisService.SaveAuthToken(user2, new Dictionary<string, string> { { "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<string, string>
{
{ "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();
}
}
}

View file

@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.6.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
<PackageReference Include="WireMock.Net" Version="1.5.40" />
<PackageReference Include="StackExchange.Redis" Version="2.7.17" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\VRCAuthProxy\VRCAuthProxy.csproj" />
</ItemGroup>
</Project>

View file

@ -1,6 +1,10 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRCAuthProxy", "VRCAuthProxy\VRCAuthProxy.csproj", "{BB549439-B34F-4BCA-B8FC-173114631646}"
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.002.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VRCAuthProxy", "VRCAuthProxy\VRCAuthProxy.csproj", "{AE86A3C0-62A5-45DE-AFDD-7DBE3D51B7C1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRCAuthProxy.Tests", "Tests\VRCAuthProxy.Tests.csproj", "{6A48DF7F-9BC2-446A-B181-F67F3B0E45FB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -8,9 +12,19 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{BB549439-B34F-4BCA-B8FC-173114631646}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BB549439-B34F-4BCA-B8FC-173114631646}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BB549439-B34F-4BCA-B8FC-173114631646}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BB549439-B34F-4BCA-B8FC-173114631646}.Release|Any CPU.Build.0 = Release|Any CPU
{AE86A3C0-62A5-45DE-AFDD-7DBE3D51B7C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AE86A3C0-62A5-45DE-AFDD-7DBE3D51B7C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AE86A3C0-62A5-45DE-AFDD-7DBE3D51B7C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AE86A3C0-62A5-45DE-AFDD-7DBE3D51B7C1}.Release|Any CPU.Build.0 = Release|Any CPU
{6A48DF7F-9BC2-446A-B181-F67F3B0E45FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6A48DF7F-9BC2-446A-B181-F67F3B0E45FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6A48DF7F-9BC2-446A-B181-F67F3B0E45FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6A48DF7F-9BC2-446A-B181-F67F3B0E45FB}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F3A2C6CA-3D72-4CE3-807B-73D6C15AC49A}
EndGlobalSection
EndGlobal

View file

@ -4,21 +4,26 @@ namespace VRCAuthProxy;
public class ConfigAccount
{
public string username { get; set; }
public string password { get; set; }
public required string username { get; set; }
public required string password { get; set; }
public string? totpSecret { get; set; }
}
public class iConfig
{
public List<ConfigAccount> accounts { get; set; }
public required List<ConfigAccount> accounts { get; set; }
}
// Load config from appsettings.json
public class Config
{
private static Config? _instance;
public List<ConfigAccount> Accounts { get; set; }
public required List<ConfigAccount> Accounts { get; set; }
public Config()
{
Accounts = new List<ConfigAccount>();
}
public static Config Instance
{
@ -31,8 +36,12 @@ public class Config
public static Config Load()
{
var config = new Config();
var configPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
var config = new Config
{
Accounts = new List<ConfigAccount>()
};
if (File.Exists(configPath))
{
var json = File.ReadAllText(configPath);

View file

@ -2,8 +2,13 @@
namespace VRCAuthProxy;
class HttpClientCookieContainer(HttpClientHandler handler) : HttpClient(handler)
public class HttpClientCookieContainer : HttpClient
{
public CookieContainer CookieContainer { get; }
public string Username { get; set; } = string.Empty;
public CookieContainer CookieContainer => handler.CookieContainer;
public HttpClientCookieContainer(HttpClientHandler handler) : base(handler)
{
CookieContainer = handler.CookieContainer;
}
}

View file

@ -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,43 +19,63 @@ using User = VRCAuthProxy.types.User;
string userAgent = "VRCAuthProxy V1.0.0 (https://github.com/PrideVRCommunity/VRCAuthProxy)";
var apiAccounts = new List<HttpClientCookieContainer>();
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<Cookie>()
.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}");
// 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($"Unable to use cached cookies for {account.username}. Getting new ones...");
string encodedUsername = HttpUtility.UrlEncode(account.username);
string encodedPassword = HttpUtility.UrlEncode(account.password);
@ -65,55 +86,67 @@ void LogInAllAccounts()
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($"TOTP required for {account.username}");
if (account.totpSecret == null)
{
Console.WriteLine($"No TOTP secret found for {account.username}");
return;
continue;
}
// 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;
continue;
}
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<TotpVerifyResponse>();
if (verifyRes.verified == false)
if (verifyRes?.verified != true)
{
Console.WriteLine($"Failed to verify TOTP for {account.username}");
return;
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<User>();
Console.WriteLine($"Logged in as {curUser.displayName}");
Console.WriteLine($"Logged in as {curUser?.displayName}");
// Store cookies in Redis
var cookies = cookieContainer.GetAllCookies().Cast<Cookie>()
.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";
});
@ -155,7 +188,7 @@ app.Use(async (context, next) =>
var result = await source.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close)
{
await target.CloseAsync(result.CloseStatus.Value, result.CloseStatusDescription, CancellationToken.None);
await target.CloseAsync(result.CloseStatus ?? WebSocketCloseStatus.NormalClosure, result.CloseStatusDescription, CancellationToken.None);
break;
}
await target.SendAsync(new ArraySegment<byte>(buffer, 0, result.Count), result.MessageType, result.EndOfMessage, CancellationToken.None);
@ -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();

View file

@ -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<string, string> cookies)
{
var serializedCookies = JsonSerializer.Serialize(cookies);
await _db.HashSetAsync(AUTH_TOKEN_KEY, username, serializedCookies);
}
public virtual async Task<Dictionary<string, string>?> GetAuthToken(string username)
{
var value = await _db.HashGetAsync(AUTH_TOKEN_KEY, username);
if (!value.HasValue) return null;
return JsonSerializer.Deserialize<Dictionary<string, string>>(value!);
}
public virtual async Task<Dictionary<string, Dictionary<string, string>>> GetAllAuthTokens()
{
var entries = await _db.HashGetAllAsync(AUTH_TOKEN_KEY);
var result = new Dictionary<string, Dictionary<string, string>>();
foreach (var entry in entries)
{
var cookies = JsonSerializer.Deserialize<Dictionary<string, string>>(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);
}
}

View file

@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
@ -15,8 +15,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.App" Version="2.2.8" />
<PackageReference Include="Otp.NET" Version="1.4.0" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0" />
<PackageReference Include="Otp.NET" Version="1.3.0" />
<PackageReference Include="StackExchange.Redis" Version="2.7.17" />
</ItemGroup>
<ItemGroup>

View file

@ -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; }
}

32
docker-compose.yml Normal file
View file

@ -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

45
docs/BADGE_SETUP.md Normal file
View file

@ -0,0 +1,45 @@
# Setting Up GitHub Badges
This document explains how to set up the GitHub Secrets required for the test status badge functionality.
## Steps to Create a GitHub Gist for Badge Storage
1. Create a new public GitHub Gist at https://gist.github.com/
- Add a file named `vrcauthproxy-tests.json` with the following content:
```json
{
"schemaVersion": 1,
"label": "tests",
"message": "unknown",
"color": "lightgrey"
}
```
- Save the Gist
2. Note the Gist ID from the URL
- The URL will look like: `https://gist.github.com/YOUR_USERNAME/GIST_ID`
- Copy the `GIST_ID` part
## Creating GitHub Secrets
1. In your VRCAuthProxy repository, go to **Settings** > **Secrets and variables** > **Actions**
2. Create the following secrets:
- **GIST_ID**
- Value: The Gist ID you copied in step 2 above
- **GIST_SECRET**
- Value: A GitHub Personal Access Token (PAT) with `gist` scope
- To create a PAT, go to your GitHub account **Settings** > **Developer settings** > **Personal access tokens** > **Tokens (classic)**
- Generate a new token with at least the `gist` scope
- Copy the token value (you won't be able to see it again after leaving the page)
3. Update the README.md badge URL
- Replace `USER_PLACEHOLDER` with your GitHub username
- Replace `GIST_ID_PLACEHOLDER` with your Gist ID
## Testing the Setup
After setting up the secrets and updating the README.md, push a commit to the main branch to trigger the workflow that will update the badge status.
If everything is set up correctly, the badge in the README should display the current test status (passing or failing).

64
docs/WORKFLOWS_GUIDE.md Normal file
View file

@ -0,0 +1,64 @@
# GitHub Actions Workflows Guide
This document explains the GitHub Actions workflows set up for this project.
## Available Workflows
### 1. Run Tests Workflow (`test.yml`)
This workflow runs on pushes to `main` and `develop` branches, as well as on pull requests to these branches. It:
- Builds the project
- Runs all tests
- Generates code coverage reports
- Uploads coverage to Codecov
- Updates the test status badge (on push to main)
### 2. Update Badges Workflow (`update-badges.yml`)
This workflow runs whenever the "Run Tests" workflow completes. It:
- Updates the test status badge based on the test results (passing/failing)
### 3. PR Tests Workflow (`pr-test.yml`)
This workflow runs on pull requests to `main` and `develop` branches. It:
- Builds the project
- Runs all tests
- Generates test results and code coverage reports
- Comments on the PR with test results
- Uploads coverage reports to Codecov
### 4. Build Docker Image Workflow (existing `build.yml`)
This workflow runs on pushes to the `main` branch. It:
- Builds the Docker image
- Pushes the image to the GitHub Container Registry
## Setting Up Badge Functionality
To enable the badge functionality, follow the instructions in [BADGE_SETUP.md](BADGE_SETUP.md).
## GitHub Secrets
The following GitHub Secrets are required:
- `GIST_SECRET`: A GitHub Personal Access Token with `gist` scope
- `GIST_ID`: The ID of the GitHub Gist used to store badge data
## Badge in README
The README includes two badges:
1. Build Status: Shows the status of the most recent build workflow
2. Tests Status: Shows whether the tests are passing or failing
## Workflow Customization
You can customize these workflows by editing the YAML files in the `.github/workflows` directory:
- `.github/workflows/test.yml`
- `.github/workflows/update-badges.yml`
- `.github/workflows/pr-test.yml`
- `.github/workflows/build.yml`