Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions Runtime/Api/AsobiAuth.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,26 @@ public async Task<AuthResponse> LoginAsync(string username, string password)
return resp;
}

public async Task<AuthResponse> GuestAsync(string deviceId, string deviceSecret)
{
var req = new GuestRequest { device_id = deviceId, device_secret = deviceSecret };
var resp = await _client.Http.Post<AuthResponse>("/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<AuthResponse> UpgradeGuestAsync(string username, string password)
{
var req = new GuestUpgradeRequest { username = username, password = password };
var resp = await _client.Http.Post<AuthResponse>("/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<OAuthResponse> OAuthAsync(string provider, string token)
{
var req = new OAuthRequest { provider = provider, token = token };
Expand Down
17 changes: 17 additions & 0 deletions Runtime/Models/AuthModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
82 changes: 82 additions & 0 deletions Tests/Runtime/AuthGuestTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using NUnit.Framework;
using UnityEngine;

namespace Asobi.Tests
{
/// <summary>
/// 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.
/// </summary>
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<AuthResponse>(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<AuthResponse>(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<AuthResponse>(raw);

Assert.That(resp.upgraded, Is.True);
Assert.That(resp.username, Is.EqualTo("player1"));
}
}
}
11 changes: 11 additions & 0 deletions Tests/Runtime/AuthGuestTests.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 61 additions & 0 deletions Tests/Runtime/SmokeTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading