Compare commits

...

13 Commits

Author SHA1 Message Date
Alexa 4ad6735e93 Use token response generator for legacy fields 2026-06-30 17:15:20 +03:00
Alexa a07e9f41ea Match legacy token cookie behavior 2026-06-30 17:01:59 +03:00
Alexa a23284ce56 Update SQLite native bundle 2026-06-30 16:21:12 +03:00
Alexa 926a5c9bba Support bearer auth on hyphen hosts 2026-06-30 16:15:52 +03:00
Alexa 8ec79ad06c Fix token cookie propagation 2026-06-30 16:10:54 +03:00
Alexa b55627a10a Migrate auth to Open.IdentityServer 2026-06-30 15:47:28 +03:00
Alexa af29fc3e8e Handle signing certificate decode failures 2026-06-30 14:44:39 +03:00
Alexa 4cdc5ee6d0 Harden signing certificate base64 parsing 2026-06-30 14:34:32 +03:00
Alexa 1e0015c18e Use native game auth client only 2026-06-30 14:29:56 +03:00
Alexa e5fedb7376 Support native game auth client 2026-06-30 14:16:34 +03:00
Alexa 704abe8df4 Restore device password grant compatibility 2026-06-30 14:06:01 +03:00
Alexa ab435c3f45 Support env-based signing certificate 2026-06-30 13:54:58 +03:00
Alexa 7617ca0367 Migrate server auth to IdentityServer 2026-06-30 13:27:56 +03:00
38 changed files with 1241 additions and 408 deletions
+4 -4
View File
@@ -1,14 +1,14 @@
# DorkNet.Server container image — Coolify deploy target. # DorkNet.Server container image — Coolify deploy target.
# ──────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────
# Multi-stage: SDK image builds + publishes, runtime image is the # Multi-stage: SDK image builds + publishes, runtime image is the
# slimmer aspnet:9.0 with only the published output. # slimmer aspnet:10.0 with only the published output.
# #
# Build context: repo root (so Tools/ and DorkNet.Models/ are visible # Build context: repo root (so Tools/ and DorkNet.Models/ are visible
# to ProjectReference + dotnet restore). The .dockerignore at the # to ProjectReference + dotnet restore). The .dockerignore at the
# repo root keeps the bin/, obj/, and other dev junk out of the # repo root keeps the bin/, obj/, and other dev junk out of the
# context. # context.
ARG DOTNET_VERSION=9.0 ARG DOTNET_VERSION=10.0
ARG NODE_VERSION=20 ARG NODE_VERSION=20
# ── admin SPA build stage ────────────────────────────────────────── # ── admin SPA build stage ──────────────────────────────────────────
@@ -76,13 +76,13 @@ WORKDIR /app
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for # curl + wget for healthcheck probes — Coolify v4 defaults to wget for
# its own probe regardless of what the image's HEALTHCHECK uses, so # its own probe regardless of what the image's HEALTHCHECK uses, so
# both have to be present. The aspnet:9.0 base image strips both by # both have to be present. The aspnet:10.0 base image strips both by
# default to keep the surface area small. # default to keep the surface area small.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends curl wget \ && apt-get install -y --no-install-recommends curl wget \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Run as a non-root user — aspnet:9.0 ships with the `app` UID 1654. # Run as a non-root user — aspnet:10.0 ships with the `app` UID 1654.
# Coolify mounts persistent volumes with write permission for this UID. # Coolify mounts persistent volumes with write permission for this UID.
USER app USER app
+1 -1
View File
@@ -1,4 +1,4 @@
ARG DOTNET_VERSION=9.0 ARG DOTNET_VERSION=10.0
ARG NODE_VERSION=20 ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine AS admin-ui FROM node:${NODE_VERSION}-alpine AS admin-ui
+73
View File
@@ -2756,6 +2756,8 @@ internal static class AuthPatches
var accessToken = __0?.ToString(); var accessToken = __0?.ToString();
if (LooksLikeJwt(accessToken)) if (LooksLikeJwt(accessToken))
_latestAccessToken = accessToken; _latestAccessToken = accessToken;
else if (string.IsNullOrWhiteSpace(__0?.ToString()) && string.IsNullOrWhiteSpace(__1?.ToString()))
_latestAccessToken = null;
if (_tokenLogCount++ < 3) if (_tokenLogCount++ < 3)
{ {
@@ -2782,6 +2784,75 @@ internal static class AuthPatches
private static string Presence(object? value) => private static string Presence(object? value) =>
string.IsNullOrWhiteSpace(value?.ToString()) ? "missing" : "present"; string.IsNullOrWhiteSpace(value?.ToString()) ? "missing" : "present";
public static void ApplyBearerTokenHeader(object? request)
{
try
{
if (request is null || string.IsNullOrWhiteSpace(_latestAccessToken)) return;
var uri = TryReadUri(request);
if (uri is null || !IsDorkNetServiceHost(uri.Host)) return;
var type = request.GetType();
var setHeader = AccessTools.Method(type, "SetHeader", new[] { typeof(string), typeof(string) })
?? AccessTools.Method(type, "AddHeader", new[] { typeof(string), typeof(string) });
setHeader?.Invoke(request, new object[] { "Authorization", "Bearer " + _latestAccessToken });
}
catch (Exception ex)
{
if (_tokenLogCount++ < 6)
Mod.Log.Warning($"[auth-trace] bearer propagation failed: {ex.Message}");
}
}
private static Uri? TryReadUri(object request)
{
var type = request.GetType();
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
if (TryGetUri(type.GetProperty("Uri", flags), request, out var uri)) return uri;
if (TryGetUri(type.GetField("<Uri>k__BackingField", flags), request, out uri)) return uri;
foreach (var prop in type.GetProperties(flags))
{
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
if (TryGetUri(prop, request, out uri)) return uri;
}
foreach (var field in type.GetFields(flags))
{
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
if (TryGetUri(field, request, out uri)) return uri;
}
return null;
}
private static bool TryGetUri(PropertyInfo? prop, object request, out Uri? uri)
{
uri = null;
if (prop is null || !prop.CanRead || prop.GetIndexParameters().Length != 0) return false;
uri = prop.GetValue(request) as Uri;
return uri is not null;
}
private static bool TryGetUri(FieldInfo? field, object request, out Uri? uri)
{
uri = null;
if (field is null) return false;
uri = field.GetValue(request) as Uri;
return uri is not null;
}
private static bool IsDorkNetServiceHost(string host)
{
var configuredHost = Mod.Cfg.ServerHost?.Trim().TrimStart('.');
if (string.IsNullOrWhiteSpace(configuredHost)) return false;
return host.Equals(configuredHost, StringComparison.OrdinalIgnoreCase)
|| host.EndsWith("." + configuredHost, StringComparison.OrdinalIgnoreCase)
|| host.EndsWith("-" + configuredHost, StringComparison.OrdinalIgnoreCase);
}
} }
internal static class HttpTracePatches internal static class HttpTracePatches
@@ -3559,6 +3630,7 @@ internal static class UriPatches
public static void HttpManagerSendRequestObject_Prefix(object request) public static void HttpManagerSendRequestObject_Prefix(object request)
{ {
RewriteRequestUri(request, "httpmanager-request-rewrite"); RewriteRequestUri(request, "httpmanager-request-rewrite");
AuthPatches.ApplyBearerTokenHeader(request);
} }
public static void UriStringCtor_Prefix(ref string uriString) public static void UriStringCtor_Prefix(ref string uriString)
@@ -3570,6 +3642,7 @@ internal static class UriPatches
public static void HttpRequestSend_Prefix(object __instance) public static void HttpRequestSend_Prefix(object __instance)
{ {
RewriteRequestUri(__instance, "http-rewrite"); RewriteRequestUri(__instance, "http-rewrite");
AuthPatches.ApplyBearerTokenHeader(__instance);
} }
private static void RewriteRequestUri(object request, string label) private static void RewriteRequestUri(object request, string label)
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
@@ -9,7 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" /> <PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" /> <PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
+2 -17
View File
@@ -1,11 +1,8 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HostFiltering; using Microsoft.AspNetCore.HostFiltering;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
namespace DorkNet.Server.Tests; namespace DorkNet.Server.Tests;
@@ -38,8 +35,8 @@ public sealed class DorkNetServerFactory : WebApplicationFactory<Program>
["S3:Endpoint"] = "", ["S3:Endpoint"] = "",
["S3:AccessKey"] = "", ["S3:AccessKey"] = "",
["S3:SecretKey"] = "", ["S3:SecretKey"] = "",
["DorkNet:DefaultClientVersion"] = "december_2020_12_18", ["DorkNet:DefaultClientVersion"] = "march_2023_03_21",
["DorkNet:SupportedVersions:0"] = "december_2020_12_18", ["DorkNet:SupportedVersions:0"] = "march_2023_03_21",
}); });
}); });
builder.ConfigureServices(services => builder.ConfigureServices(services =>
@@ -56,18 +53,6 @@ public sealed class DorkNetServerFactory : WebApplicationFactory<Program>
options.AllowEmptyHosts = true; options.AllowEmptyHosts = true;
options.IncludeFailureMessage = true; options.IncludeFailureMessage = true;
}); });
services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret)),
ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/",
ValidateAudience = false,
ClockSkew = TimeSpan.Zero,
};
});
}); });
} }
@@ -61,8 +61,8 @@ public sealed class GameClientEndpointContractTests :
ToHttpMethod(route.Method), ToHttpMethod(route.Method),
new Uri($"http://{host}{path}")); new Uri($"http://{host}{path}"));
request.Headers.Accept.ParseAdd("application/json"); request.Headers.Accept.ParseAdd("application/json");
request.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18"); request.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18"); request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
if (NeedsRequestBody(request.Method)) if (NeedsRequestBody(request.Method))
+6 -4
View File
@@ -21,11 +21,13 @@ public static class GameClientSessionFactory
HttpMethod.Post, HttpMethod.Post,
new Uri($"http://auth.{apexDomain}/connect/token")); new Uri($"http://auth.{apexDomain}/connect/token"));
tokenRequest.Headers.Accept.ParseAdd("application/json"); tokenRequest.Headers.Accept.ParseAdd("application/json");
tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18"); tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18"); tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
tokenRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string> tokenRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{ {
["grant_type"] = "password", ["grant_type"] = "password",
["client_id"] = "recroom",
["client_secret"] = "VxZ53kgbbEaRoZAeMe00MagtgD12GLL2",
["username"] = "EndpointContract", ["username"] = "EndpointContract",
["device_id"] = deviceId, ["device_id"] = deviceId,
["platform"] = "0", ["platform"] = "0",
@@ -49,8 +51,8 @@ public static class GameClientSessionFactory
HttpMethod.Get, HttpMethod.Get,
new Uri($"http://auth.{apexDomain}/connect/userinfo")); new Uri($"http://auth.{apexDomain}/connect/userinfo"));
userInfoRequest.Headers.Accept.ParseAdd("application/json"); userInfoRequest.Headers.Accept.ParseAdd("application/json");
userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18"); userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18"); userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
userInfoRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); userInfoRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var userInfoResponse = await client.SendAsync(userInfoRequest); using var userInfoResponse = await client.SendAsync(userInfoRequest);
@@ -11,11 +11,11 @@ namespace DorkNet.Server.Tests;
public sealed class VersionCheckControllerTests public sealed class VersionCheckControllerTests
{ {
[Fact] [Fact]
public void December_2020_build_is_valid_with_minimal_service_config() public void March_2023_build_is_valid_with_minimal_service_config()
{ {
var controller = CreateController(); var controller = CreateController();
var result = Assert.IsType<OkObjectResult>(controller.Check("20201210", "0")); var result = Assert.IsType<OkObjectResult>(controller.Check("20230321", "0"));
Assert.Equal(0, ReadVersionStatus(result.Value)); Assert.Equal(0, ReadVersionStatus(result.Value));
} }
@@ -37,9 +37,9 @@ public sealed class VersionCheckControllerTests
[new Late2020VersionPlugin()], [new Late2020VersionPlugin()],
new HashSet<string>(StringComparer.OrdinalIgnoreCase) new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ {
"december_2020_12_18", "march_2023_03_21",
}, },
"december_2020_12_18"); "march_2023_03_21");
return new VersionCheckController( return new VersionCheckController(
config, config,
@@ -0,0 +1,785 @@
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 Open.IdentityServer;
using Open.IdentityServer.Models;
using Open.IdentityServer.ResponseHandling;
using Open.IdentityServer.Services;
using Open.IdentityServer.Stores;
using Open.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);
var password = config["IdentityServer:SigningCertificatePassword"] ?? _legacySecret;
var certificateBase64 = config["IdentityServer:SigningCertificateBase64"];
if (!string.IsNullOrWhiteSpace(certificateBase64))
{
LoadedFromBase64 = true;
Certificate = LoadCertificateFromBase64(certificateBase64, password);
}
else
{
Certificate = LoadOrCreateCertificate(CertificatePath, password);
}
SigningKey = new X509SecurityKey(Certificate);
LegacyJwtKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_legacySecret));
}
public string CertificatePath { get; }
public bool LoadedFromBase64 { get; }
public string CertificateSource => LoadedFromBase64
? "IdentityServer:SigningCertificateBase64"
: CertificatePath;
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 LoadCertificateFromBase64(string certificateBase64, string password)
{
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
var compact = NormalizeCertificateBase64(certificateBase64);
byte[] bytes;
try
{
bytes = Convert.FromBase64String(compact);
}
catch (FormatException ex)
{
throw new InvalidOperationException(
"IdentityServer signing certificate base64 is invalid. " +
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64, " +
"without a file path or shell assignment text.",
ex);
}
try
{
return new X509Certificate2(bytes, password, flags);
}
catch (CryptographicException ex)
{
if (TryLoadPemCertificate(bytes, password, flags, out var certificate))
return certificate;
throw new InvalidOperationException(
"IdentityServer signing certificate base64 decoded successfully, but it is not a valid PFX/PKCS#12 certificate. " +
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64 and make sure " +
"IdentityServer__SigningCertificatePassword matches the PFX password.",
ex);
}
}
private static bool TryLoadPemCertificate(
byte[] bytes,
string password,
X509KeyStorageFlags flags,
out X509Certificate2 certificate)
{
certificate = null!;
string pem;
try
{
pem = Encoding.UTF8.GetString(bytes);
}
catch (DecoderFallbackException)
{
return false;
}
if (!pem.Contains("-----BEGIN", StringComparison.Ordinal))
return false;
try
{
using var pemCertificate = X509Certificate2.CreateFromPem(pem, pem);
if (!pemCertificate.HasPrivateKey)
return false;
var pfxBytes = pemCertificate.Export(X509ContentType.Pfx, password);
certificate = new X509Certificate2(pfxBytes, password, flags);
return true;
}
catch (CryptographicException)
{
return false;
}
}
private static string NormalizeCertificateBase64(string value)
{
var text = value.Trim().Trim('"', '\'');
var equals = text.IndexOf('=');
if (equals > 0)
{
var key = text[..equals].Trim();
if (key.Equals("IdentityServer__SigningCertificateBase64", StringComparison.OrdinalIgnoreCase) ||
key.Equals("IdentityServer:SigningCertificateBase64", StringComparison.OrdinalIgnoreCase))
text = text[(equals + 1)..].Trim().Trim('"', '\'');
}
if (text.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var comma = text.IndexOf(',');
if (comma >= 0)
text = text[(comma + 1)..];
}
if (text.StartsWith("base64:", StringComparison.OrdinalIgnoreCase))
text = text["base64:".Length..];
var compact = new string(text.Where(c => !char.IsWhiteSpace(c)).ToArray())
.Replace('-', '+')
.Replace('_', '/');
return (compact.Length % 4) switch
{
2 => compact + "==",
3 => compact + "=",
_ => compact,
};
}
private static X509Certificate2 LoadOrCreateCertificate(string path, string password)
{
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('/');
})
.AddInMemoryIdentityResources(RecRoomIdentityServerConfig.IdentityResources)
.AddInMemoryApiScopes(RecRoomIdentityServerConfig.ApiScopes)
.AddInMemoryClients(RecRoomIdentityServerConfig.Clients)
.AddSigningCredential(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256)
.AddResourceOwnerValidator<RecRoomPasswordValidator>()
.AddExtensionGrantValidator<CachedLoginGrantValidator>()
.AddProfileService<RecRoomProfileService>();
services.AddTransient<ITokenResponseGenerator, RecRoomTokenResponseGenerator>();
}
}
internal static class RecRoomIdentityServerConfig
{
public const string ClientId = "recroom";
public const string ClientSecret = "VxZ53kgbbEaRoZAeMe00MagtgD12GLL2";
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 game client",
RequireClientSecret = true,
ClientSecrets = { new Secret(ClientSecret.Sha256()) },
AllowedGrantTypes = { GrantType.ResourceOwnerPassword, "refresh_token", CachedLoginGrantType },
AllowedScopes = RecRoomScopes(),
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,
},
};
private static ICollection<string> RecRoomScopes() =>
[
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.OfflineAccess,
GameClientScope,
"api",
];
}
public sealed class IdentityServerGameTokenRequestMiddleware(
RequestDelegate next,
DomainConfig domain,
IConfiguration config,
ILogger<IdentityServerGameTokenRequestMiddleware> logger)
{
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 (IsDevicePasswordGrant(values))
{
if (!values.ContainsKey("username") || string.IsNullOrWhiteSpace(values["username"].ToString()))
{
values["username"] = First(
values.TryGetValue("deviceId", out var deviceId) ? deviceId.ToString() : null,
values.TryGetValue("device_id", out var deviceIdSnake) ? deviceIdSnake.ToString() : null,
values.TryGetValue("platformId", out var platformId) ? platformId.ToString() : null,
values.TryGetValue("platform_id", out var platformIdSnake) ? platformIdSnake.ToString() : null,
"anonymous");
changed = true;
}
if (!values.ContainsKey("password") || string.IsNullOrWhiteSpace(values["password"].ToString()))
{
values["password"] = "__dorknet_device_login__";
values["dorknet_device_login"] = "true";
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<IFormFeature>(new FormFeature(new FormCollection(values)));
await InvokeAndPersistAccessTokenCookieAsync(context);
}
private async Task InvokeAndPersistAccessTokenCookieAsync(HttpContext context)
{
var originalBody = context.Response.Body;
await using var bufferedBody = new MemoryStream();
context.Response.Body = bufferedBody;
try
{
await next(context);
bufferedBody.Position = 0;
if (IsSuccessfulJsonResponse(context.Response))
TryPersistAccessTokenCookie(context, bufferedBody);
bufferedBody.Position = 0;
await bufferedBody.CopyToAsync(originalBody);
}
finally
{
context.Response.Body = originalBody;
}
}
private static bool IsSuccessfulJsonResponse(HttpResponse response) =>
response.StatusCode is >= 200 and < 300
&& response.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true;
private void TryPersistAccessTokenCookie(HttpContext context, Stream responseBody)
{
try
{
using var document = JsonDocument.Parse(responseBody);
if (!document.RootElement.TryGetProperty("access_token", out var accessTokenElement))
return;
var accessToken = accessTokenElement.GetString();
if (string.IsNullOrWhiteSpace(accessToken))
return;
var expires = DateTimeOffset.UtcNow.AddHours(12);
if (document.RootElement.TryGetProperty("expires_in", out var expiresInElement) &&
expiresInElement.TryGetInt32(out var expiresIn) &&
expiresIn > 0)
{
expires = DateTimeOffset.UtcNow.AddSeconds(expiresIn);
}
var options = new CookieOptions
{
HttpOnly = true,
Secure = string.Equals(domain.Scheme, "https", StringComparison.OrdinalIgnoreCase),
SameSite = SameSiteMode.None,
Expires = expires,
IsEssential = true,
};
var cookieDomain = ResolveCookieDomain(context.Request.Host.Host);
if (cookieDomain is not null)
options.Domain = cookieDomain;
context.Response.Cookies.Append(AuthService.AccessCookieName, accessToken, options);
logger.LogInformation(
"[auth-cookie] issued access cookie host={Host} domain={Domain} secure={Secure} sameSite={SameSite} essential={IsEssential}",
context.Request.Host.Host,
cookieDomain ?? "<host-only>",
options.Secure,
options.SameSite,
options.IsEssential);
}
catch (JsonException)
{
// Leave the token response untouched if the upstream payload is not JSON.
}
}
private string? ResolveCookieDomain(string host)
{
var configuredCookieDomain =
Environment.GetEnvironmentVariable("DORKNET_AUTH_COOKIE_DOMAIN")
?? config["Auth:CookieDomain"]
?? config["IdentityServer:CookieDomain"];
if (!string.IsNullOrWhiteSpace(configuredCookieDomain))
return configuredCookieDomain.Trim().TrimStart('.') is { Length: > 0 } value ? "." + value : null;
var apex = domain.Apex.Trim().TrimStart('.');
if (string.IsNullOrWhiteSpace(apex) ||
apex.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
apex.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase) ||
apex.Equals("::1", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (host.Equals(apex, StringComparison.OrdinalIgnoreCase) ||
host.EndsWith("." + apex, StringComparison.OrdinalIgnoreCase))
{
return "." + apex;
}
if (domain.UsesHyphenSubdomains)
{
var parent = HyphenCookieParent(apex, host);
if (parent is not null)
return "." + parent;
}
return null;
}
private static string? HyphenCookieParent(string apex, string host)
{
var parts = apex.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 3)
return null;
var environmentLabel = parts[0];
var parent = string.Join('.', parts.Skip(1));
if (!host.EndsWith("." + parent, StringComparison.OrdinalIgnoreCase))
return null;
var hostLabel = host[..^(parent.Length + 1)];
return hostLabel.EndsWith("-" + environmentLabel, StringComparison.OrdinalIgnoreCase)
? parent
: null;
}
private static bool IsTokenRequest(HttpContext context) =>
HttpMethods.IsPost(context.Request.Method)
&& context.Request.Path.Equals("/connect/token", StringComparison.OrdinalIgnoreCase)
&& context.Request.HasFormContentType;
private static bool IsDevicePasswordGrant(Dictionary<string, StringValues> values)
{
if (!values.TryGetValue("grant_type", out var grantType) ||
!string.Equals(grantType.ToString(), GrantType.ResourceOwnerPassword, StringComparison.OrdinalIgnoreCase))
return false;
return !values.TryGetValue("password", out var password) ||
string.IsNullOrWhiteSpace(password.ToString());
}
private static string First(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "anonymous";
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 RecRoomTokenResponseGenerator(
TimeProvider clock,
ITokenService tokenService,
IRefreshTokenService refreshTokenService,
IScopeParser scopeParser,
IResourceStore resources,
IClientStore clients,
ILogger<TokenResponseGenerator> logger) : ITokenResponseGenerator
{
private readonly TokenResponseGenerator _inner = new(
clock,
tokenService,
refreshTokenService,
scopeParser,
resources,
clients,
logger);
public async Task<TokenResponse> ProcessAsync(TokenRequestValidationResult request)
{
var response = await _inner.ProcessAsync(request);
AddLegacyGameFields(response);
return response;
}
private static void AddLegacyGameFields(TokenResponse response)
{
if (string.IsNullOrWhiteSpace(response.AccessToken))
return;
response.Custom ??= new Dictionary<string, object>(StringComparer.Ordinal);
AddIfMissing(response.Custom, "Access_token", response.AccessToken);
AddIfMissing(response.Custom, "accessToken", response.AccessToken);
AddIfMissing(response.Custom, "AccessToken", response.AccessToken);
AddIfMissing(response.Custom, "token", response.AccessToken);
AddIfMissing(response.Custom, "Token", response.AccessToken);
AddIfMissing(response.Custom, "tokenType", "Bearer");
AddIfMissing(response.Custom, "TokenType", "Bearer");
AddIfMissing(response.Custom, "expiresIn", response.AccessTokenLifetime);
AddIfMissing(response.Custom, "ExpiresIn", response.AccessTokenLifetime);
if (!string.IsNullOrWhiteSpace(response.RefreshToken))
{
AddIfMissing(response.Custom, "Refresh_token", response.RefreshToken);
AddIfMissing(response.Custom, "refreshToken", response.RefreshToken);
AddIfMissing(response.Custom, "RefreshToken", response.RefreshToken);
}
var signingKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
AddIfMissing(response.Custom, "key", signingKey);
AddIfMissing(response.Custom, "Key", signingKey);
}
private static void AddIfMissing(IDictionary<string, object> values, string key, object value)
{
if (!values.ContainsKey(key))
values[key] = value;
}
}
public sealed class RecRoomPasswordValidator(
PlayerService playerService,
LevelService level,
ServerSettingsService settings,
DorkNetDbContext db,
ILogger<RecRoomPasswordValidator> 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;
var deviceLogin = string.Equals(raw.Get("dorknet_device_login"), "true", StringComparison.OrdinalIgnoreCase);
if (!deviceLogin && !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<GrantValidationResult> 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<CachedLoginGrantValidator> 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<string> UserClaimTypes =
[
JwtRegisteredClaimNames.Sub,
ClaimTypes.NameIdentifier,
"name",
"role",
ClaimTypes.Role,
"roles",
"accountId",
"account_id",
"accountid",
];
public static async Task<List<Claim>> 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<Claim>
{
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;
}
}
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using DorkNet.Models.Notification; using DorkNet.Models.Notification;
using DorkNet.Server.Auth; using DorkNet.Server.Auth;
using DorkNet.Server.Data; using DorkNet.Server.Data;
@@ -39,6 +40,7 @@ public class AdminController(
SignupCodeService signupCodes, SignupCodeService signupCodes,
IObjectStorage storage, IObjectStorage storage,
DomainConfig domain, DomainConfig domain,
IdentityServerSigningKeyProvider signingKeys,
ILogger<AdminController> adminLogger) : ControllerBase ILogger<AdminController> adminLogger) : ControllerBase
{ {
private long CurrentAdminId => this.RequireCurrentPlayerId(); private long CurrentAdminId => this.RequireCurrentPlayerId();
@@ -1479,6 +1481,53 @@ public class AdminController(
// ── Server settings (runtime toggles) ──────────────────────────────── // ── 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 certificateExists = signingKeys.LoadedFromBase64 || System.IO.File.Exists(fullCertPath);
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
{
signingKeys.CertificateSource,
signingKeys.LoadedFromBase64,
CertificatePath = fullCertPath,
CertificateExists = certificateExists,
signingKeys.Certificate.Thumbprint,
NotBeforeUtc = signingKeys.Certificate.NotBefore.ToUniversalTime(),
NotAfterUtc = signingKeys.Certificate.NotAfter.ToUniversalTime(),
Algorithm = SecurityAlgorithms.RsaSha256,
},
PersistedGrants = new
{
Store = "in-memory",
Warning = "Refresh tokens are process-local until an operational store is configured.",
},
});
}
/// <summary>GET <c>api/admin/v1/settings</c> — current server-wide /// <summary>GET <c>api/admin/v1/settings</c> — current server-wide
/// toggle state. Single row, single round trip.</summary> /// toggle state. Single row, single round trip.</summary>
[HttpGet("settings")] [HttpGet("settings")]
+20 -287
View File
@@ -1,6 +1,4 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth; using DorkNet.Models.Auth;
using DorkNet.Server.Data.Entities; using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services; using DorkNet.Server.Services;
@@ -8,258 +6,18 @@ using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Auth; namespace DorkNet.Server.Controllers.Auth;
/// <summary> /// <summary>
/// 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 IdentityServer.
/// </summary> /// </summary>
[ApiController] [ApiController]
public class AuthController( public class AuthController(
PlayerService playerService, PlayerService playerService,
AuthService authService, AuthService authService) : ControllerBase
LevelService level,
OrphanAccountTracker orphans,
ServerSettingsService settings,
DorkNet.Server.Data.DorkNetDbContext db,
DomainConfig domain,
ILogger<AuthController> logger) : ControllerBase
{ {
/// <summary>Award the per-day first-login XP if the calling
/// player hasn't been seen yet today (UTC).</summary>
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 Dictionary<string, object> BuildTokenResponse(string accessToken, string refreshToken)
{
var signingKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
return new()
{
["access_token"] = accessToken,
["Access_token"] = accessToken,
["accessToken"] = accessToken,
["AccessToken"] = accessToken,
["token"] = accessToken,
["Token"] = accessToken,
["token_type"] = "Bearer",
["tokenType"] = "Bearer",
["TokenType"] = "Bearer",
["expires_in"] = 43200,
["expiresIn"] = 43200,
["ExpiresIn"] = 43200,
["refresh_token"] = refreshToken,
["Refresh_token"] = refreshToken,
["refreshToken"] = refreshToken,
["RefreshToken"] = refreshToken,
["key"] = signingKey,
["Key"] = signingKey,
["scope"] = "openid profile",
};
}
private IActionResult TokenOk(string accessToken, string refreshToken)
{
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;
Response.Cookies.Append(AuthService.AccessCookieName, accessToken, cookie);
return Ok(BuildTokenResponse(accessToken, refreshToken));
}
// 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<IActionResult> 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 TokenOk(acc1, refr1);
}
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 TokenOk(acc, refr);
}
// 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 TokenOk(pair.AccessToken, pair.RefreshToken);
}
// 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 TokenOk(access, refresh);
}
[HttpGet("/connect/userinfo")]
public async Task<IActionResult> 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 // 2020.12 added an EAC challenge step in the login flow. The client GETs
// a challenge string and passes it to EACManager.GenerateChallengeResponse, // a challenge string and passes it to EACManager.GenerateChallengeResponse,
// which calls System.Convert.FromBase64CharPtr on the string. So the // which calls System.Convert.FromBase64CharPtr on the string. So the
// returned challenge MUST be valid base64 — sending arbitrary text like // returned challenge MUST be valid base64 — sending arbitrary text throws
// "dorknet-eac-disabled" throws FormatException client-side and kicks // FormatException client-side and kicks the login.
// the login. 24 chars of base64 'A's decodes to 18 zero bytes; matches
// the rough size of a real EAC handshake nonce.
[HttpGet("/eac/challenge")] [HttpGet("/eac/challenge")]
[HttpPost("/eac/challenge")] [HttpPost("/eac/challenge")]
public IActionResult EacChallenge() public IActionResult EacChallenge()
@@ -268,11 +26,6 @@ public class AuthController(
// Cached-logins lookup — boot-sequence call from ScreenAccountSelectionManager. // Cached-logins lookup — boot-sequence call from ScreenAccountSelectionManager.
// URL: GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId} // URL: GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId}
// Returns List<Login.CachedLogin>. Empty array = no remembered accounts. // Returns List<Login.CachedLogin>. 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}")] [HttpGet("/cachedlogin/forplatformid/{platform:int}/{platformId}")]
public async Task<IActionResult> CachedLoginsForPlatform(int platform, string platformId) public async Task<IActionResult> CachedLoginsForPlatform(int platform, string platformId)
{ {
@@ -282,10 +35,8 @@ public class AuthController(
/// <summary>POST <c>cachedlogin/forplatformids</c> — bulk variant /// <summary>POST <c>cachedlogin/forplatformids</c> — bulk variant
/// the watch uses when hydrating multiple platform-id mappings /// the watch uses when hydrating multiple platform-id mappings
/// at once (e.g. populating a friend list from Steam ids). Body /// at once. Body is form-urlencoded <c>platform=&amp;platformIds=a,b,c</c>
/// is form-urlencoded <c>platform=&amp;platformIds=a,b,c</c> or /// or JSON with the same shape.</summary>
/// JSON with the same shape. Returns the flattened
/// <c>List&lt;CachedLogin&gt;</c> across every matched id.</summary>
[HttpPost("/cachedlogin/forplatformids")] [HttpPost("/cachedlogin/forplatformids")]
[HttpGet("/cachedlogin/forplatformids")] [HttpGet("/cachedlogin/forplatformids")]
public async Task<IActionResult> CachedLoginsForPlatformIds() public async Task<IActionResult> CachedLoginsForPlatformIds()
@@ -303,6 +54,7 @@ public class AuthController(
} }
} }
catch { /* not form */ } catch { /* not form */ }
if (string.IsNullOrWhiteSpace(rawIds)) if (string.IsNullOrWhiteSpace(rawIds))
{ {
int.TryParse(Request.Query["platform"], out platform); int.TryParse(Request.Query["platform"], out platform);
@@ -328,10 +80,8 @@ public class AuthController(
[HttpGet("/cachedlogin/current")] [HttpGet("/cachedlogin/current")]
public async Task<IActionResult> CurrentCachedLogin() public async Task<IActionResult> CurrentCachedLogin()
{ {
var auth = Request.Headers.Authorization.ToString(); var token = GetBearerToken();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) if (token is null) return Unauthorized();
return Unauthorized();
var token = auth["Bearer ".Length..].Trim();
var id = authService.ValidateToken(token); var id = authService.ValidateToken(token);
if (id is not long playerId) return Unauthorized(); if (id is not long playerId) return Unauthorized();
var player = await playerService.GetByIdAsync(playerId); var player = await playerService.GetByIdAsync(playerId);
@@ -344,10 +94,8 @@ public class AuthController(
[FromForm] int platform = 0, [FromForm] int platform = 0,
[FromForm] string? platformId = null) [FromForm] string? platformId = null)
{ {
var auth = Request.Headers.Authorization.ToString(); var token = GetBearerToken();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) if (token is null) return Unauthorized();
return Unauthorized();
var token = auth["Bearer ".Length..].Trim();
var id = authService.ValidateToken(token); var id = authService.ValidateToken(token);
if (id is not long playerId) return Unauthorized(); if (id is not long playerId) return Unauthorized();
await playerService.TagPlatformAsync(playerId, platform, platformId); await playerService.TagPlatformAsync(playerId, platform, platformId);
@@ -357,37 +105,22 @@ public class AuthController(
: Ok(ToCachedLogin(player, platform, platformId ?? string.Empty)); : Ok(ToCachedLogin(player, platform, platformId ?? string.Empty));
} }
[HttpPost("/connect/deviceauthorization")] private string? GetBearerToken()
[HttpGet("/connect/deviceauthorization")]
public async Task<IActionResult> DeviceAuthorization()
{ {
var deviceCode = Guid.NewGuid().ToString("N"); var auth = Request.Headers.Authorization.ToString();
var userCode = deviceCode[..8].ToUpperInvariant(); if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
db.PlayerSettings.Add(new PlayerSettingEntity return auth["Bearer ".Length..].Trim();
{ return Request.Cookies.TryGetValue(AuthService.AccessCookieName, out var cookieToken)
PlayerId = RelationshipQueries.SystemAccountId, ? cookieToken
Key = $"deviceauthorization:{deviceCode}", : null;
Value = $"{userCode}|{DateTime.UtcNow.AddMinutes(15):O}",
});
await db.SaveChangesAsync();
return Ok(new
{
device_code = deviceCode,
user_code = userCode,
verification_uri = $"https://{domain.Sub("auth")}/device",
verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={userCode}",
expires_in = 900,
interval = 5,
});
} }
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new() private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string? platformId) => new()
{ {
Platform = platform, Platform = platform,
PlatformId = platformId, PlatformId = platformId ?? string.Empty,
AccountId = (int)p.Id, AccountId = (int)p.Id,
LastLoginTime = p.LastSeenAt, LastLoginTime = p.LastSeenAt,
RequirePassword = false, RequirePassword = false,
}; };
} }
+12 -9
View File
@@ -1,20 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.15" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4"> <PackageReference Include="Open.IdentityServer" Version="1.0.0" />
<PackageReference Include="MessagePack" Version="3.1.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<!-- RenderedCompactJsonFormatter for stdout JSON logs in production <!-- RenderedCompactJsonFormatter for stdout JSON logs in production
(Coolify-friendly). The host pipeline reads stdout line-by-line, (Coolify-friendly). The host pipeline reads stdout line-by-line,
and structured JSON lets a future Loki/Datadog ingest parse fields and structured JSON lets a future Loki/Datadog ingest parse fields
@@ -25,8 +28,8 @@
scale: SignalR group fanout, OrphanAccountTracker (sticky-session scale: SignalR group fanout, OrphanAccountTracker (sticky-session
account-creation flow), and PlayerPresenceService heartbeats all account-creation flow), and PlayerPresenceService heartbeats all
need shared state across replicas. --> need shared state across replicas. -->
<PackageReference Include="StackExchange.Redis" Version="2.8.16" /> <PackageReference Include="StackExchange.Redis" Version="2.13.17" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.4" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="10.0.9" />
<!-- S3-compatible object storage. Production target: Garage in <!-- S3-compatible object storage. Production target: Garage in
Coolify (one-click). Dev fallback: local MinIO for parity Coolify (one-click). Dev fallback: local MinIO for parity
testing. The SDK speaks plain S3, so swapping endpoints testing. The SDK speaks plain S3, so swapping endpoints
@@ -38,7 +41,7 @@
cost+salt in the output string so we only need one column. --> cost+salt in the output string so we only need one column. -->
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
<!-- Server-side crop/resize for img.* CDN transforms (?cropSquare=1&width=N <!-- Server-side crop/resize for img.* CDN transforms (?cropSquare=1&width=N
on profile thumbnails). The 2020 client treats this as part of the on profile thumbnails). The 2020 client treats this as part of the
image CDN contract — it uploads a full-frame capture and expects the image CDN contract — it uploads a full-frame capture and expects the
+17 -42
View File
@@ -3,13 +3,15 @@ using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using DorkNet.Server.Auth;
using DorkNet.Server.Data; using DorkNet.Server.Data;
namespace DorkNet.Server.Services; 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 AccessCookieName = "dorknet_access_token";
public const string LegacyIssuer = "https://api.rec.net/";
// Resolution order MUST match Program.cs's JwtBearer validator setup — // Resolution order MUST match Program.cs's JwtBearer validator setup —
// env var first, config key second — otherwise tokens are signed with // env var first, config key second — otherwise tokens are signed with
@@ -40,47 +42,17 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
private string CreateJwt(long playerId, TimeSpan lifetime, string tokenType) private string CreateJwt(long playerId, TimeSpan lifetime, string tokenType)
{ {
var handler = new JwtSecurityTokenHandler(); var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey)); var claims = RecRoomIdentityClaims.CreateAsync(db, playerId)
.GetAwaiter()
// Look up dev/admin flags so the JWT carries a "developer" role .GetResult();
// claim for accounts that should see the in-game debug button. claims.Add(new Claim("token_type", tokenType));
// The watch's Login.SetAccessToken parses the access_token's
// <c>role</c> claim — either a single string or a list — and
// calls <c>HasRole("developer", token)</c>; that result drives
// <c>Accounts.SetLocalAccountIsDev</c> and ultimately
// <c>SessionManager.IsDeveloper</c>, 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<Claim>
{
new(ClaimTypes.NameIdentifier, playerId.ToString()),
new(JwtRegisteredClaimNames.Sub, playerId.ToString()),
new("accountId", playerId.ToString()),
new("account_id", playerId.ToString()),
new("accountid", playerId.ToString()),
new(ClaimTypes.Role, "gameClient"),
new("roles", "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<string>
// forms.
if (isDev)
{
claims.Add(new Claim(ClaimTypes.Role, "developer"));
claims.Add(new Claim("roles", "developer"));
}
var descriptor = new SecurityTokenDescriptor var descriptor = new SecurityTokenDescriptor
{ {
Subject = new ClaimsIdentity(claims), Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.Add(lifetime), Expires = DateTime.UtcNow.Add(lifetime),
Issuer = "https://api.rec.net/", Issuer = domain.AuthIssuer,
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256), SigningCredentials = new SigningCredentials(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256),
}; };
return handler.WriteToken(handler.CreateJwtSecurityToken(descriptor)); return handler.WriteToken(handler.CreateJwtSecurityToken(descriptor));
@@ -91,19 +63,22 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
try try
{ {
var handler = new JwtSecurityTokenHandler(); var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
var principal = handler.ValidateToken(token, new TokenValidationParameters var principal = handler.ValidateToken(token, new TokenValidationParameters
{ {
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
IssuerSigningKey = key, IssuerSigningKeys = signingKeyProvider.ValidationKeys,
ValidateIssuer = true, ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/", ValidIssuers = new[] { domain.AuthIssuer, LegacyIssuer },
ValidateAudience = false, ValidateAudience = false,
ClockSkew = TimeSpan.Zero, ClockSkew = TimeSpan.Zero,
}, out _); }, 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; return idClaim is not null ? long.Parse(idClaim) : null;
} }
catch catch
+2
View File
@@ -28,6 +28,8 @@ public sealed class DomainConfig
return UsesHyphenSubdomains ? $"{prefix}-{Apex}" : $"{prefix}.{Apex}"; return UsesHyphenSubdomains ? $"{prefix}-{Apex}" : $"{prefix}.{Apex}";
} }
public string AuthIssuer => Url("auth");
public string Url(string prefix, string path = "") public string Url(string prefix, string path = "")
{ {
var host = string.IsNullOrEmpty(prefix) ? Apex : Sub(prefix); var host = string.IsNullOrEmpty(prefix) ? Apex : Sub(prefix);
@@ -87,6 +87,9 @@ public static class MiddlewarePipelineExtensions
// calls (login, photon custom auth) are gated too. // calls (login, photon custom auth) are gated too.
app.UseMiddleware<VersionDetectionMiddleware>(); app.UseMiddleware<VersionDetectionMiddleware>();
app.UseMiddleware<IdentityServerGameTokenRequestMiddleware>();
app.UseIdentityServer();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
// Sits between auth and the controllers — by this point ctx.User has // Sits between auth and the controllers — by this point ctx.User has
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using DorkNet.Server.Auth;
using DorkNet.Server.Compat; using DorkNet.Server.Compat;
using DorkNet.Server.Data; using DorkNet.Server.Data;
using DorkNet.Server.Services; using DorkNet.Server.Services;
@@ -24,11 +25,11 @@ public static class ServiceCollectionExtensions
{ {
ConfigureLogging(builder); ConfigureLogging(builder);
AddDatabase(builder); AddDatabase(builder);
AddDomain(builder); var domain = AddDomain(builder);
AddCoreServices(builder.Services); AddCoreServices(builder.Services);
AddVersionCompatibility(builder); AddVersionCompatibility(builder);
var redisConn = AddRedis(builder); var redisConn = AddRedis(builder);
AddAuthentication(builder); AddAuthentication(builder, domain);
AddKestrelAndSignalR(builder, redisConn); AddKestrelAndSignalR(builder, redisConn);
} }
@@ -108,7 +109,7 @@ public static class ServiceCollectionExtensions
// Replaces per-controller [Host(...)] filters that hard-coded // Replaces per-controller [Host(...)] filters that hard-coded
// rec.net/localhost pairs — the singleton DomainConfig is injected // rec.net/localhost pairs — the singleton DomainConfig is injected
// anywhere code needs to build outbound URLs. // anywhere code needs to build outbound URLs.
private static void AddDomain(WebApplicationBuilder builder) private static DomainConfig AddDomain(WebApplicationBuilder builder)
{ {
var apex = var apex =
builder.Configuration["Domain:Apex"] builder.Configuration["Domain:Apex"]
@@ -147,6 +148,7 @@ public static class ServiceCollectionExtensions
opt.IncludeFailureMessage = true; opt.IncludeFailureMessage = true;
}); });
Console.WriteLine($"[domain] apex={apex}, style={domain.HostStyle}, allowedHosts=[{string.Join(", ", allowedHosts)}]"); Console.WriteLine($"[domain] apex={apex}, style={domain.HostStyle}, allowedHosts=[{string.Join(", ", allowedHosts)}]");
return domain;
} }
private static readonly string[] KnownServiceSubdomains = private static readonly string[] KnownServiceSubdomains =
@@ -319,15 +321,11 @@ public static class ServiceCollectionExtensions
// Never commit a real secret to appsettings.json. DORKNET_JWT_SECRET is the // Never commit a real secret to appsettings.json. DORKNET_JWT_SECRET is the
// canonical env var name; RECNET_JWT_SECRET kept for backward compat with // canonical env var name; RECNET_JWT_SECRET kept for backward compat with
// older Coolify configs. // older Coolify configs.
private static void AddAuthentication(WebApplicationBuilder builder) private static void AddAuthentication(WebApplicationBuilder builder, DomainConfig domain)
{ {
var jwtSecret = Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET") var signingKeyProvider = new IdentityServerSigningKeyProvider(builder.Configuration);
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET") builder.Services.AddSingleton(signingKeyProvider);
?? builder.Configuration["Jwt:Secret"] builder.Services.AddRecRoomIdentityServer(builder.Configuration, domain, signingKeyProvider);
?? throw new InvalidOperationException(
"JWT secret not configured. Set the DORKNET_JWT_SECRET env var or " +
"add `Jwt:Secret` to appsettings.Local.json (the install-plugin.ps1 " +
"script generates one automatically on same-machine setup).");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt => .AddJwtBearer(opt =>
@@ -335,9 +333,9 @@ public static class ServiceCollectionExtensions
opt.TokenValidationParameters = new TokenValidationParameters opt.TokenValidationParameters = new TokenValidationParameters
{ {
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)), IssuerSigningKeys = signingKeyProvider.ValidationKeys,
ValidateIssuer = true, ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/", ValidIssuers = new[] { domain.AuthIssuer, AuthService.LegacyIssuer },
ValidateAudience = false, ValidateAudience = false,
ClockSkew = TimeSpan.Zero, ClockSkew = TimeSpan.Zero,
}; };
@@ -0,0 +1,209 @@
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: {
certificateSource: string;
loadedFromBase64: boolean;
certificatePath: string;
certificateExists: boolean;
thumbprint: string;
notBeforeUtc: string;
notAfterUtc: string;
algorithm: string;
};
persistedGrants: {
store: string;
warning: string;
};
}
export function IdentityServer() {
const [settings, setSettings] = useState<IdentityServerSettings | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const load = async () => {
setBusy(true);
try {
const data = await get<IdentityServerSettings>('/identityserver');
setSettings(data);
setErr(null);
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
};
useEffect(() => { void load(); }, []);
return (
<div>
<div className="mb-3 flex justify-end">
<button onClick={load} className="btn-secondary text-xs" disabled={busy}>
<RefreshCw className={busy ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{err && (
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div>
)}
{!settings && !err && (
<div className="card !p-6 text-center text-xs text-ink-400">Loading...</div>
)}
{settings && (
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-5">
<div className="card !p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-sm font-semibold text-ink-50">IdentityServer</h2>
<p className="mt-1 break-all font-mono text-xs text-ink-300">{settings.issuer}</p>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<StatusBadge ok={settings.signing.certificateExists} on="Signing key" off="Missing key" />
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-3">
<Endpoint label="Discovery" value={settings.discoveryUrl} />
<Endpoint label="Token" value={settings.tokenEndpoint} />
<Endpoint label="UserInfo" value={settings.userInfoEndpoint} />
</div>
</div>
<div className="card !p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-ink-50">Client</h2>
<p className="mt-1 font-mono text-xs text-ink-400">{settings.client.clientId}</p>
</div>
<span className={settings.client.allowOfflineAccess ? 'badge-online' : 'badge-neutral'}>
Offline access {settings.client.allowOfflineAccess ? 'on' : 'off'}
</span>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<ChipList title="Grant types" values={settings.client.allowedGrantTypes} />
<ChipList title="Scopes" values={settings.client.allowedScopes} />
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-3">
<Metric label="Access token" value={duration(settings.client.accessTokenLifetime)} />
<Metric label="Refresh token" value={duration(settings.client.absoluteRefreshTokenLifetime)} />
<Metric label="Refresh mode" value={`${settings.client.refreshTokenUsage} / ${settings.client.refreshTokenExpiration}`} />
</div>
</div>
</div>
<div className="space-y-5">
<div className="card !p-5">
<h2 className="text-sm font-semibold text-ink-50">Signing certificate</h2>
<div className="mt-4 space-y-3">
<Detail label="Source" value={settings.signing.certificateSource} mono />
{!settings.signing.loadedFromBase64 && (
<Detail label="Path" value={settings.signing.certificatePath} mono />
)}
<Detail label="Thumbprint" value={settings.signing.thumbprint} mono />
<Detail label="Algorithm" value={settings.signing.algorithm} mono />
<Detail label="Valid from" value={formatDate(settings.signing.notBeforeUtc)} />
<Detail label="Valid until" value={formatDate(settings.signing.notAfterUtc)} />
</div>
</div>
<div className="card !p-5">
<h2 className="text-sm font-semibold text-ink-50">Operational store</h2>
<div className="mt-3 flex items-center justify-between gap-3">
<span className="text-xs text-ink-400">Persisted grants</span>
<span className="badge-junior">{settings.persistedGrants.store}</span>
</div>
<p className="mt-4 text-xs leading-5 text-ink-400">{settings.persistedGrants.warning}</p>
</div>
</div>
</div>
)}
</div>
);
}
function Endpoint({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-ink-800 bg-ink-950/40 p-3">
<div className="label">{label}</div>
<div className="mt-2 break-all font-mono text-xs text-ink-100">{value}</div>
</div>
);
}
function ChipList({ title, values }: { title: string; values: string[] }) {
return (
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
<h3 className="text-xs font-semibold uppercase text-ink-400">{title}</h3>
<div className="mt-3 flex flex-wrap gap-2">
{values.map((value) => (
<span key={value} className="badge-neutral font-mono normal-case tracking-normal">{value}</span>
))}
</div>
</div>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-3">
<div className="text-[11px] uppercase text-ink-500">{label}</div>
<div className="mt-1 text-sm font-semibold text-ink-50">{value}</div>
</div>
);
}
function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<div className="label">{label}</div>
<div className={(mono ? 'font-mono ' : '') + 'mt-1 break-all text-xs text-ink-100'}>{value}</div>
</div>
);
}
function StatusBadge({ ok, on, off }: { ok: boolean; on: string; off: string }) {
return <span className={ok ? 'badge-online' : 'badge-junior'}>{ok ? on : off}</span>;
}
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`;
}
@@ -1,4 +1,5 @@
import { Settings } from './Settings'; import { Settings } from './Settings';
import { IdentityServer } from './IdentityServer';
import { SignupCodes } from './SignupCodes'; import { SignupCodes } from './SignupCodes';
import { PageHeader } from '../components/PageHeader'; import { PageHeader } from '../components/PageHeader';
import { Tabs, useTabParam } from '../components/Tabs'; 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 // invite codes (an access-control setting in its own right). Old
// /signup-codes route redirects here with ?tab=signup. // /signup-codes route redirects here with ?tab=signup.
export function SettingsHome() { export function SettingsHome() {
const [tab, setTab] = useTabParam<'server' | 'signup'>('server', ['server', 'signup']); const [tab, setTab] = useTabParam<'server' | 'identity' | 'signup'>('server', ['server', 'identity', 'signup']);
return ( return (
<div> <div>
<PageHeader title="Settings" blurb="Runtime server toggles and single-use signup invite codes." /> <PageHeader title="Settings" blurb="Runtime server toggles, authentication, and single-use signup invite codes." />
<Tabs <Tabs
tabs={[ tabs={[
{ key: 'server', label: 'Server' }, { key: 'server', label: 'Server' },
{ key: 'identity', label: 'IdentityServer' },
{ key: 'signup', label: 'Signup codes' }, { key: 'signup', label: 'Signup codes' },
]} ]}
active={tab} active={tab}
onChange={setTab} onChange={setTab}
/> />
{tab === 'server' && <Settings embedded />} {tab === 'server' && <Settings embedded />}
{tab === 'identity' && <IdentityServer />}
{tab === 'signup' && <SignupCodes embedded />} {tab === 'signup' && <SignupCodes embedded />}
</div> </div>
); );
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
+5 -1
View File
@@ -72,13 +72,17 @@ cd DorkNet
| `S3:SecretKey` | `S3__SecretKey` | production | S3 secret key | | `S3:SecretKey` | `S3__SecretKey` | production | S3 secret key |
| `S3:Region` | `S3__Region` | no | defaults to `garage`; use `auto` for R2 | | `S3:Region` | `S3__Region` | no | defaults to `garage`; use `auto` for R2 |
`/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.<domain>`.
**4. Boot a local standalone server:** **4. Boot a local standalone server:**
```pwsh ```pwsh
dotnet run --project DorkNet.Server dotnet run --project DorkNet.Server
``` ```
First boot creates `bin/Debug/net9.0/data/dorknet.db`, runs every First boot creates `bin/Debug/net10.0/data/dorknet.db`, runs every
`LegacyUpgrades` pass, seeds canonical rooms / store catalog / club `LegacyUpgrades` pass, seeds canonical rooms / store catalog / club
categories / playlists, and starts listening. You should see categories / playlists, and starts listening. You should see
`Application started.` in the log; `curl http://localhost:8080/healthz` `Application started.` in the log; `curl http://localhost:8080/healthz`
+5 -1
View File
@@ -38,7 +38,7 @@ DorkNet.sln
| `DorkNet.Contracts` | Service names, route ownership, service-map options, health responses, probe responses | .NET 9 | | `DorkNet.Contracts` | Service names, route ownership, service-map options, health responses, probe responses | .NET 9 |
| `DorkNet.ServiceDefaults` | Common service health checks, JSON defaults, HTTP clients, service identity, route guard | .NET 9 | | `DorkNet.ServiceDefaults` | Common service health checks, JSON defaults, HTTP clients, service identity, route guard | .NET 9 |
| `DorkNet.Gateway` | Edge reverse proxy; routes host/path slices to services and exposes service health probes | .NET 9 | | `DorkNet.Gateway` | Edge reverse proxy; routes host/path slices to services and exposes service health probes | .NET 9 |
| `DorkNet.Services.Identity` | Accounts, auth, platform login, JWT issuance route slice | .NET 9 | | `DorkNet.Services.Identity` | Accounts, auth, platform login, OpenID discovery, JWT issuance route slice | .NET 9 |
| `DorkNet.Services.Rooms` | Rooms, room keys, matchmaking, discovery route slice | .NET 9 | | `DorkNet.Services.Rooms` | Rooms, room keys, matchmaking, discovery route slice | .NET 9 |
| `DorkNet.Services.Notify` | SignalR edge, messages/chat, notification fan-out route slice | .NET 9 | | `DorkNet.Services.Notify` | SignalR edge, messages/chat, notification fan-out route slice | .NET 9 |
| `DorkNet.Services.Content` | CDN paths, image/photo APIs, uploads, S3-backed blobs | .NET 9 | | `DorkNet.Services.Content` | CDN paths, image/photo APIs, uploads, S3-backed blobs | .NET 9 |
@@ -113,6 +113,10 @@ Compose network and forwards Cloudflare Tunnel traffic to
configured with `DORKNET_S3_*` environment variables; the compose files configured with `DORKNET_S3_*` environment variables; the compose files
do not run MinIO or Garage. do not run MinIO or Garage.
The identity slice is the token issuer in the service split. Discovery at
`/.well-known/openid-configuration` advertises the configured auth host,
and access/refresh tokens minted by `/connect/token` use that same issuer.
`Dockerfile.service` is the reusable service image. It accepts `Dockerfile.service` is the reusable service image. It accepts
`PROJECT_PATH` and `APP_DLL` build args, rebuilds the admin/site Vite `PROJECT_PATH` and `APP_DLL` build args, rebuilds the admin/site Vite
SPAs for service images that need static assets, publishes the selected SPAs for service images that need static assets, publishes the selected
+5
View File
@@ -42,6 +42,11 @@ shared `DorkNet.Server` controller/service stack behind route ownership
guards, so response contracts stay identical while the architecture is guards, so response contracts stay identical while the architecture is
split. split.
Auth tokens are issued by the identity service using the configured
auth host (`https://auth.<apex>` or the equivalent configured host style)
as the JWT/OpenID issuer. `DORKNET_JWT_SECRET` still supplies the signing
key for every service that validates bearer tokens.
--- ---
## Dokploy Microservices ## Dokploy Microservices
@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
+2 -2
View File
@@ -51,8 +51,8 @@ static Assembly LoadServerAssembly(string[] args)
var repositoryRoot = FindRepositoryRoot(); var repositoryRoot = FindRepositoryRoot();
var candidates = new[] var candidates = new[]
{ {
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net9.0", "DorkNet.Server.dll"), Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net10.0", "DorkNet.Server.dll"),
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net9.0", "DorkNet.Server.dll") Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net10.0", "DorkNet.Server.dll")
}; };
var existingCandidates = candidates.Where(File.Exists).ToArray(); var existingCandidates = candidates.Where(File.Exists).ToArray();
@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
+2 -2
View File
@@ -333,8 +333,8 @@ static Assembly LoadServerAssembly(string? configuredPath)
var repositoryRoot = FindRepositoryRoot(); var repositoryRoot = FindRepositoryRoot();
var candidates = new[] var candidates = new[]
{ {
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net9.0", "DorkNet.Server.dll"), Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net10.0", "DorkNet.Server.dll"),
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net9.0", "DorkNet.Server.dll") Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net10.0", "DorkNet.Server.dll")
}; };
var existingCandidates = candidates.Where(File.Exists).ToArray(); var existingCandidates = candidates.Where(File.Exists).ToArray();
if (existingCandidates.Length == 0) if (existingCandidates.Length == 0)