diff --git a/README.md b/README.md index 035cfc0..0ad3c92 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,36 @@ await client.Matchmaker.AddAsync("demo"); > ⚠️ **Threading**: realtime events fire on a background thread. Marshal to the main thread before touching `UnityEngine.Object` (see the demo's `UnityMainThread` helper). A future SDK version will own this dispatch. +### Guest / anonymous auth + +Sign a player in without a username or password. Generate a device id and a +device secret (>= 32 CSPRNG bytes, base64-encoded) once, persist them on the +device, and reuse them to resume the same guest. + +```csharp +using Asobi; + +var client = new AsobiClient("localhost", port: 8084); + +// deviceId + deviceSecret are yours to generate and persist. The secret +// must be the base64 of at least 32 cryptographically random bytes. +var resp = await client.Auth.GuestAsync(deviceId, deviceSecret); +if (resp.created) + Debug.Log($"New guest {resp.player_id}"); +else + Debug.Log($"Resumed guest {resp.player_id}"); + +// Later, let the guest claim a permanent account. Uses the current +// access token and replaces the stored tokens with the upgraded pair. +var upgraded = await client.Auth.UpgradeGuestAsync("player1", "secret123"); +Debug.Log($"Upgraded: {upgraded.upgraded}"); +``` + See the [WebSocket protocol guide](https://github.com/widgrensit/asobi/blob/main/guides/websocket-protocol.md) for the full event surface. ## Features -- **Auth** — Register, login, OAuth, provider linking, token refresh +- **Auth** — Register, login, guest (anonymous device create-or-resume + upgrade), OAuth, provider linking, token refresh - **Players** — Profiles, updates - **Matchmaker** — Queue, status, cancel - **Matches** — List, details diff --git a/Runtime/Api/AsobiAuth.cs b/Runtime/Api/AsobiAuth.cs index de845af..2d830ee 100644 --- a/Runtime/Api/AsobiAuth.cs +++ b/Runtime/Api/AsobiAuth.cs @@ -32,6 +32,26 @@ public async Task LoginAsync(string username, string password) return resp; } + public async Task GuestAsync(string deviceId, string deviceSecret) + { + var req = new GuestRequest { device_id = deviceId, device_secret = deviceSecret }; + var resp = await _client.Http.Post("/api/v1/auth/guest", req); + _client.AccessToken = resp.access_token; + _client.RefreshToken = resp.refresh_token; + _client.PlayerId = resp.player_id; + return resp; + } + + public async Task UpgradeGuestAsync(string username, string password) + { + var req = new GuestUpgradeRequest { username = username, password = password }; + var resp = await _client.Http.Post("/api/v1/auth/guest/upgrade", req); + _client.AccessToken = resp.access_token; + _client.RefreshToken = resp.refresh_token; + _client.PlayerId = resp.player_id; + return resp; + } + public async Task OAuthAsync(string provider, string token) { var req = new OAuthRequest { provider = provider, token = token }; diff --git a/Runtime/Models/AuthModels.cs b/Runtime/Models/AuthModels.cs index ce68c89..4817616 100644 --- a/Runtime/Models/AuthModels.cs +++ b/Runtime/Models/AuthModels.cs @@ -17,6 +17,23 @@ public class AuthResponse public string access_token; public string refresh_token; public string username; + public bool created; + public bool guest; + public bool upgraded; + } + + [Serializable] + public class GuestRequest + { + public string device_id; + public string device_secret; + } + + [Serializable] + public class GuestUpgradeRequest + { + public string username; + public string password; } [Serializable] diff --git a/Tests/Runtime/AuthGuestTests.cs b/Tests/Runtime/AuthGuestTests.cs new file mode 100644 index 0000000..91b64e5 --- /dev/null +++ b/Tests/Runtime/AuthGuestTests.cs @@ -0,0 +1,82 @@ +using NUnit.Framework; +using UnityEngine; + +namespace Asobi.Tests +{ + /// + /// Wire-shape tests for guest auth request/response models. + /// + /// Guest sign-in (POST /api/v1/auth/guest) and upgrade + /// (POST /api/v1/auth/guest/upgrade) are exercised end-to-end against a + /// live backend by SmokeTest; these EditMode tests pin the JSON contract + /// (request field names + response flag mapping) without a backend. + /// + public class AuthGuestTests + { + [Test] + public void GuestRequestSerializesDeviceFields() + { + var json = JsonUtility.ToJson(new GuestRequest + { + device_id = "dev-1", + device_secret = "c2VjcmV0" + }); + + Assert.That(json, Does.Contain("\"device_id\":\"dev-1\"")); + Assert.That(json, Does.Contain("\"device_secret\":\"c2VjcmV0\"")); + } + + [Test] + public void GuestUpgradeRequestSerializesCredentials() + { + var json = JsonUtility.ToJson(new GuestUpgradeRequest + { + username = "player1", + password = "secret123" + }); + + Assert.That(json, Does.Contain("\"username\":\"player1\"")); + Assert.That(json, Does.Contain("\"password\":\"secret123\"")); + } + + [Test] + public void AuthResponseParsesGuestCreation() + { + var raw = "{\"player_id\":\"p1\",\"access_token\":\"a\",\"refresh_token\":\"r\"," + + "\"username\":\"guest_p1\",\"created\":true,\"guest\":true}"; + + var resp = JsonUtility.FromJson(raw); + + Assert.That(resp.player_id, Is.EqualTo("p1")); + Assert.That(resp.access_token, Is.EqualTo("a")); + Assert.That(resp.refresh_token, Is.EqualTo("r")); + Assert.That(resp.created, Is.True); + Assert.That(resp.guest, Is.True); + Assert.That(resp.upgraded, Is.False); + } + + [Test] + public void AuthResponseParsesGuestResume() + { + var raw = "{\"player_id\":\"p1\",\"access_token\":\"a2\",\"refresh_token\":\"r2\"," + + "\"username\":\"guest_p1\",\"guest\":true}"; + + var resp = JsonUtility.FromJson(raw); + + Assert.That(resp.guest, Is.True); + Assert.That(resp.created, Is.False); + } + + [Test] + public void AuthResponseParsesUpgrade() + { + var raw = "{\"player_id\":\"p1\",\"access_token\":\"a3\",\"refresh_token\":\"r3\"," + + "\"username\":\"player1\",\"upgraded\":true}"; + + var resp = JsonUtility.FromJson(raw); + + Assert.That(resp.upgraded, Is.True); + Assert.That(resp.username, Is.EqualTo("player1")); + } + } +} diff --git a/Tests/Runtime/AuthGuestTests.cs.meta b/Tests/Runtime/AuthGuestTests.cs.meta new file mode 100644 index 0000000..739b452 --- /dev/null +++ b/Tests/Runtime/AuthGuestTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a67ede1d533d4f338784772886667cc4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/SmokeTest.cs b/Tests/Runtime/SmokeTest.cs index 33c0126..f1739cd 100644 --- a/Tests/Runtime/SmokeTest.cs +++ b/Tests/Runtime/SmokeTest.cs @@ -37,6 +37,67 @@ public IEnumerator RunsCanonicalFlow() if (task.IsFaulted) throw task.Exception!; } + [UnityTest] + public IEnumerator RunsGuestFlow() + { + var task = RunGuestFlow(); + while (!task.IsCompleted) yield return null; + if (task.IsFaulted) throw task.Exception!; + } + + private static async Task RunGuestFlow() + { + var (host, port, useSsl) = ParseUrl( + Environment.GetEnvironmentVariable("ASOBI_URL") ?? "http://localhost:8084" + ); + Log($"Waiting for backend at {host}:{port}"); + await WaitForServer(host, port, useSsl); + + var deviceId = $"smoke_device_{DateTime.UtcNow.Ticks}"; + var deviceSecret = GenerateDeviceSecret(); + + var client = new AsobiClient(host, port: port); + var created = await client.Auth.GuestAsync(deviceId, deviceSecret); + if (!created.created) throw new Exception("expected created:true on first guest sign-in"); + if (!created.guest) throw new Exception("expected guest:true on guest sign-in"); + if (string.IsNullOrEmpty(client.AccessToken)) throw new Exception("guest tokens not stored on client"); + var playerId = created.player_id; + Log($"Guest created: {playerId}"); + + // Fresh client + same device credentials must resume the same guest. + var resumeClient = new AsobiClient(host, port: port); + var resumed = await resumeClient.Auth.GuestAsync(deviceId, deviceSecret); + if (resumed.created) throw new Exception("resume must not report created:true"); + if (resumed.player_id != playerId) + throw new Exception($"resume player_id mismatch: {resumed.player_id} vs {playerId}"); + Log("Guest resumed on fresh client."); + + // Weak secret is rejected. + var weakClient = new AsobiClient(host, port: port); + var rejected = false; + try { await weakClient.Auth.GuestAsync($"smoke_weak_{DateTime.UtcNow.Ticks}", "short"); } + catch (AsobiException e) when (e.StatusCode == 400) { rejected = true; } + if (!rejected) throw new Exception("weak device_secret should be rejected with HTTP 400"); + Log("Weak secret rejected."); + + // Claim a permanent account; tokens must be replaced with the upgraded pair. + var username = $"claimed_{DateTime.UtcNow.Ticks}"; + var upgraded = await client.Auth.UpgradeGuestAsync(username, "smoke_pw_12345"); + if (!upgraded.upgraded) throw new Exception("expected upgraded:true"); + if (upgraded.player_id != playerId) throw new Exception("upgrade must preserve player_id"); + if (client.AccessToken != upgraded.access_token) + throw new Exception("upgraded tokens not stored on client"); + Log("GUEST PASS"); + } + + private static string GenerateDeviceSecret() + { + var bytes = new byte[32]; + using var rng = System.Security.Cryptography.RandomNumberGenerator.Create(); + rng.GetBytes(bytes); + return Convert.ToBase64String(bytes); + } + private static async Task RunFlow() { var (host, port, useSsl) = ParseUrl(