From 2e7b39c2cdae3a2d96a172451e4f4e06d9739376 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:27:55 +0300 Subject: [PATCH] Migrate server auth to IdentityServer --- .../Auth/IdentityServerIntegration.cs | 556 ++++++++++++++++++ .../Controllers/Admin/AdminController.cs | 60 ++ .../Controllers/Auth/AuthController.cs | 287 ++------- DorkNet.Server/DorkNet.Server.csproj | 1 + DorkNet.Server/Services/AuthService.cs | 52 +- DorkNet.Server/Services/DomainConfig.cs | 3 + .../Startup/MiddlewarePipelineExtensions.cs | 4 + .../Startup/ServiceCollectionExtensions.cs | 33 +- .../admin-ui/src/pages/IdentityServer.tsx | 232 ++++++++ .../admin-ui/src/pages/SettingsHome.tsx | 7 +- README.md | 4 + docs/architecture.md | 6 +- docs/deploy.md | 5 + 13 files changed, 960 insertions(+), 290 deletions(-) create mode 100644 DorkNet.Server/Auth/IdentityServerIntegration.cs create mode 100644 DorkNet.Server/admin-ui/src/pages/IdentityServer.tsx 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 5c5bb9f..fd8f66b 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(); @@ -1460,6 +1463,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 a6c4363..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,241 +6,18 @@ 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 like - // "dorknet-eac-disabled" throws FormatException client-side and kicks - // the login. 24 chars of base64 'A's decodes to 18 zero bytes; matches - // the rough size of a real EAC handshake nonce. + // 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() @@ -250,11 +26,6 @@ public class AuthController( // 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) { @@ -264,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() @@ -285,6 +54,7 @@ public class AuthController( } } catch { /* not form */ } + if (string.IsNullOrWhiteSpace(rawIds)) { int.TryParse(Request.Query["platform"], out platform); @@ -307,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/DorkNet.Server.csproj b/DorkNet.Server/DorkNet.Server.csproj index f37f54a..6bdbec2 100644 --- a/DorkNet.Server/DorkNet.Server.csproj +++ b/DorkNet.Server/DorkNet.Server.csproj @@ -7,6 +7,7 @@ + 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 2b328b3..de8aea3 100644 --- a/DorkNet.Server/Services/DomainConfig.cs +++ b/DorkNet.Server/Services/DomainConfig.cs @@ -20,6 +20,9 @@ public sealed class DomainConfig } public string Sub(string prefix) => $"{prefix}.{Apex}"; + + public string AuthIssuer => Url("auth"); + public string Url(string prefix, string path = "") { var host = string.IsNullOrEmpty(prefix) ? Apex : Sub(prefix); diff --git a/DorkNet.Server/Startup/MiddlewarePipelineExtensions.cs b/DorkNet.Server/Startup/MiddlewarePipelineExtensions.cs index 4313c8a..be35bd1 100644 --- a/DorkNet.Server/Startup/MiddlewarePipelineExtensions.cs +++ b/DorkNet.Server/Startup/MiddlewarePipelineExtensions.cs @@ -87,6 +87,10 @@ public static class MiddlewarePipelineExtensions // calls (login, photon custom auth) are gated too. app.UseMiddleware(); + app.UseMiddleware(); + app.UseMiddleware(); + app.UseIdentityServer(); + app.UseAuthentication(); app.UseAuthorization(); // Sits between auth and the controllers — by this point ctx.User has diff --git a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs index 0331db7..fedb23f 100644 --- a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs +++ b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using DorkNet.Contracts; +using DorkNet.Server.Auth; using DorkNet.Server.Compat; using DorkNet.Server.Data; using DorkNet.Server.Services; @@ -24,11 +25,11 @@ public static class ServiceCollectionExtensions { ConfigureLogging(builder); AddDatabase(builder); - AddDomain(builder); + var domain = AddDomain(builder); AddCoreServices(builder.Services); AddVersionCompatibility(builder); var redisConn = AddRedis(builder); - AddAuthentication(builder); + AddAuthentication(builder, domain); AddKestrelAndSignalR(builder, redisConn); } @@ -101,14 +102,15 @@ public static class ServiceCollectionExtensions // Replaces per-controller [Host(...)] filters that hard-coded // rec.net/localhost pairs — the singleton DomainConfig is injected // anywhere code needs to build outbound URLs. - private static void AddDomain(WebApplicationBuilder builder) + private static DomainConfig AddDomain(WebApplicationBuilder builder) { var apex = builder.Configuration["Domain:Apex"] ?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN") ?? "localhost"; var scheme = builder.Configuration["Domain:Scheme"] ?? "https"; - builder.Services.AddSingleton(new DomainConfig(apex, scheme)); + var domain = new DomainConfig(apex, scheme); + builder.Services.AddSingleton(domain); var allowedHosts = new[] { apex, "localhost", "127.0.0.1" } .Concat(DorkNetRouteOwnership.PublicSubdomains.Select(subdomain => $"{subdomain}.{apex}")) .Distinct(StringComparer.OrdinalIgnoreCase) @@ -120,6 +122,7 @@ public static class ServiceCollectionExtensions opt.IncludeFailureMessage = true; }); Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{string.Join(", ", allowedHosts)}]"); + return domain; } // ── Services ────────────────────────────────────────────────────────────── @@ -252,15 +255,11 @@ public static class ServiceCollectionExtensions // 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 // older Coolify configs. - private static void AddAuthentication(WebApplicationBuilder builder) + private static void AddAuthentication(WebApplicationBuilder builder, DomainConfig domain) { - 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 (the install-plugin.ps1 " + - "script generates one automatically on same-machine setup)."); + var signingKeyProvider = new IdentityServerSigningKeyProvider(builder.Configuration); + builder.Services.AddSingleton(signingKeyProvider); + builder.Services.AddRecRoomIdentityServer(builder.Configuration, domain, signingKeyProvider); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(opt => @@ -268,9 +267,9 @@ public static class ServiceCollectionExtensions 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[] { domain.AuthIssuer, AuthService.LegacyIssuer }, ValidateAudience = false, ClockSkew = TimeSpan.Zero, }; @@ -290,6 +289,12 @@ public static class ServiceCollectionExtensions 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; }, }; 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 ( +
+
+ +
+ + {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 ( +
+
{label}
+
{value}
+
+ ); +} + +function ChipList({ title, values }: { title: string; values: string[] }) { + return ( +
+

{title}

+
+ {values.map((value) => ( + {value} + ))} +
+
+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function Compatibility({ label, ok }: { label: string; ok: boolean }) { + return ( +
+ {label} + +
+ ); +} + +function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +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/README.md b/README.md index 9bfd528..1471156 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,10 @@ cd DorkNet | `S3:SecretKey` | `S3__SecretKey` | production | S3 secret key | | `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.`. + **4. Boot a local standalone server:** ```pwsh diff --git a/docs/architecture.md b/docs/architecture.md index b55d9e2..8f5119c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,7 +38,7 @@ DorkNet.sln | `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.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.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 | @@ -113,6 +113,10 @@ Compose network and forwards Cloudflare Tunnel traffic to configured with `DORKNET_S3_*` environment variables; the compose files 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 `PROJECT_PATH` and `APP_DLL` build args, rebuilds the admin/site Vite SPAs for service images that need static assets, publishes the selected diff --git a/docs/deploy.md b/docs/deploy.md index c2c14ad..a7e2bb5 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -42,6 +42,11 @@ shared `DorkNet.Server` controller/service stack behind route ownership guards, so response contracts stay identical while the architecture is split. +Auth tokens are issued by the identity service using the configured +auth host (`https://auth.` 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