diff --git a/DorkNet.ClientMod/DorkNet.ClientMod.csproj b/DorkNet.ClientMod/DorkNet.ClientMod.csproj
index 341adf0..9ddb55d 100644
--- a/DorkNet.ClientMod/DorkNet.ClientMod.csproj
+++ b/DorkNet.ClientMod/DorkNet.ClientMod.csproj
@@ -1,12 +1,13 @@
- net6.0
+ net6.0
latest
enable
DorkNet.ClientMod
DorkNet.ClientMod
- false
+ false
+ true
false
$(NoWarn);CS8602;CS8604;CS0436
@@ -19,21 +20,28 @@
they would be derivative works of the game anyway, so we never
reference them. Only MelonLoader's own DLLs are needed.
- By default the project downloads the pinned MelonLoader release
- into DorkNet.ClientMod/build/ and references those DLLs. Pass
- -p:MelonLoaderDir= only when you
- deliberately want to use a local override.
+ By default the project downloads the pinned desktop MelonLoader release
+ into DorkNet.ClientMod/build/ and references its net6 DLLs. For Quest /
+ LemonLoader builds, pass:
+
+ -p:TargetFrameworks=net8.0
+ -p:MelonLoaderDir=
+
+ The reference folder follows the target framework unless explicitly
+ overridden with -p:MelonLoaderReferenceTfm=...
-->
v0.6.6
$(MSBuildThisFileDirectory)build\melonloader
$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip
$(MelonLoaderCacheRoot)\MelonLoader
+ net8
+ net6
+ Condition="'$(MelonLoaderReferenceTfm)' == 'net6' and !Exists('$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll')">
- $(MelonLoaderDir)\net6\MelonLoader.dll
+ $(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll
false
- $(MelonLoaderDir)\net6\0Harmony.dll
+ $(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll
false
- $(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll
+ $(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll
false
- $(MelonLoaderDir)\net6\Il2CppInterop.Common.dll
+ $(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll
false
diff --git a/DorkNet.Server/Auth/IdentityServerIntegration.cs b/DorkNet.Server/Auth/IdentityServerIntegration.cs
new file mode 100644
index 0000000..6b7cede
--- /dev/null
+++ b/DorkNet.Server/Auth/IdentityServerIntegration.cs
@@ -0,0 +1,556 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using System.Text.Json;
+using Duende.IdentityServer;
+using Duende.IdentityServer.Models;
+using Duende.IdentityServer.Services;
+using Duende.IdentityServer.Validation;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Primitives;
+using Microsoft.IdentityModel.Tokens;
+using DorkNet.Server.Data;
+using DorkNet.Server.Data.Entities;
+using DorkNet.Server.Services;
+
+namespace DorkNet.Server.Auth;
+
+public sealed class IdentityServerSigningKeyProvider
+{
+ private readonly string _legacySecret;
+
+ public IdentityServerSigningKeyProvider(IConfiguration config)
+ {
+ _legacySecret =
+ Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
+ ?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
+ ?? config["Jwt:Secret"]
+ ?? throw new InvalidOperationException(
+ "JWT secret not configured. Set DORKNET_JWT_SECRET env var or Jwt:Secret in appsettings.Local.json.");
+
+ CertificatePath = ResolveCertificatePath(config);
+ Certificate = LoadOrCreateCertificate(CertificatePath, config, _legacySecret);
+ SigningKey = new X509SecurityKey(Certificate);
+ LegacyJwtKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_legacySecret));
+ }
+
+ public string CertificatePath { get; }
+ public X509Certificate2 Certificate { get; }
+ public SecurityKey SigningKey { get; }
+ public SecurityKey LegacyJwtKey { get; }
+ public SecurityKey[] ValidationKeys => new[] { SigningKey, LegacyJwtKey };
+
+ private static string ResolveCertificatePath(IConfiguration config)
+ {
+ var path = config["IdentityServer:SigningCertificatePath"];
+ if (string.IsNullOrWhiteSpace(path))
+ path = Path.Combine(AppContext.BaseDirectory, "data", "identityserver-signing.pfx");
+ return path;
+ }
+
+ private static X509Certificate2 LoadOrCreateCertificate(string path, IConfiguration config, string fallbackPassword)
+ {
+ var password = config["IdentityServer:SigningCertificatePassword"] ?? fallbackPassword;
+ var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+
+ if (File.Exists(path))
+ return new X509Certificate2(path, password, flags);
+
+ using var rsa = RSA.Create(2048);
+ var request = new CertificateRequest(
+ "CN=DorkNet IdentityServer Signing",
+ rsa,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+ request.CertificateExtensions.Add(
+ new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: false));
+
+ using var created = request.CreateSelfSigned(
+ DateTimeOffset.UtcNow.AddDays(-1),
+ DateTimeOffset.UtcNow.AddYears(10));
+ var bytes = created.Export(X509ContentType.Pfx, password);
+ File.WriteAllBytes(path, bytes);
+ return new X509Certificate2(bytes, password, flags);
+ }
+}
+
+public static class RecRoomIdentityServerRegistration
+{
+ public static void AddRecRoomIdentityServer(
+ this IServiceCollection services,
+ IConfiguration config,
+ DomainConfig domain,
+ IdentityServerSigningKeyProvider signingKeyProvider)
+ {
+ services
+ .AddIdentityServer(options =>
+ {
+ options.IssuerUri = domain.AuthIssuer.TrimEnd('/');
+ options.KeyManagement.Enabled = false;
+ var licenseKey =
+ Environment.GetEnvironmentVariable("DUENDE_IDENTITYSERVER_LICENSE_KEY")
+ ?? config["IdentityServer:LicenseKey"];
+ if (!string.IsNullOrWhiteSpace(licenseKey))
+ options.LicenseKey = licenseKey;
+ })
+ .AddInMemoryIdentityResources(RecRoomIdentityServerConfig.IdentityResources)
+ .AddInMemoryApiScopes(RecRoomIdentityServerConfig.ApiScopes)
+ .AddInMemoryClients(RecRoomIdentityServerConfig.Clients)
+ .AddSigningCredential(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256)
+ .AddResourceOwnerValidator()
+ .AddExtensionGrantValidator()
+ .AddProfileService();
+ }
+}
+
+internal static class RecRoomIdentityServerConfig
+{
+ public const string ClientId = "recroom.client";
+ public const string GameClientScope = "gameClient";
+ public const string CachedLoginGrantType = "cached_login";
+
+ public static readonly IdentityResource[] IdentityResources =
+ {
+ new IdentityResources.OpenId(),
+ new IdentityResources.Profile(),
+ };
+
+ public static readonly ApiScope[] ApiScopes =
+ {
+ new(GameClientScope, "Game client")
+ {
+ UserClaims = RecRoomIdentityClaims.UserClaimTypes,
+ },
+ new("api", "DorkNet API")
+ {
+ UserClaims = RecRoomIdentityClaims.UserClaimTypes,
+ },
+ };
+
+ public static readonly Client[] Clients =
+ {
+ new()
+ {
+ ClientId = ClientId,
+ ClientName = "Rec Room Client",
+ RequireClientSecret = false,
+ AllowedGrantTypes = { GrantType.ResourceOwnerPassword, CachedLoginGrantType },
+ AllowedScopes =
+ {
+ IdentityServerConstants.StandardScopes.OpenId,
+ IdentityServerConstants.StandardScopes.Profile,
+ IdentityServerConstants.StandardScopes.OfflineAccess,
+ GameClientScope,
+ "api",
+ },
+ AllowOfflineAccess = true,
+ AccessTokenLifetime = 60 * 60 * 12,
+ AbsoluteRefreshTokenLifetime = 60 * 60 * 24 * 30,
+ SlidingRefreshTokenLifetime = 60 * 60 * 24 * 30,
+ RefreshTokenUsage = TokenUsage.ReUse,
+ RefreshTokenExpiration = TokenExpiration.Absolute,
+ UpdateAccessTokenClaimsOnRefresh = true,
+ AlwaysIncludeUserClaimsInIdToken = true,
+ },
+ };
+}
+
+public sealed class IdentityServerLegacyTokenRequestMiddleware(RequestDelegate next)
+{
+ public async Task InvokeAsync(HttpContext context)
+ {
+ if (!IsTokenRequest(context))
+ {
+ await next(context);
+ return;
+ }
+
+ var form = await context.Request.ReadFormAsync();
+ var values = form.ToDictionary(
+ kvp => kvp.Key,
+ kvp => kvp.Value,
+ StringComparer.OrdinalIgnoreCase);
+ var changed = false;
+
+ if (!values.ContainsKey("client_id"))
+ {
+ values["client_id"] = RecRoomIdentityServerConfig.ClientId;
+ changed = true;
+ }
+
+ var scopes = values.TryGetValue("scope", out var rawScope)
+ ? rawScope.ToString()
+ : string.Empty;
+ var mergedScopes = MergeScopes(scopes);
+ if (!string.Equals(scopes, mergedScopes, StringComparison.Ordinal))
+ {
+ values["scope"] = mergedScopes;
+ changed = true;
+ }
+
+ if (changed)
+ context.Features.Set(new FormFeature(new FormCollection(values)));
+
+ await next(context);
+ }
+
+ private static bool IsTokenRequest(HttpContext context) =>
+ HttpMethods.IsPost(context.Request.Method)
+ && context.Request.Path.Equals("/connect/token", StringComparison.OrdinalIgnoreCase)
+ && context.Request.HasFormContentType;
+
+ private static string MergeScopes(string existing)
+ {
+ var scopes = existing
+ .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ scopes.Add(IdentityServerConstants.StandardScopes.OpenId);
+ scopes.Add(IdentityServerConstants.StandardScopes.Profile);
+ scopes.Add(IdentityServerConstants.StandardScopes.OfflineAccess);
+ scopes.Add(RecRoomIdentityServerConfig.GameClientScope);
+ return string.Join(' ', scopes);
+ }
+}
+
+public sealed class IdentityServerTokenResponseMiddleware(RequestDelegate next, DomainConfig domain)
+{
+ public async Task InvokeAsync(HttpContext context)
+ {
+ if (!IsTokenRequest(context))
+ {
+ await next(context);
+ return;
+ }
+
+ var originalBody = context.Response.Body;
+ await using var buffer = new MemoryStream();
+ context.Response.Body = buffer;
+
+ await next(context);
+
+ context.Response.Body = originalBody;
+ buffer.Position = 0;
+
+ if (context.Response.StatusCode != StatusCodes.Status200OK ||
+ !IsJson(context.Response.ContentType))
+ {
+ await buffer.CopyToAsync(originalBody);
+ return;
+ }
+
+ using var document = await JsonDocument.ParseAsync(buffer);
+ var response = new Dictionary(StringComparer.Ordinal);
+ foreach (var property in document.RootElement.EnumerateObject())
+ response[property.Name] = ConvertJsonValue(property.Value);
+
+ if (response.TryGetValue("access_token", out var accessValue) &&
+ accessValue is string accessToken &&
+ !string.IsNullOrWhiteSpace(accessToken))
+ {
+ AddLegacyTokenAliases(response, accessToken);
+ SetAccessCookie(context, accessToken);
+ }
+
+ var bytes = JsonSerializer.SerializeToUtf8Bytes(response);
+ context.Response.ContentLength = bytes.Length;
+ context.Response.ContentType = "application/json; charset=utf-8";
+ await originalBody.WriteAsync(bytes);
+ }
+
+ private static bool IsTokenRequest(HttpContext context) =>
+ HttpMethods.IsPost(context.Request.Method)
+ && context.Request.Path.Equals("/connect/token", StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsJson(string? contentType) =>
+ contentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) == true;
+
+ private static object? ConvertJsonValue(JsonElement value) =>
+ value.ValueKind switch
+ {
+ JsonValueKind.String => value.GetString(),
+ JsonValueKind.Number when value.TryGetInt64(out var l) => l,
+ JsonValueKind.Number => value.GetDouble(),
+ JsonValueKind.True => true,
+ JsonValueKind.False => false,
+ JsonValueKind.Null => null,
+ _ => JsonSerializer.Deserialize(value.GetRawText()),
+ };
+
+ private static void AddLegacyTokenAliases(Dictionary response, string accessToken)
+ {
+ response["Access_token"] = accessToken;
+ response["accessToken"] = accessToken;
+ response["AccessToken"] = accessToken;
+ response["token"] = accessToken;
+ response["Token"] = accessToken;
+ response["tokenType"] = "Bearer";
+ response["TokenType"] = "Bearer";
+
+ if (response.TryGetValue("expires_in", out var expiresIn))
+ {
+ response["expiresIn"] = expiresIn;
+ response["ExpiresIn"] = expiresIn;
+ }
+
+ if (response.TryGetValue("refresh_token", out var refreshToken))
+ {
+ response["Refresh_token"] = refreshToken;
+ response["refreshToken"] = refreshToken;
+ response["RefreshToken"] = refreshToken;
+ }
+
+ var signingKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
+ response["key"] = signingKey;
+ response["Key"] = signingKey;
+ }
+
+ private void SetAccessCookie(HttpContext context, string accessToken)
+ {
+ var cookie = new CookieOptions
+ {
+ HttpOnly = true,
+ Secure = string.Equals(domain.Scheme, "https", StringComparison.OrdinalIgnoreCase),
+ SameSite = SameSiteMode.None,
+ Expires = DateTimeOffset.UtcNow.AddHours(12),
+ IsEssential = true,
+ };
+ if (domain.Apex.Contains('.'))
+ cookie.Domain = "." + domain.Apex;
+ context.Response.Cookies.Append(AuthService.AccessCookieName, accessToken, cookie);
+ }
+}
+
+public sealed class RecRoomPasswordValidator(
+ PlayerService playerService,
+ LevelService level,
+ ServerSettingsService settings,
+ DorkNetDbContext db,
+ ILogger logger) : IResourceOwnerPasswordValidator
+{
+ public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
+ {
+ var raw = context.Request.Raw;
+ var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
+ var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
+ var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
+
+ if (!string.IsNullOrEmpty(context.Password) && !string.IsNullOrEmpty(context.UserName))
+ {
+ var byName = await playerService.GetByUsernameAsync(context.UserName);
+ if (byName is null || byName.PasswordHash is null)
+ {
+ context.Result = new GrantValidationResult(
+ TokenRequestErrors.InvalidGrant,
+ "unknown_user_or_no_password");
+ return;
+ }
+
+ if (!BCrypt.Net.BCrypt.Verify(context.Password, byName.PasswordHash))
+ {
+ context.Result = new GrantValidationResult(
+ TokenRequestErrors.InvalidGrant,
+ "password_mismatch");
+ return;
+ }
+
+ await playerService.TagPlatformAsync(byName.Id, platform, platformId);
+ await TryAwardLoginXpAsync(byName.Id);
+ context.Result = await CreateSuccessAsync(byName, "password");
+ return;
+ }
+
+ var effectiveDeviceId = !string.IsNullOrWhiteSpace(deviceId)
+ ? deviceId
+ : $"legacy-{(context.UserName ?? "anonymous").ToLowerInvariant()}";
+
+ if (await settings.AreSignupsDisabledAsync())
+ {
+ var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
+ if (existing is null)
+ {
+ logger.LogInformation(
+ "[auth] IdentityServer password grant device fallback refused - signups disabled (device={Device})",
+ effectiveDeviceId);
+ context.Result = new GrantValidationResult(
+ TokenRequestErrors.InvalidGrant,
+ "signups_disabled");
+ return;
+ }
+ }
+
+ var player = await playerService.GetOrCreateByDeviceAsync(
+ deviceId: effectiveDeviceId,
+ platform: platform,
+ platformId: platformId,
+ displayName: context.UserName);
+ await TryAwardLoginXpAsync(player.Id);
+ context.Result = await CreateSuccessAsync(player, "device_password");
+ }
+
+ private async Task CreateSuccessAsync(PlayerEntity player, string authMethod) =>
+ new(
+ subject: player.Id.ToString(),
+ authenticationMethod: authMethod,
+ claims: await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
+
+ private async Task TryAwardLoginXpAsync(long playerId)
+ {
+ var lastSeen = await db.Players
+ .Where(p => p.Id == playerId)
+ .Select(p => p.LastSeenAt)
+ .FirstOrDefaultAsync();
+ if (lastSeen.Date < DateTime.UtcNow.Date)
+ await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
+ }
+
+ private static string? First(params string?[] values) =>
+ values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
+}
+
+public sealed class CachedLoginGrantValidator(
+ PlayerService playerService,
+ LevelService level,
+ OrphanAccountTracker orphans,
+ DorkNetDbContext db,
+ ILogger logger) : IExtensionGrantValidator
+{
+ public string GrantType => RecRoomIdentityServerConfig.CachedLoginGrantType;
+
+ public async Task ValidateAsync(ExtensionGrantValidationContext context)
+ {
+ var raw = context.Request.Raw;
+ if (!long.TryParse(raw.Get("account_id"), out var pickedId))
+ {
+ context.Result = new GrantValidationResult(
+ TokenRequestErrors.InvalidGrant,
+ "missing account_id");
+ return;
+ }
+
+ var picked = await playerService.GetByIdAsync(pickedId);
+ if (picked is null)
+ {
+ context.Result = new GrantValidationResult(
+ TokenRequestErrors.InvalidGrant,
+ "unknown account_id");
+ return;
+ }
+
+ var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
+ var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
+ var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
+
+ var pendingId = orphans.PeekPending(deviceId, platformId);
+ if (pendingId is long orphanId && orphanId != pickedId)
+ {
+ await playerService.DeleteOrphanAsync(orphanId);
+ logger.LogInformation(
+ "[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}",
+ pickedId,
+ orphanId);
+ }
+ orphans.Clear(deviceId, platformId);
+
+ await TryAwardLoginXpAsync(picked.Id);
+ await playerService.TagPlatformAsync(picked.Id, platform, platformId);
+ context.Result = new GrantValidationResult(
+ subject: picked.Id.ToString(),
+ authenticationMethod: GrantType,
+ claims: await RecRoomIdentityClaims.CreateAsync(db, picked.Id, picked.Username));
+ }
+
+ private async Task TryAwardLoginXpAsync(long playerId)
+ {
+ var lastSeen = await db.Players
+ .Where(p => p.Id == playerId)
+ .Select(p => p.LastSeenAt)
+ .FirstOrDefaultAsync();
+ if (lastSeen.Date < DateTime.UtcNow.Date)
+ await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
+ }
+
+ private static string? First(params string?[] values) =>
+ values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
+}
+
+public sealed class RecRoomProfileService(PlayerService playerService, DorkNetDbContext db) : IProfileService
+{
+ public async Task GetProfileDataAsync(ProfileDataRequestContext context)
+ {
+ if (!TryGetSubjectId(context.Subject, out var playerId))
+ return;
+
+ var player = await playerService.GetByIdAsync(playerId);
+ if (player is null)
+ return;
+
+ context.IssuedClaims.AddRange(
+ await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
+ }
+
+ public async Task IsActiveAsync(IsActiveContext context)
+ {
+ context.IsActive =
+ TryGetSubjectId(context.Subject, out var playerId)
+ && await playerService.GetByIdAsync(playerId) is not null;
+ }
+
+ private static bool TryGetSubjectId(ClaimsPrincipal subject, out long playerId)
+ {
+ var value = subject.FindFirstValue(JwtRegisteredClaimNames.Sub)
+ ?? subject.FindFirstValue(ClaimTypes.NameIdentifier);
+ return long.TryParse(value, out playerId);
+ }
+}
+
+public static class RecRoomIdentityClaims
+{
+ public static readonly List UserClaimTypes =
+ [
+ JwtRegisteredClaimNames.Sub,
+ ClaimTypes.NameIdentifier,
+ "name",
+ "role",
+ ClaimTypes.Role,
+ "roles",
+ "accountId",
+ "account_id",
+ "accountid",
+ ];
+
+ public static async Task> CreateAsync(DorkNetDbContext db, long playerId, string? username = null)
+ {
+ var playerInfo = await db.Players
+ .Where(p => p.Id == playerId)
+ .Select(p => new { p.Username, IsDev = p.IsDeveloper || p.IsAdmin })
+ .FirstOrDefaultAsync();
+ var name = username ?? playerInfo?.Username ?? playerId.ToString();
+ var isDev = playerInfo?.IsDev ?? false;
+
+ var claims = new List
+ {
+ new(ClaimTypes.NameIdentifier, playerId.ToString()),
+ new(JwtRegisteredClaimNames.Sub, playerId.ToString()),
+ new("name", name),
+ new("accountId", playerId.ToString()),
+ new("account_id", playerId.ToString()),
+ new("accountid", playerId.ToString()),
+ new(ClaimTypes.Role, "gameClient"),
+ new("role", "gameClient"),
+ new("roles", "gameClient"),
+ };
+
+ if (isDev)
+ {
+ claims.Add(new Claim(ClaimTypes.Role, "developer"));
+ claims.Add(new Claim("role", "developer"));
+ claims.Add(new Claim("roles", "developer"));
+ }
+
+ return claims;
+ }
+}
diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs
index 9d2b075..1b5f697 100644
--- a/DorkNet.Server/Controllers/Admin/AdminController.cs
+++ b/DorkNet.Server/Controllers/Admin/AdminController.cs
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
+using Microsoft.IdentityModel.Tokens;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
@@ -39,6 +40,8 @@ public class AdminController(
SignupCodeService signupCodes,
IObjectStorage storage,
DomainConfig domain,
+ IConfiguration config,
+ IdentityServerSigningKeyProvider signingKeys,
ILogger adminLogger) : ControllerBase
{
private long CurrentAdminId => this.RequireCurrentPlayerId();
@@ -1159,6 +1162,63 @@ public class AdminController(
// ── Server settings (runtime toggles) ────────────────────────────────
+ [HttpGet("identityserver")]
+ public ActionResult GetIdentityServerSettings()
+ {
+ var issuer = domain.AuthIssuer.TrimEnd('/');
+ var client = RecRoomIdentityServerConfig.Clients[0];
+ var certPath = signingKeys.CertificatePath;
+ var fullCertPath = Path.GetFullPath(certPath);
+ var licenseConfigured =
+ !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DUENDE_IDENTITYSERVER_LICENSE_KEY"))
+ || !string.IsNullOrWhiteSpace(config["IdentityServer:LicenseKey"]);
+
+ return Ok(new
+ {
+ Issuer = issuer,
+ DiscoveryUrl = $"{issuer}/.well-known/openid-configuration",
+ TokenEndpoint = $"{issuer}/connect/token",
+ UserInfoEndpoint = $"{issuer}/connect/userinfo",
+ Client = new
+ {
+ client.ClientId,
+ client.ClientName,
+ AllowedGrantTypes = client.AllowedGrantTypes.ToArray(),
+ AllowedScopes = client.AllowedScopes.ToArray(),
+ client.AllowOfflineAccess,
+ client.AccessTokenLifetime,
+ client.AbsoluteRefreshTokenLifetime,
+ client.SlidingRefreshTokenLifetime,
+ RefreshTokenUsage = client.RefreshTokenUsage.ToString(),
+ RefreshTokenExpiration = client.RefreshTokenExpiration.ToString(),
+ },
+ Signing = new
+ {
+ CertificatePath = fullCertPath,
+ CertificateExists = System.IO.File.Exists(fullCertPath),
+ signingKeys.Certificate.Thumbprint,
+ NotBeforeUtc = signingKeys.Certificate.NotBefore.ToUniversalTime(),
+ NotAfterUtc = signingKeys.Certificate.NotAfter.ToUniversalTime(),
+ Algorithm = SecurityAlgorithms.RsaSha256,
+ },
+ License = new
+ {
+ Configured = licenseConfigured,
+ },
+ PersistedGrants = new
+ {
+ Store = "in-memory",
+ Warning = "Refresh tokens are process-local until an operational store is configured.",
+ },
+ LegacyCompatibility = new
+ {
+ InjectsClientId = true,
+ InjectsDefaultScopes = true,
+ AddsLegacyTokenAliases = true,
+ AcceptsLegacyJwtSigningKey = true,
+ },
+ });
+ }
/// GET api/admin/v1/settings — current server-wide
/// toggle state. Single row, single round trip.
[HttpGet("settings")]
diff --git a/DorkNet.Server/Controllers/Auth/AuthController.cs b/DorkNet.Server/Controllers/Auth/AuthController.cs
index a6c7e81..78bd5b3 100644
--- a/DorkNet.Server/Controllers/Auth/AuthController.cs
+++ b/DorkNet.Server/Controllers/Auth/AuthController.cs
@@ -1,5 +1,4 @@
using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
@@ -7,242 +6,26 @@ using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Auth;
///
-/// auth.rec.net — OAuth-style token endpoint used by 2019+ builds alongside PlatformLogin.
+/// auth.rec.net game-specific auth helpers. OAuth/OIDC endpoints are owned by Duende IdentityServer.
///
[ApiController]
public class AuthController(
PlayerService playerService,
- AuthService authService,
- LevelService level,
- OrphanAccountTracker orphans,
- ServerSettingsService settings,
- DorkNet.Server.Data.DorkNetDbContext db,
- DomainConfig domain,
- ILogger logger) : ControllerBase
+ AuthService authService) : ControllerBase
{
- /// Award the per-day first-login XP if the calling
- /// player hasn't been seen yet today (UTC).
- private async Task TryAwardLoginXpAsync(long playerId)
- {
- var lastSeen = await db.Players
- .Where(p => p.Id == playerId)
- .Select(p => p.LastSeenAt)
- .FirstOrDefaultAsync();
- if (lastSeen.Date < DateTime.UtcNow.Date)
- await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
- }
- // OAuth token endpoint. The 2020 client uses three grant types here:
- // - grant_type=cached_login → user clicked a remembered account on the
- // account-selection screen. Body includes `account_id` (and platform
- // creds). MUST issue a token for THAT specific account; otherwise the
- // client receives a JWT for a different account and prompts the user
- // to create a new one (because the JWT identity doesn't match what
- // they picked).
- // - grant_type=refresh_token → re-issue access token from the existing
- // refresh JWT.
- // - grant_type=password → first-time sign-in, resolve via deviceId
- // (preferred) or fall back to a stable synthetic id derived from
- // username so legacy callers don't share accounts.
- [HttpPost("/connect/token")]
- [Consumes("application/x-www-form-urlencoded")]
- public async Task Token(
- [FromForm] string? grant_type,
- [FromForm] string? username,
- [FromForm] string? password,
- [FromForm] string? refresh_token,
- [FromForm] string? deviceId,
- [FromForm(Name = "device_id")] string? device_id_snake,
- [FromForm] int platform = 0,
- [FromForm] string? platformId = null,
- [FromForm(Name = "platform_id")] string? platform_id_snake = null,
- [FromForm] long? account_id = null)
- {
- // The client mixes camelCase and snake_case parameter naming
- // depending on grant type — accept both and merge.
- var effDeviceId = !string.IsNullOrWhiteSpace(deviceId) ? deviceId : device_id_snake;
- var effPlatformId = !string.IsNullOrWhiteSpace(platformId) ? platformId : platform_id_snake;
-
- if (string.Equals(grant_type, "cached_login", StringComparison.OrdinalIgnoreCase))
- {
- if (account_id is not long pickedId)
- return BadRequest(new { error = "invalid_request", error_description = "missing account_id" });
- var picked = await playerService.GetByIdAsync(pickedId);
- if (picked is null)
- return Unauthorized(new { error = "invalid_grant", error_description = "unknown account_id" });
-
- // Orphan cleanup: if the watch just created a brand-new
- // account on account/create as part of its boot probe, but
- // the user actually picked a different cached account here,
- // the just-created row is unused. Delete it so the DB
- // doesn't accumulate one orphan per game launch. When the
- // user is genuinely creating a new account, the picked id
- // matches the just-created id and we leave it.
- var pendingId = orphans.PeekPending(effDeviceId, effPlatformId);
- if (pendingId is long orphanId && orphanId != pickedId)
- {
- await playerService.DeleteOrphanAsync(orphanId);
- logger.LogInformation(
- "[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}",
- pickedId, orphanId);
- }
- orphans.Clear(effDeviceId, effPlatformId);
-
- await TryAwardLoginXpAsync(picked.Id);
- await playerService.TagPlatformAsync(picked.Id, platform, effPlatformId);
- var (acc1, refr1) = authService.GenerateTokenPair(picked.Id);
- return Ok(new
- {
- access_token = acc1,
- token_type = "Bearer",
- expires_in = 43200,
- refresh_token = refr1,
- scope = "openid profile",
- });
- }
-
- if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
- {
- if (string.IsNullOrWhiteSpace(refresh_token))
- return BadRequest(new { error = "invalid_request" });
- var resolved = authService.ValidateToken(refresh_token);
- if (resolved is not long refreshedId)
- return Unauthorized(new { error = "invalid_grant" });
- var existing = await playerService.GetByIdAsync(refreshedId);
- if (existing is null)
- return Unauthorized(new { error = "invalid_grant" });
- var (acc, refr) = authService.GenerateTokenPair(existing.Id);
- return Ok(new
- {
- access_token = acc,
- token_type = "Bearer",
- expires_in = 43200,
- refresh_token = refr,
- scope = "openid profile",
- });
- }
-
- // password grant: two paths.
- // • Username + password supplied: real password verify against
- // PlayerEntity.PasswordHash. Wrong password → 401.
- // • No password: legacy device-id synth path for the 2020
- // client's first-launch flow where the player hasn't set a
- // password yet (HasPassword returns false in the watch UI
- // and prompts them to create one). Resolves the existing
- // account (or creates a fresh one) by deviceId.
- if (!string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(username))
- {
- var byName = await playerService.GetByUsernameAsync(username);
- if (byName is null || byName.PasswordHash is null)
- return Unauthorized(new
- {
- error = "invalid_grant",
- error_description = "unknown_user_or_no_password",
- });
- if (!BCrypt.Net.BCrypt.Verify(password, byName.PasswordHash))
- return Unauthorized(new
- {
- error = "invalid_grant",
- error_description = "password_mismatch",
- });
- // Tag the platform so the next boot's cached-login lookup
- // finds this account — without this the watch's "remembered
- // accounts" prompt would stay empty after a manual sign-in.
- await playerService.TagPlatformAsync(byName.Id, platform, effPlatformId);
- await TryAwardLoginXpAsync(byName.Id);
- var pair = authService.GenerateTokenPair(byName.Id);
- return Ok(new
- {
- access_token = pair.AccessToken,
- token_type = "Bearer",
- expires_in = 43200,
- refresh_token = pair.RefreshToken,
- scope = "openid profile",
- });
- }
-
- // Device-id fallback path. Prefer the real deviceId; fall back
- // to a stable legacy synthesis from username so older callers
- // don't accidentally share an account.
- var effectiveDeviceId = !string.IsNullOrWhiteSpace(effDeviceId)
- ? effDeviceId
- : $"legacy-{(username ?? "anonymous").ToLowerInvariant()}";
-
- // Honour the admin "signups disabled" toggle. If the device
- // hasn't logged in before, GetOrCreateByDeviceAsync would mint
- // a fresh account here — same outcome as hitting
- // /account/create directly, which the toggle blocks. Refuse
- // BEFORE creation so the flag isn't bypassable.
- if (await settings.AreSignupsDisabledAsync())
- {
- var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
- if (existing is null)
- {
- logger.LogInformation(
- "[auth] /connect/token device fallback refused — signups disabled (device={Device})",
- effectiveDeviceId);
- return Unauthorized(new
- {
- error = "signups_disabled",
- error_description = "Account creation is currently disabled by the server admin.",
- });
- }
- }
-
- var player = await playerService.GetOrCreateByDeviceAsync(
- deviceId: effectiveDeviceId,
- platform: platform,
- platformId: effPlatformId,
- displayName: username);
- await TryAwardLoginXpAsync(player.Id);
- var (access, refresh) = authService.GenerateTokenPair(player.Id);
-
- return Ok(new
- {
- access_token = access,
- token_type = "Bearer",
- expires_in = 43200,
- refresh_token = refresh,
- scope = "openid profile",
- });
- }
-
- [HttpGet("/connect/userinfo")]
- public async Task UserInfo()
- {
- var auth = Request.Headers.Authorization.ToString();
- if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
- return Unauthorized();
- var token = auth["Bearer ".Length..].Trim();
- var id = authService.ValidateToken(token);
- if (id is not long playerId) return Unauthorized();
- var player = await playerService.GetByIdAsync(playerId);
- if (player is null) return Unauthorized();
- return Ok(new { sub = player.Id.ToString(), name = player.Username });
- }
-
- // Discovery document — issuer + endpoints derived from
- // DomainConfig so a single DORKNET_DOMAIN env var keeps the
- // OpenID document pointed at the live deployment.
- [HttpGet("/.well-known/openid-configuration")]
- public IActionResult OpenIdConfig()
- {
- var base_ = $"https://{domain.Sub("auth")}";
- return Ok(new
- {
- issuer = base_,
- token_endpoint = $"{base_}/connect/token",
- userinfo_endpoint = $"{base_}/connect/userinfo",
- });
- }
+ // 2020.12 added an EAC challenge step in the login flow. The client GETs
+ // a challenge string and passes it to EACManager.GenerateChallengeResponse,
+ // which calls System.Convert.FromBase64CharPtr on the string. So the
+ // returned challenge MUST be valid base64 — sending arbitrary text throws
+ // FormatException client-side and kicks the login.
+ [HttpGet("/eac/challenge")]
+ [HttpPost("/eac/challenge")]
+ public IActionResult EacChallenge()
+ => Content("\"AAAAAAAAAAAAAAAAAAAAAAAA\"", "application/json");
// Cached-logins lookup — boot-sequence call from ScreenAccountSelectionManager.
// URL: GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId}
// Returns List. Empty array = no remembered accounts.
- //
- // Real lookup: any account whose last platformlogin matched
- // (platform, platformId) shows up here, sorted most-recent-first. On a
- // single-Steam-emu setup that's one row; on a multi-account dev box
- // it's the full list the user has cycled through.
[HttpGet("/cachedlogin/forplatformid/{platform:int}/{platformId}")]
public async Task CachedLoginsForPlatform(int platform, string platformId)
{
@@ -252,10 +35,8 @@ public class AuthController(
/// POST cachedlogin/forplatformids — bulk variant
/// the watch uses when hydrating multiple platform-id mappings
- /// at once (e.g. populating a friend list from Steam ids). Body
- /// is form-urlencoded platform=&platformIds=a,b,c or
- /// JSON with the same shape. Returns the flattened
- /// List<CachedLogin> across every matched id.
+ /// at once. Body is form-urlencoded platform=&platformIds=a,b,c
+ /// or JSON with the same shape.
[HttpPost("/cachedlogin/forplatformids")]
[HttpGet("/cachedlogin/forplatformids")]
public async Task CachedLoginsForPlatformIds()
@@ -273,6 +54,7 @@ public class AuthController(
}
}
catch { /* not form */ }
+
if (string.IsNullOrWhiteSpace(rawIds))
{
int.TryParse(Request.Query["platform"], out platform);
@@ -295,13 +77,50 @@ public class AuthController(
return Ok(results);
}
- private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
+ [HttpGet("/cachedlogin/current")]
+ public async Task CurrentCachedLogin()
+ {
+ var token = GetBearerToken();
+ if (token is null) return Unauthorized();
+ var id = authService.ValidateToken(token);
+ if (id is not long playerId) return Unauthorized();
+ var player = await playerService.GetByIdAsync(playerId);
+ if (player is null) return Unauthorized();
+ return Ok(ToCachedLogin(player, player.LastPlatform, player.LastPlatformId));
+ }
+
+ [HttpPost("/cachedlogin/migrate")]
+ public async Task MigrateCachedLogin(
+ [FromForm] int platform = 0,
+ [FromForm] string? platformId = null)
+ {
+ var token = GetBearerToken();
+ if (token is null) return Unauthorized();
+ var id = authService.ValidateToken(token);
+ if (id is not long playerId) return Unauthorized();
+ await playerService.TagPlatformAsync(playerId, platform, platformId);
+ var player = await playerService.GetByIdAsync(playerId);
+ return player is null
+ ? Unauthorized()
+ : Ok(ToCachedLogin(player, platform, platformId ?? string.Empty));
+ }
+
+ private string? GetBearerToken()
+ {
+ var auth = Request.Headers.Authorization.ToString();
+ if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
+ return auth["Bearer ".Length..].Trim();
+ return Request.Cookies.TryGetValue(AuthService.AccessCookieName, out var cookieToken)
+ ? cookieToken
+ : null;
+ }
+
+ private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string? platformId) => new()
{
Platform = platform,
- PlatformId = platformId,
+ PlatformId = platformId ?? string.Empty,
AccountId = (int)p.Id,
LastLoginTime = p.LastSeenAt,
RequirePassword = false,
};
-
}
diff --git a/DorkNet.Server/Controllers/Site/PublicSiteController.cs b/DorkNet.Server/Controllers/Site/PublicSiteController.cs
index 838533c..f0af54b 100644
--- a/DorkNet.Server/Controllers/Site/PublicSiteController.cs
+++ b/DorkNet.Server/Controllers/Site/PublicSiteController.cs
@@ -59,6 +59,18 @@ public class PublicSiteController(
var photoCount = await db.Photos
.CountAsync(ph => ph.UploaderPlayerId == id && ph.IsPublic && ph.DeletedAt == null);
+ var taggedNeedle = $",{id},";
+ var photosOfPlayerCount = await db.Photos
+ .CountAsync(ph => ph.IsPublic && ph.DeletedAt == null &&
+ ("," + ph.TaggedPlayerIdsCsv + ",").Contains(taggedNeedle));
+ var friendCount = await db.Relationships
+ .Where(r => r.Status == RelationshipStatus.Friend &&
+ (r.RequesterId == id || r.TargetId == id))
+ .Select(r => r.RequesterId == id ? r.TargetId : r.RequesterId)
+ .Distinct()
+ .CountAsync();
+ var publicRoomCount = await PublicPublishedRooms()
+ .CountAsync(r => r.CreatorPlayerId == id);
return Ok(new
{
@@ -75,6 +87,10 @@ public class PublicSiteController(
createdAt = p.CreatedAt,
profileImageName = p.ProfileImageName,
photoCount,
+ photosTakenCount = photoCount,
+ photosOfPlayerCount,
+ friendCount,
+ publicRoomCount,
});
}
@@ -94,6 +110,54 @@ public class PublicSiteController(
return Ok(await EnrichPhotosAsync(rows));
}
+ /// GET /api/site/v1/players/{id}/photos/of — public photos
+ /// where this player was tagged by the camera.
+ [HttpGet("players/{id:long}/photos/of")]
+ public async Task GetPhotosOfPlayer(long id,
+ [FromQuery] int take = 24, [FromQuery] int skip = 0)
+ {
+ take = Math.Clamp(take, 1, 100);
+ skip = Math.Max(0, skip);
+ var needle = $",{id},";
+ var rows = await db.Photos
+ .Where(p => p.IsPublic && p.DeletedAt == null &&
+ ("," + p.TaggedPlayerIdsCsv + ",").Contains(needle))
+ .OrderByDescending(p => p.CreatedAt)
+ .Skip(skip).Take(take)
+ .ToListAsync();
+ return Ok(await EnrichPhotosAsync(rows));
+ }
+
+ /// GET /api/site/v1/players/{id}/rooms — public rooms
+ /// published by this player.
+ [HttpGet("players/{id:long}/rooms")]
+ public async Task GetPlayerRooms(long id,
+ [FromQuery] int take = 24, [FromQuery] int skip = 0)
+ {
+ take = Math.Clamp(take, 1, 100);
+ skip = Math.Max(0, skip);
+ var rows = await PublicPublishedRooms()
+ .Where(r => r.CreatorPlayerId == id)
+ .OrderByDescending(r => r.HotScore)
+ .ThenByDescending(r => r.UpdatedAt)
+ .Skip(skip).Take(take)
+ .Select(r => new
+ {
+ id = r.Id,
+ name = r.Name,
+ description = r.Description,
+ creatorPlayerId = r.CreatorPlayerId,
+ isDormRoom = r.IsDormRoom,
+ isAGRoom = r.IsAGRoom,
+ visitCount = r.VisitCount,
+ visitorCount = r.VisitorCount,
+ cheerCount = r.CheerCount,
+ imageName = r.ImageName,
+ })
+ .ToListAsync();
+ return Ok(rows);
+ }
+
// ── Photos ───────────────────────────────────────────────────────
/// GET /api/site/v1/feed — global public feed, newest first.
@@ -195,6 +259,12 @@ public class PublicSiteController(
profileImageName = p.ProfileImageName,
};
+ private IQueryable PublicPublishedRooms() =>
+ db.Rooms.Where(r => r.State == 0 &&
+ r.Accessibility == 1 &&
+ !r.IsDormRoom &&
+ !r.HiddenFromBrowse);
+
/// Materialise photo rows into wire DTOs with display names
/// + room names + an absolute image URL. The URL apex switches
/// based on the request host so a localhost visitor gets localhost
@@ -205,10 +275,15 @@ public class PublicSiteController(
if (list.Count == 0) return new();
var uploaderIds = list.Select(p => p.UploaderPlayerId).Distinct().ToList();
+ var taggedIds = list
+ .SelectMany(p => ParseTaggedPlayerIds(p.TaggedPlayerIdsCsv))
+ .Distinct()
+ .ToList();
+ var playerIds = uploaderIds.Concat(taggedIds).Distinct().ToList();
var roomIds = list.Select(p => p.RoomId).Where(id => id > 0).Distinct().ToList();
- var uploaders = await db.Players
- .Where(p => uploaderIds.Contains(p.Id))
+ var playersById = await db.Players
+ .Where(p => playerIds.Contains(p.Id))
.Select(p => new { p.Id, p.Username, p.DisplayName, p.ProfileImageName })
.ToListAsync();
var rooms = await db.Rooms
@@ -216,14 +291,14 @@ public class PublicSiteController(
.Select(r => new { r.Id, r.Name })
.ToListAsync();
- var uMap = uploaders.ToDictionary(u => u.Id);
+ var pMap = playersById.ToDictionary(u => u.Id);
var rMap = rooms.ToDictionary(r => r.Id);
var cdnHost = domain.Sub("cdn");
return list.Select(p =>
{
- uMap.TryGetValue(p.UploaderPlayerId, out var u);
+ pMap.TryGetValue(p.UploaderPlayerId, out var u);
rMap.TryGetValue(p.RoomId, out var r);
return (object)new
{
@@ -237,6 +312,17 @@ public class PublicSiteController(
caption = p.Caption,
roomId = p.RoomId,
roomName = r?.Name ?? string.Empty,
+ taggedPlayers = ParseTaggedPlayerIds(p.TaggedPlayerIdsCsv)
+ .Select(id => pMap.TryGetValue(id, out var tagged) ? tagged : null)
+ .Where(tagged => tagged is not null)
+ .Select(tagged => new
+ {
+ id = tagged!.Id,
+ username = tagged.Username,
+ displayName = tagged.DisplayName,
+ profileImageName = tagged.ProfileImageName,
+ })
+ .ToArray(),
isPublic = p.IsPublic,
cheerCount = p.CheerCount,
viewCount = p.ViewCount,
@@ -245,6 +331,16 @@ public class PublicSiteController(
}).ToList();
}
+ private static List ParseTaggedPlayerIds(string? csv)
+ {
+ if (string.IsNullOrWhiteSpace(csv)) return [];
+ return csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(s => long.TryParse(s, out var id) ? id : 0)
+ .Where(id => id > 0)
+ .Distinct()
+ .ToList();
+ }
+
// ── Signup (code redemption) ─────────────────────────────────────
/// GET /api/site/v1/join/pending-devices — device ids that
diff --git a/DorkNet.Server/DorkNet.Server.csproj b/DorkNet.Server/DorkNet.Server.csproj
index 4e2687e..f01b039 100644
--- a/DorkNet.Server/DorkNet.Server.csproj
+++ b/DorkNet.Server/DorkNet.Server.csproj
@@ -7,6 +7,7 @@
+
diff --git a/DorkNet.Server/Program.cs b/DorkNet.Server/Program.cs
index ff0ef7d..74dcf2e 100644
--- a/DorkNet.Server/Program.cs
+++ b/DorkNet.Server/Program.cs
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
+using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
using Serilog;
@@ -105,7 +106,8 @@ var domainPort =
var singleOriginBaseUrl =
builder.Configuration["Domain:SingleOriginBaseUrl"]
?? Environment.GetEnvironmentVariable("DORKNET_SINGLE_ORIGIN_BASE_URL");
-builder.Services.AddSingleton(new DomainConfig(apex, domainScheme, domainPort, singleOriginBaseUrl));
+var domainConfig = new DomainConfig(apex, domainScheme, domainPort, singleOriginBaseUrl);
+builder.Services.AddSingleton(domainConfig);
builder.Services.Configure(opt =>
{
opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" };
@@ -214,12 +216,9 @@ static string NormalizeRedisConn(string raw)
// kept for backward compat with older Coolify configs (will warn at
// startup, can be removed once everyone has migrated). AuthService
// has the matching fallback so signing + validation use the same key.
-var jwtSecret = Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
- ?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
- ?? builder.Configuration["Jwt:Secret"]
- ?? throw new InvalidOperationException(
- "JWT secret not configured. Set the DORKNET_JWT_SECRET env var or " +
- "add `Jwt:Secret` to appsettings.Local.json.");
+var signingKeyProvider = new IdentityServerSigningKeyProvider(builder.Configuration);
+builder.Services.AddSingleton(signingKeyProvider);
+builder.Services.AddRecRoomIdentityServer(builder.Configuration, domainConfig, signingKeyProvider);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
@@ -227,9 +226,9 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
- IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
+ IssuerSigningKeys = signingKeyProvider.ValidationKeys,
ValidateIssuer = true,
- ValidIssuer = "https://api.rec.net/",
+ ValidIssuers = new[] { domainConfig.AuthIssuer, AuthService.LegacyIssuer },
ValidateAudience = false,
ClockSkew = TimeSpan.Zero,
};
@@ -251,6 +250,12 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
var path = ctx.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub"))
ctx.Token = accessToken;
+ if (string.IsNullOrEmpty(ctx.Token) &&
+ ctx.Request.Cookies.TryGetValue(AuthService.AccessCookieName, out var cookieToken) &&
+ !string.IsNullOrWhiteSpace(cookieToken))
+ {
+ ctx.Token = cookieToken;
+ }
return Task.CompletedTask;
},
};
@@ -1151,6 +1156,10 @@ app.Use(async (ctx, next) =>
// no DB hit beyond the (small) IpBans lookup.
app.UseMiddleware();
+app.UseMiddleware();
+app.UseMiddleware();
+app.UseIdentityServer();
+
app.UseAuthentication();
app.UseAuthorization();
// Sits between auth and the controllers — by this point ctx.User has the
diff --git a/DorkNet.Server/Services/AuthService.cs b/DorkNet.Server/Services/AuthService.cs
index 88232c9..235cb58 100644
--- a/DorkNet.Server/Services/AuthService.cs
+++ b/DorkNet.Server/Services/AuthService.cs
@@ -3,12 +3,16 @@ using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
+using DorkNet.Server.Auth;
using DorkNet.Server.Data;
namespace DorkNet.Server.Services;
-public class AuthService(IConfiguration config, DorkNetDbContext db)
+public class AuthService(IConfiguration config, DorkNetDbContext db, DomainConfig domain, IdentityServerSigningKeyProvider signingKeyProvider)
{
+ public const string AccessCookieName = "dorknet_access_token";
+ public const string LegacyIssuer = "https://api.rec.net/";
+
// Resolution order MUST match Program.cs's JwtBearer validator setup —
// env var first, config key second — otherwise tokens are signed with
// one secret and validated against another and every authenticated
@@ -38,38 +42,17 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
private string CreateJwt(long playerId, TimeSpan lifetime, string tokenType)
{
var handler = new JwtSecurityTokenHandler();
- var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
-
- // Look up dev/admin flags so the JWT carries a "developer" role
- // claim for accounts that should see the in-game debug button.
- // The watch's Login.SetAccessToken parses the access_token's
- // role claim — either a single string or a list — and
- // calls HasRole("developer", token) ; that result drives
- // Accounts.SetLocalAccountIsDev and ultimately
- // SessionManager.IsDeveloper , which the watch UI checks
- // when deciding whether to show the dev console toggle.
- var isDev = db.Players
- .Where(p => p.Id == playerId)
- .Select(p => (bool?)(p.IsDeveloper || p.IsAdmin))
- .FirstOrDefault() ?? false;
-
- var claims = new List
- {
- new(ClaimTypes.NameIdentifier, playerId.ToString()),
- new(ClaimTypes.Role, "gameClient"),
- new("token_type", tokenType),
- };
- // Multiple Role claims become a JSON array on the wire — the
- // watch's HasRole helper handles both string and List
- // forms.
- if (isDev) claims.Add(new Claim(ClaimTypes.Role, "developer"));
+ var claims = RecRoomIdentityClaims.CreateAsync(db, playerId)
+ .GetAwaiter()
+ .GetResult();
+ claims.Add(new Claim("token_type", tokenType));
var descriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.Add(lifetime),
- Issuer = "https://api.rec.net/",
- SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
+ Issuer = domain.AuthIssuer,
+ SigningCredentials = new SigningCredentials(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256),
};
return handler.WriteToken(handler.CreateJwtSecurityToken(descriptor));
@@ -80,19 +63,22 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
try
{
var handler = new JwtSecurityTokenHandler();
- var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
-
var principal = handler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
- IssuerSigningKey = key,
+ IssuerSigningKeys = signingKeyProvider.ValidationKeys,
ValidateIssuer = true,
- ValidIssuer = "https://api.rec.net/",
+ ValidIssuers = new[] { domain.AuthIssuer, LegacyIssuer },
ValidateAudience = false,
ClockSkew = TimeSpan.Zero,
}, out _);
- var idClaim = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ var idClaim = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value
+ ?? principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value
+ ?? principal.FindFirst("sub")?.Value
+ ?? principal.FindFirst("accountId")?.Value
+ ?? principal.FindFirst("account_id")?.Value
+ ?? principal.FindFirst("accountid")?.Value;
return idClaim is not null ? long.Parse(idClaim) : null;
}
catch
diff --git a/DorkNet.Server/Services/DomainConfig.cs b/DorkNet.Server/Services/DomainConfig.cs
index 1bed6e8..731f174 100644
--- a/DorkNet.Server/Services/DomainConfig.cs
+++ b/DorkNet.Server/Services/DomainConfig.cs
@@ -27,6 +27,7 @@ public sealed class DomainConfig
}
public string Sub(string prefix) => $"{prefix}.{Apex}";
+ public string AuthIssuer => SubUrl("auth");
public string Url(string host) => Port is null ? $"{Scheme}://{host}" : $"{Scheme}://{host}:{Port}";
public string SubUrl(string prefix) => Url(Sub(prefix));
public static bool MatchesSubdomain(string host, string prefix)
diff --git a/DorkNet.Server/Services/PlayerService.cs b/DorkNet.Server/Services/PlayerService.cs
index faa37e5..8813e93 100644
--- a/DorkNet.Server/Services/PlayerService.cs
+++ b/DorkNet.Server/Services/PlayerService.cs
@@ -681,8 +681,21 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger p.Username.Contains(query) || p.DisplayName.Contains(query))
+ .Where(p => p.Id == idQuery ||
+ p.Username.ToLower().Contains(needle) ||
+ p.DisplayName.ToLower().Contains(needle))
+ .OrderBy(p =>
+ p.Id == idQuery ||
+ p.Username.ToLower() == needle ||
+ p.DisplayName.ToLower() == needle ? 0 :
+ p.Username.ToLower().StartsWith(needle) ||
+ p.DisplayName.ToLower().StartsWith(needle) ? 1 : 2)
+ .ThenBy(p => p.Username)
.Take(clamped)
.ToListAsync();
}
diff --git a/DorkNet.Server/admin-ui/src/pages/IdentityServer.tsx b/DorkNet.Server/admin-ui/src/pages/IdentityServer.tsx
new file mode 100644
index 0000000..d361c8a
--- /dev/null
+++ b/DorkNet.Server/admin-ui/src/pages/IdentityServer.tsx
@@ -0,0 +1,232 @@
+import { useEffect, useState } from 'react';
+import { get } from '../lib/api';
+import { RefreshCw } from '../components/Icons';
+
+interface IdentityServerSettings {
+ issuer: string;
+ discoveryUrl: string;
+ tokenEndpoint: string;
+ userInfoEndpoint: string;
+ client: {
+ clientId: string;
+ clientName: string;
+ allowedGrantTypes: string[];
+ allowedScopes: string[];
+ allowOfflineAccess: boolean;
+ accessTokenLifetime: number;
+ absoluteRefreshTokenLifetime: number;
+ slidingRefreshTokenLifetime: number;
+ refreshTokenUsage: string;
+ refreshTokenExpiration: string;
+ };
+ signing: {
+ certificatePath: string;
+ certificateExists: boolean;
+ thumbprint: string;
+ notBeforeUtc: string;
+ notAfterUtc: string;
+ algorithm: string;
+ };
+ license: {
+ configured: boolean;
+ };
+ persistedGrants: {
+ store: string;
+ warning: string;
+ };
+ legacyCompatibility: {
+ injectsClientId: boolean;
+ injectsDefaultScopes: boolean;
+ addsLegacyTokenAliases: boolean;
+ acceptsLegacyJwtSigningKey: boolean;
+ };
+}
+
+export function IdentityServer() {
+ const [settings, setSettings] = useState(null);
+ const [err, setErr] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ const load = async () => {
+ setBusy(true);
+ try {
+ const data = await get('/identityserver');
+ setSettings(data);
+ setErr(null);
+ } catch (e) {
+ setErr((e as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ useEffect(() => { void load(); }, []);
+
+ return (
+
+
+
+
+ Refresh
+
+
+
+ {err && (
+
{err}
+ )}
+
+ {!settings && !err && (
+
Loading...
+ )}
+
+ {settings && (
+
+
+
+
+
+
IdentityServer
+
{settings.issuer}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Client
+
{settings.client.clientId}
+
+
+ Offline access {settings.client.allowOfflineAccess ? 'on' : 'off'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Legacy compatibility
+
+
+
+
+
+
+
+
+
+
+
+
Signing certificate
+
+
+
+
+
+
+
+
+
+
+
Operational store
+
+ Persisted grants
+ {settings.persistedGrants.store}
+
+
{settings.persistedGrants.warning}
+
+
+
+ )}
+
+ );
+}
+
+function Endpoint({ label, value }: { label: string; value: string }) {
+ return (
+
+ );
+}
+
+function ChipList({ title, values }: { title: string; values: string[] }) {
+ return (
+
+
{title}
+
+ {values.map((value) => (
+ {value}
+ ))}
+
+
+ );
+}
+
+function Metric({ label, value }: { label: string; value: string }) {
+ return (
+
+ );
+}
+
+function Compatibility({ label, ok }: { label: string; ok: boolean }) {
+ return (
+
+ {label}
+
+
+ );
+}
+
+function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
+ return (
+
+ );
+}
+
+function StatusBadge({ ok, on, off }: { ok: boolean; on: string; off: string }) {
+ return {ok ? on : off} ;
+}
+
+function formatDate(value: string): string {
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
+}
+
+function duration(seconds: number): string {
+ if (!Number.isFinite(seconds) || seconds <= 0) return '0s';
+ const days = Math.floor(seconds / 86400);
+ const hours = Math.floor((seconds % 86400) / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ if (days > 0) return `${days}d ${hours}h`;
+ if (hours > 0) return `${hours}h ${minutes}m`;
+ if (minutes > 0) return `${minutes}m`;
+ return `${seconds}s`;
+}
diff --git a/DorkNet.Server/admin-ui/src/pages/SettingsHome.tsx b/DorkNet.Server/admin-ui/src/pages/SettingsHome.tsx
index a16190b..1f6e45e 100644
--- a/DorkNet.Server/admin-ui/src/pages/SettingsHome.tsx
+++ b/DorkNet.Server/admin-ui/src/pages/SettingsHome.tsx
@@ -1,4 +1,5 @@
import { Settings } from './Settings';
+import { IdentityServer } from './IdentityServer';
import { SignupCodes } from './SignupCodes';
import { PageHeader } from '../components/PageHeader';
import { Tabs, useTabParam } from '../components/Tabs';
@@ -7,19 +8,21 @@ import { Tabs, useTabParam } from '../components/Tabs';
// invite codes (an access-control setting in its own right). Old
// /signup-codes route redirects here with ?tab=signup.
export function SettingsHome() {
- const [tab, setTab] = useTabParam<'server' | 'signup'>('server', ['server', 'signup']);
+ const [tab, setTab] = useTabParam<'server' | 'identity' | 'signup'>('server', ['server', 'identity', 'signup']);
return (
-
+
{tab === 'server' &&
}
+ {tab === 'identity' &&
}
{tab === 'signup' &&
}
);
diff --git a/DorkNet.Server/site/src/components/PhotoCard.tsx b/DorkNet.Server/site/src/components/PhotoCard.tsx
index d58a8d2..9ec6fe3 100644
--- a/DorkNet.Server/site/src/components/PhotoCard.tsx
+++ b/DorkNet.Server/site/src/components/PhotoCard.tsx
@@ -10,6 +10,8 @@ import { num, relativeTime } from '../lib/format';
// inner anchor preserves that even on touch devices where iOS
// occasionally fires both handlers.
export function PhotoCard({ photo }: { photo: SitePhoto }) {
+ const tagged = photo.taggedPlayers ?? [];
+
return (
{photo.caption}
)}
-
+ {tagged.length > 0 && (
+
+
With
+
+ {tagged.slice(0, 3).map(p => (
+
e.stopPropagation()}
+ className="inline-flex max-w-[8rem] items-center gap-1 rounded-md bg-ink-800/70 px-1.5 py-0.5 text-ink-200 hover:text-ink-50"
+ >
+
+
{p.displayName || p.username}
+
+ ))}
+ {tagged.length > 3 &&
+{tagged.length - 3} }
+
+
+ )}
+
♥ {num(photo.cheerCount)}
- {photo.roomName
- ? ^{photo.roomName}
- : {relativeTime(photo.createdAt)}
- }
+
+ {photo.roomName && (
+ ^{photo.roomName}
+ )}
+ {photo.roomName && · }
+ {relativeTime(photo.createdAt)}
+
diff --git a/DorkNet.Server/site/src/lib/types.ts b/DorkNet.Server/site/src/lib/types.ts
index 90c0c04..fd2dddc 100644
--- a/DorkNet.Server/site/src/lib/types.ts
+++ b/DorkNet.Server/site/src/lib/types.ts
@@ -20,6 +20,17 @@ export interface SitePlayerDetail extends SitePlayerCard {
xp: number;
createdAt: string;
photoCount: number;
+ photosTakenCount: number;
+ photosOfPlayerCount: number;
+ friendCount: number;
+ publicRoomCount: number;
+}
+
+export interface SitePhotoPlayer {
+ id: number;
+ username: string;
+ displayName: string;
+ profileImageName: string | null;
}
export interface SitePhoto {
@@ -33,6 +44,7 @@ export interface SitePhoto {
caption: string;
roomId: number;
roomName: string;
+ taggedPlayers: SitePhotoPlayer[];
isPublic: boolean;
cheerCount: number;
viewCount: number;
diff --git a/README.md b/README.md
index 2540a6c..445801f 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,10 @@ cd DorkNet
| `ConnectionStrings:Redis` | `ConnectionStrings__Redis` | no | enables SignalR backplane |
| `Domain:Apex` | `DORKNET_DOMAIN` | no | defaults to `localhost`; set for production |
+`/connect/token` issues access and refresh tokens with the configured
+auth host as the issuer. Set `DORKNET_DOMAIN` for production so OpenID
+discovery and JWT validation agree on `https://auth.`.
+
**4. Boot:**
```pwsh
@@ -104,6 +108,38 @@ AppIds, and bypasses TLS verification on the client's HTTPS calls so
self-signed certs work. (First run prints "launch the game once"; do
that, then re-run with `-ResumeBuild`.)
+### Standalone Quest build (experimental)
+
+The March Quest APK is also IL2CPP and can build the same
+`DorkNet.ClientMod` source against LemonLoader's Android runtime. The
+current Quest path is still device-tested manually: build the mod as a
+net8 LemonLoader DLL, install it into a LemonLoader-patched APK, then
+sideload the resigned APK.
+
+From this branch:
+
+```pwsh
+dotnet build .\DorkNet.ClientMod\DorkNet.ClientMod.csproj `
+ -p:TargetFrameworks=net8.0 `
+ -p:MelonLoaderDir="C:\path\to\LemonLoader\melon_data\MelonLoader"
+```
+
+Output lands at
+`DorkNet.ClientMod\bin\Debug\net8.0\DorkNet.ClientMod.dll`. Desktop
+builds still default to net6 and keep the historical flat output path.
+
+Quest notes:
+
+- Target package: `com.AgainstGravity.RecRoom`.
+- Use a public HTTPS DorkNet host; keep `"EnableTlsTrustBypass": false`
+ for Quest builds.
+- March uses the older PhotonServerSettings path: the ClientMod polls
+ until `PhotonNetwork.PhotonServerSettings` is loaded, then rewrites
+ `AppID`, `VoiceAppID`, and the fixed region by reflection.
+- Standalone APK patching/resigning is not yet wrapped by
+ `install-melon.ps1`; this is the porting path, not a public one-click
+ installer yet.
+
### Debug console (opt-in)
The 2020 client ships a built-in dev console