From 8e2568de49553ed15ebda2fd1e6924aa9fafbeed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luc=20=E2=99=A5?= Date: Tue, 2 Sep 2025 13:38:45 +0200 Subject: [PATCH 01/63] feat: add discord oauth login --- API/Controller/Account/LoginDiscord.cs | 52 ++++++++++++++++++ API/Models/Requests/DiscordOAuth.cs | 9 ++++ API/Options/DiscordOAuthOptions.cs | 23 ++++++++ API/Program.cs | 3 ++ API/Services/Account/AccountService.cs | 72 ++++++++++++++++++++++++- API/Services/Account/IAccountService.cs | 5 +- API/appsettings.json | 7 +++ README.md | 8 +++ 8 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 API/Controller/Account/LoginDiscord.cs create mode 100644 API/Models/Requests/DiscordOAuth.cs create mode 100644 API/Options/DiscordOAuthOptions.cs diff --git a/API/Controller/Account/LoginDiscord.cs b/API/Controller/Account/LoginDiscord.cs new file mode 100644 index 00000000..90d62e43 --- /dev/null +++ b/API/Controller/Account/LoginDiscord.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Mvc; +using System.Net.Mime; +using Asp.Versioning; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Options; +using OpenShock.API.Models.Requests; +using OpenShock.API.Models.Response; +using OpenShock.API.Services.Account; +using OpenShock.Common.Errors; +using OpenShock.Common.Problems; +using OpenShock.Common.Options; +using OpenShock.Common.Utils; +using OpenShock.Common.Models; + +namespace OpenShock.API.Controller.Account; + +public sealed partial class AccountController +{ + [HttpPost("login/discord")] + [EnableRateLimiting("auth")] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, MediaTypeNames.Application.ProblemJson)] + [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] + [MapToApiVersion("2")] + public async Task LoginDiscord( + [FromBody] DiscordOAuth body, + [FromServices] IOptions options, + CancellationToken cancellationToken) + { + var cookieDomainToUse = options.Value.CookieDomain.Split(',').FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase)); + if (cookieDomainToUse is null) return Problem(LoginError.InvalidDomain); + + var remoteIP = HttpContext.GetRemoteIP(); + + var loginAction = await _accountService.CreateUserLoginSessionViaDiscordAsync(body.Code, new LoginContext + { + Ip = remoteIP.ToString(), + UserAgent = HttpContext.GetUserAgent(), + }, cancellationToken); + + return loginAction.Match( + ok => + { + HttpContext.SetSessionKeyCookie(ok.Token, "." + cookieDomainToUse); + return Ok(LoginV2OkResponse.FromUser(ok.User)); + }, + notActivated => Problem(AccountError.AccountNotActivated), + deactivated => Problem(AccountError.AccountDeactivated), + _ => Problem(LoginError.InvalidCredentials) + ); + } +} diff --git a/API/Models/Requests/DiscordOAuth.cs b/API/Models/Requests/DiscordOAuth.cs new file mode 100644 index 00000000..d3e1c995 --- /dev/null +++ b/API/Models/Requests/DiscordOAuth.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.API.Models.Requests; + +public sealed class DiscordOAuth +{ + [Required] + public required string Code { get; init; } +} diff --git a/API/Options/DiscordOAuthOptions.cs b/API/Options/DiscordOAuthOptions.cs new file mode 100644 index 00000000..5237f30a --- /dev/null +++ b/API/Options/DiscordOAuthOptions.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Options; +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.API.Options; + +public sealed class DiscordOAuthOptions +{ + public const string SectionName = "OpenShock:Discord"; + + [Required(AllowEmptyStrings = false)] + public required string ClientId { get; init; } + + [Required(AllowEmptyStrings = false)] + public required string ClientSecret { get; init; } + + [Required(AllowEmptyStrings = false)] + public required string RedirectUri { get; init; } +} + +[OptionsValidator] +public partial class DiscordOAuthOptionsValidator : IValidateOptions +{ +} diff --git a/API/Program.cs b/API/Program.cs index 3684799f..47f89dc7 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -5,6 +5,7 @@ using OpenShock.API.Services.Account; using OpenShock.API.Services.Email; using OpenShock.API.Services.UserService; +using OpenShock.API.Options; using OpenShock.Common; using OpenShock.Common.DeviceControl; using OpenShock.Common.Extensions; @@ -27,6 +28,7 @@ builder.Services.Configure(builder.Configuration.GetRequiredSection(FrontendOptions.SectionName)); builder.Services.AddSingleton, FrontendOptionsValidator>(); +builder.Services.Configure(builder.Configuration.GetRequiredSection(DiscordOAuthOptions.SectionName)); var databaseConfig = builder.Configuration.GetDatabaseOptions(); var redisConfig = builder.Configuration.GetRedisConfigurationOptions(); @@ -52,6 +54,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddHttpClient("DiscordOAuth", client => client.BaseAddress = new Uri("https://discord.com/api/")); builder.AddSwaggerExt(); diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 9bd816f9..c972dd28 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -1,10 +1,15 @@ using System.Net.Mail; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json.Serialization; using OneOf; using OneOf.Types; using OpenShock.API.Services.Email; using OpenShock.API.Services.Email.Mailjet.Mail; +using OpenShock.API.Options; using OpenShock.Common.Constants; using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; @@ -25,6 +30,8 @@ public sealed class AccountService : IAccountService private readonly ISessionService _sessionService; private readonly ILogger _logger; private readonly FrontendOptions _frontendConfig; + private readonly IHttpClientFactory _httpClientFactory; + private readonly DiscordOAuthOptions _discordOptions; /// /// DI Constructor @@ -35,13 +42,16 @@ public sealed class AccountService : IAccountService /// /// public AccountService(OpenShockContext db, IEmailService emailService, - ISessionService sessionService, ILogger logger, IOptions options) + ISessionService sessionService, ILogger logger, IOptions options, + IHttpClientFactory httpClientFactory, IOptions discordOptions) { _db = db; _emailService = emailService; _logger = logger; _frontendConfig = options.Value; _sessionService = sessionService; + _httpClientFactory = httpClientFactory; + _discordOptions = discordOptions.Value; } private async Task IsUserNameBlacklisted(string username) @@ -279,6 +289,66 @@ public async Task> CreateUserLoginSessionViaDiscordAsync(string code, LoginContext loginContext, CancellationToken cancellationToken = default) + { + var client = _httpClientFactory.CreateClient("DiscordOAuth"); + + var tokenResponse = await client.PostAsync("oauth2/token", + new FormUrlEncodedContent(new Dictionary + { + ["client_id"] = _discordOptions.ClientId, + ["client_secret"] = _discordOptions.ClientSecret, + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = _discordOptions.RedirectUri + }), cancellationToken); + + if (!tokenResponse.IsSuccessStatusCode) return new DiscordOAuthError(); + + var token = await tokenResponse.Content.ReadFromJsonAsync(cancellationToken); + if (token?.AccessToken is null) return new DiscordOAuthError(); + + var userRequest = new HttpRequestMessage(HttpMethod.Get, "users/@me"); + userRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + var userResponse = await client.SendAsync(userRequest, cancellationToken); + if (!userResponse.IsSuccessStatusCode) return new DiscordOAuthError(); + + var discordUser = await userResponse.Content.ReadFromJsonAsync(cancellationToken); + if (discordUser?.Email is null) return new DiscordOAuthError(); + + var email = discordUser.Email.ToLowerInvariant(); + var user = await _db.Users.Include(u => u.UserDeactivation).FirstOrDefaultAsync(x => x.Email == email, cancellationToken); + + if (user is null) + { + var username = discordUser.Username; + var attempt = 0; + while (await _db.Users.AnyAsync(x => x.Name == username, cancellationToken)) + { + attempt++; + username = discordUser.Username + attempt; + } + + var password = CryptoUtils.RandomString(AuthConstants.GeneratedTokenLength); + var created = await CreateAccountWithoutActivationFlowLegacyAsync(email, username, password); + if (created.IsT1) return new DiscordOAuthError(); + user = created.AsT0.Value; + } + else + { + if (user.ActivatedAt is null) return new AccountNotActivated(); + if (user.UserDeactivation is not null) return new AccountDeactivated(); + } + + var session = await _sessionService.CreateSessionAsync(user.Id, loginContext.UserAgent, loginContext.Ip); + return new CreateUserLoginSessionSuccess(user, session.Token); + } + + private sealed record DiscordTokenResponse([property: JsonPropertyName("access_token")] string AccessToken); + private sealed record DiscordUserResponse([property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("username")] string Username, + [property: JsonPropertyName("email")] string? Email); + /// public async Task> CheckPasswordResetExistsAsync(Guid passwordResetId, string secret, CancellationToken cancellationToken = default) diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 52e74fa1..9246f82c 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -51,6 +51,8 @@ public interface IAccountService /// /// public Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password, LoginContext loginContext, CancellationToken cancellationToken = default); + + public Task> CreateUserLoginSessionViaDiscordAsync(string code, LoginContext loginContext, CancellationToken cancellationToken = default); /// /// Check if a password reset request exists and the secret is valid @@ -125,4 +127,5 @@ public sealed record CreateUserLoginSessionSuccess(User User, string Token); public readonly struct UsernameTaken; -public readonly struct RecentlyChanged; \ No newline at end of file +public readonly struct RecentlyChanged; +public readonly struct DiscordOAuthError; \ No newline at end of file diff --git a/API/appsettings.json b/API/appsettings.json index e9263458..e4a16e67 100644 --- a/API/appsettings.json +++ b/API/appsettings.json @@ -35,5 +35,12 @@ "FromLogContext", "WithOpenShockEnricher" ] + }, + "OpenShock": { + "Discord": { + "ClientId": "", + "ClientSecret": "", + "RedirectUri": "" + } } } diff --git a/README.md b/README.md index ed4c0969..fa150f5d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,14 @@ Preferred way is a .env file. Refer to the [Npgsql Connection String](https://www.npgsql.org/doc/connection-string-parameters.html) documentation page for details about `OPENSHOCK__DB_CONN`. Refer to [StackExchange.Redis Configuration](https://stackexchange.github.io/StackExchange.Redis/Configuration.html) documentation page for details about `OPENSHOCK__REDIS__CONN`. +### Discord OAuth + +| Variable | Required | Default value | Allowed / Example value | +|----------|----------|---------------|-------------------------| +| `OPENSHOCK__DISCORD__CLIENTID` | x | | | +| `OPENSHOCK__DISCORD__CLIENTSECRET` | x | | | +| `OPENSHOCK__DISCORD__REDIRECTURI` | x | | `https://my-openshock-instance.net/discord/callback` | + ## Turnstile When Turnstile enable is set to `true`, the following environment variable is required: From ee05a116fe96a228451cb521f36bbd121967909f Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 15:20:35 +0200 Subject: [PATCH 02/63] Copy over some OAuth logic from ZapMe --- API/API.csproj | 1 + .../{LoginDiscord.cs => OAuthAuthenticate.cs} | 27 +++++---- API/Controller/Account/OAuthListProviders.cs | 20 +++++++ .../{ => OAuth}/DiscordOAuthOptions.cs | 11 +--- API/Program.cs | 45 +++++++++++++- API/Services/Account/AccountService.cs | 60 +------------------ API/Services/Account/IAccountService.cs | 5 +- API/Utils/DistributedCacheSecureDataFormat.cs | 56 +++++++++++++++++ Common/Constants/AuthConstants.cs | 5 +- ...IAuthenticationSchemeProviderExtensions.cs | 27 +++++++++ Common/OpenShockServiceHelper.cs | 10 +++- 11 files changed, 181 insertions(+), 86 deletions(-) rename API/Controller/Account/{LoginDiscord.cs => OAuthAuthenticate.cs} (68%) create mode 100644 API/Controller/Account/OAuthListProviders.cs rename API/Options/{ => OAuth}/DiscordOAuthOptions.cs (54%) create mode 100644 API/Utils/DistributedCacheSecureDataFormat.cs create mode 100644 Common/Extensions/IAuthenticationSchemeProviderExtensions.cs diff --git a/API/API.csproj b/API/API.csproj index 71e4d7ea..79724a3e 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -3,6 +3,7 @@ + diff --git a/API/Controller/Account/LoginDiscord.cs b/API/Controller/Account/OAuthAuthenticate.cs similarity index 68% rename from API/Controller/Account/LoginDiscord.cs rename to API/Controller/Account/OAuthAuthenticate.cs index 90d62e43..389dcd01 100644 --- a/API/Controller/Account/LoginDiscord.cs +++ b/API/Controller/Account/OAuthAuthenticate.cs @@ -1,28 +1,35 @@ -using Microsoft.AspNetCore.Mvc; -using System.Net.Mime; using Asp.Versioning; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; using OpenShock.API.Models.Requests; using OpenShock.API.Models.Response; using OpenShock.API.Services.Account; using OpenShock.Common.Errors; -using OpenShock.Common.Problems; +using OpenShock.Common.Models; using OpenShock.Common.Options; +using OpenShock.Common.Problems; using OpenShock.Common.Utils; -using OpenShock.Common.Models; +using System.Net.Mime; namespace OpenShock.API.Controller.Account; public sealed partial class AccountController { - [HttpPost("login/discord")] + /// + /// Warning: This endpoint is not meant to be called by API clients, but only by the frontend. + /// SSO authentication endpoint + /// + /// Name of the SSO provider to use, supported providers can be fetched from /api/v1/sso/providers + /// Not Acceptable, the SSO provider is not supported [EnableRateLimiting("auth")] - [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status401Unauthorized, MediaTypeNames.Application.ProblemJson)] - [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] - [MapToApiVersion("2")] - public async Task LoginDiscord( + [EnableCors("allow_sso_providers")] + [HttpGet("oauth/{providerName}", Name = "InternalSsoAuthenticate")] + [HttpPost("oauth/{providerName}", Name = "InternalSsoCallback")] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status406NotAcceptable)] + public async Task OAuthAuthenticate( [FromBody] DiscordOAuth body, [FromServices] IOptions options, CancellationToken cancellationToken) diff --git a/API/Controller/Account/OAuthListProviders.cs b/API/Controller/Account/OAuthListProviders.cs new file mode 100644 index 00000000..78564c01 --- /dev/null +++ b/API/Controller/Account/OAuthListProviders.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.Common.Extensions; +using System.Net.Mime; + +namespace OpenShock.API.Controller.Account; + +public sealed partial class AccountController +{ + /// + /// Returns a list of supported SSO providers + /// + [HttpGet("oauth/providers", Name = "GetOAuthProviderlist")] + [EnableRateLimiting("auth")] + public async Task ListOAuthProviders([FromServices] IAuthenticationSchemeProvider schemesProvider) + { + return await schemesProvider.GetOAuthSchemeNamesAsync(); + } +} diff --git a/API/Options/DiscordOAuthOptions.cs b/API/Options/OAuth/DiscordOAuthOptions.cs similarity index 54% rename from API/Options/DiscordOAuthOptions.cs rename to API/Options/OAuth/DiscordOAuthOptions.cs index 5237f30a..0d4d4583 100644 --- a/API/Options/DiscordOAuthOptions.cs +++ b/API/Options/OAuth/DiscordOAuthOptions.cs @@ -1,20 +1,15 @@ using Microsoft.Extensions.Options; -using System.ComponentModel.DataAnnotations; namespace OpenShock.API.Options; public sealed class DiscordOAuthOptions { - public const string SectionName = "OpenShock:Discord"; + public const string SectionName = "OpenShock:OAuth2:Discord"; - [Required(AllowEmptyStrings = false)] public required string ClientId { get; init; } - - [Required(AllowEmptyStrings = false)] public required string ClientSecret { get; init; } - - [Required(AllowEmptyStrings = false)] - public required string RedirectUri { get; init; } + public required PathString CallbackPath { get; init; } + public required PathString AccessDeniedPath { get; init; } } [OptionsValidator] diff --git a/API/Program.cs b/API/Program.cs index 47f89dc7..08484046 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,12 +1,16 @@ +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.Connections; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; +using OpenShock.API.Options; using OpenShock.API.Realtime; using OpenShock.API.Services; using OpenShock.API.Services.Account; using OpenShock.API.Services.Email; using OpenShock.API.Services.UserService; -using OpenShock.API.Options; +using OpenShock.API.Utils; using OpenShock.Common; +using OpenShock.Common.Constants; using OpenShock.Common.DeviceControl; using OpenShock.Common.Extensions; using OpenShock.Common.Hubs; @@ -19,6 +23,7 @@ using OpenShock.Common.Services.Turnstile; using OpenShock.Common.Swagger; using Serilog; +using System.Configuration; var builder = OpenShockApplication.CreateDefaultBuilder(args); @@ -29,6 +34,7 @@ builder.Services.Configure(builder.Configuration.GetRequiredSection(FrontendOptions.SectionName)); builder.Services.AddSingleton, FrontendOptionsValidator>(); builder.Services.Configure(builder.Configuration.GetRequiredSection(DiscordOAuthOptions.SectionName)); +builder.Services.AddSingleton, DiscordOAuthOptionsValidator>(); var databaseConfig = builder.Configuration.GetDatabaseOptions(); var redisConfig = builder.Configuration.GetRedisConfigurationOptions(); @@ -37,7 +43,42 @@ builder.Services.AddOpenShockMemDB(redisConfig); builder.Services.AddOpenShockDB(databaseConfig); -builder.Services.AddOpenShockServices(); +builder.Services.AddOpenShockServices(auth => +{ + static ISecureDataFormat GetSecureDataFormat() + { + return new DistributedCacheSecureDataFormat(redisConfig, TimeSpan.FromMinutes(1)); + } + + auth.AddDiscord(AuthConstants.DiscordScheme, opt => + { + DiscordOAuthOptions discordOptions = builder.Configuration.GetRequiredSection(DiscordOAuthOptions.SectionName).Get()!; + + opt.ClientId = discordOptions.ClientId; + opt.ClientSecret = discordOptions.ClientSecret; + opt.CallbackPath = discordOptions.CallbackPath; + opt.AccessDeniedPath = discordOptions.AccessDeniedPath; + opt.Scope.Add(); + + opt.Prompt = "none"; + opt.SaveTokens = true; + opt.StateDataFormat = GetSecureDataFormat(); + opt.CorrelationCookie.HttpOnly = true; + opt.CorrelationCookie.SameSite = SameSiteMode.Lax; + opt.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always; + opt.ClaimActions.MapJsonKey(ZapMeClaimTypes.UserEmailVerified, "verified"); + opt.ClaimActions.MapCustomJson(ZapMeClaimTypes.UserAvatarUrl, json => + { + string? userId = json.GetString("id"); + string? avatar = json.GetString("avatar"); + if (String.IsNullOrEmpty(userId) || String.IsNullOrEmpty(avatar)) + return null; + + return $"https://cdn.discordapp.com/avatars/{userId}/{avatar}.png"; + }); + opt.Validate(); + }) +}); builder.Services.AddSignalR() .AddOpenShockStackExchangeRedis(options => { options.Configuration = redisConfig; }) diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index c972dd28..2e5504b8 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -30,8 +30,6 @@ public sealed class AccountService : IAccountService private readonly ISessionService _sessionService; private readonly ILogger _logger; private readonly FrontendOptions _frontendConfig; - private readonly IHttpClientFactory _httpClientFactory; - private readonly DiscordOAuthOptions _discordOptions; /// /// DI Constructor @@ -42,8 +40,7 @@ public sealed class AccountService : IAccountService /// /// public AccountService(OpenShockContext db, IEmailService emailService, - ISessionService sessionService, ILogger logger, IOptions options, - IHttpClientFactory httpClientFactory, IOptions discordOptions) + ISessionService sessionService, ILogger logger, IOptions options) { _db = db; _emailService = emailService; @@ -289,61 +286,6 @@ public async Task> CreateUserLoginSessionViaDiscordAsync(string code, LoginContext loginContext, CancellationToken cancellationToken = default) - { - var client = _httpClientFactory.CreateClient("DiscordOAuth"); - - var tokenResponse = await client.PostAsync("oauth2/token", - new FormUrlEncodedContent(new Dictionary - { - ["client_id"] = _discordOptions.ClientId, - ["client_secret"] = _discordOptions.ClientSecret, - ["grant_type"] = "authorization_code", - ["code"] = code, - ["redirect_uri"] = _discordOptions.RedirectUri - }), cancellationToken); - - if (!tokenResponse.IsSuccessStatusCode) return new DiscordOAuthError(); - - var token = await tokenResponse.Content.ReadFromJsonAsync(cancellationToken); - if (token?.AccessToken is null) return new DiscordOAuthError(); - - var userRequest = new HttpRequestMessage(HttpMethod.Get, "users/@me"); - userRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); - var userResponse = await client.SendAsync(userRequest, cancellationToken); - if (!userResponse.IsSuccessStatusCode) return new DiscordOAuthError(); - - var discordUser = await userResponse.Content.ReadFromJsonAsync(cancellationToken); - if (discordUser?.Email is null) return new DiscordOAuthError(); - - var email = discordUser.Email.ToLowerInvariant(); - var user = await _db.Users.Include(u => u.UserDeactivation).FirstOrDefaultAsync(x => x.Email == email, cancellationToken); - - if (user is null) - { - var username = discordUser.Username; - var attempt = 0; - while (await _db.Users.AnyAsync(x => x.Name == username, cancellationToken)) - { - attempt++; - username = discordUser.Username + attempt; - } - - var password = CryptoUtils.RandomString(AuthConstants.GeneratedTokenLength); - var created = await CreateAccountWithoutActivationFlowLegacyAsync(email, username, password); - if (created.IsT1) return new DiscordOAuthError(); - user = created.AsT0.Value; - } - else - { - if (user.ActivatedAt is null) return new AccountNotActivated(); - if (user.UserDeactivation is not null) return new AccountDeactivated(); - } - - var session = await _sessionService.CreateSessionAsync(user.Id, loginContext.UserAgent, loginContext.Ip); - return new CreateUserLoginSessionSuccess(user, session.Token); - } - private sealed record DiscordTokenResponse([property: JsonPropertyName("access_token")] string AccessToken); private sealed record DiscordUserResponse([property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("username")] string Username, diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 9246f82c..52e74fa1 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -51,8 +51,6 @@ public interface IAccountService /// /// public Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password, LoginContext loginContext, CancellationToken cancellationToken = default); - - public Task> CreateUserLoginSessionViaDiscordAsync(string code, LoginContext loginContext, CancellationToken cancellationToken = default); /// /// Check if a password reset request exists and the secret is valid @@ -127,5 +125,4 @@ public sealed record CreateUserLoginSessionSuccess(User User, string Token); public readonly struct UsernameTaken; -public readonly struct RecentlyChanged; -public readonly struct DiscordOAuthError; \ No newline at end of file +public readonly struct RecentlyChanged; \ No newline at end of file diff --git a/API/Utils/DistributedCacheSecureDataFormat.cs b/API/Utils/DistributedCacheSecureDataFormat.cs new file mode 100644 index 00000000..1766d2e4 --- /dev/null +++ b/API/Utils/DistributedCacheSecureDataFormat.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Caching.Distributed; +using OpenShock.Common.Utils; +using System.Text.Json; + +namespace OpenShock.API.Utils; + +public sealed class DistributedCacheSecureDataFormat : ISecureDataFormat +{ + private readonly RedisCache _redisCache; + private readonly DistributedCacheEntryOptions _entryOptions; + + public DistributedCacheSecureDataFormat(string connectionString, TimeSpan secretLifeSpan) + { + _redisCache = new RedisCache(new RedisCacheOptions + { + Configuration = connectionString + }); + _entryOptions = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = secretLifeSpan + }; + } + public string Protect(T data) + { + string key = CryptoUtils.RandomString(32); + _redisCache.Set(key, JsonSerializer.SerializeToUtf8Bytes(data), _entryOptions); + return key; + } + + public string Protect(T data, string? purpose) + { + return Protect(data); + } + + public T? Unprotect(string? protectedText) + { + if (protectedText is null) + { + return default; + } + + byte[]? bytes = _redisCache.Get(protectedText); + if (bytes is null) + { + return default; + } + + return JsonSerializer.Deserialize(bytes); + } + + public T? Unprotect(string? protectedText, string? purpose) + { + return Unprotect(protectedText); + } +} \ No newline at end of file diff --git a/Common/Constants/AuthConstants.cs b/Common/Constants/AuthConstants.cs index 3f1153d5..d739e1c2 100644 --- a/Common/Constants/AuthConstants.cs +++ b/Common/Constants/AuthConstants.cs @@ -6,7 +6,10 @@ public static class AuthConstants public const string UserSessionHeaderName = "OpenShockSession"; public const string ApiTokenHeaderName = "OpenShockToken"; public const string HubTokenHeaderName = "DeviceToken"; - + + public const string DiscordScheme = "discord"; + public static readonly string[] OAuth2Schemes = [DiscordScheme]; + public const int GeneratedTokenLength = 32; public const int ApiTokenLength = 64; } diff --git a/Common/Extensions/IAuthenticationSchemeProviderExtensions.cs b/Common/Extensions/IAuthenticationSchemeProviderExtensions.cs new file mode 100644 index 00000000..26539f09 --- /dev/null +++ b/Common/Extensions/IAuthenticationSchemeProviderExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Authentication; +using OpenShock.Common.Constants; +using System.Linq; + +namespace OpenShock.Common.Extensions; + +public static class IAuthenticationSchemeProviderExtensions +{ + public static async Task GetOAuthSchemeNamesAsync(this IAuthenticationSchemeProvider provider) + { + var allSchemes = await provider.GetAllSchemesAsync(); + + return allSchemes + .Where(scheme => AuthConstants.OAuth2Schemes.Contains(scheme.Name)) + .Select(scheme => scheme.Name) + .ToArray(); + } + public static async Task IsSupportedOAuthProviderAsync(this IAuthenticationSchemeProvider provider, string scheme) + { + foreach (var supportedScheme in await provider.GetOAuthSchemeNamesAsync()) + { + if (string.Equals(scheme, supportedScheme, StringComparison.InvariantCultureIgnoreCase)) return true; + } + + return false; + } +} diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index 03dd11ad..4e379b82 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -106,8 +106,9 @@ public static IServiceCollection AddOpenShockDB(this IServiceCollection services /// Register all OpenShock services for PRODUCTION use /// /// + /// /// - public static IServiceCollection AddOpenShockServices(this IServiceCollection services) + public static IServiceCollection AddOpenShockServices(this IServiceCollection services, Action? configureAuth = null) { // <---- ASP.NET ----> services.AddExceptionHandler(); @@ -128,13 +129,18 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se services.AddScoped(); services.AddAuthenticationCore(); - new AuthenticationBuilder(services) + var authbuilder = new AuthenticationBuilder(services) .AddScheme( OpenShockAuthSchemas.UserSessionCookie, _ => { }) .AddScheme( OpenShockAuthSchemas.ApiToken, _ => { }) .AddScheme( OpenShockAuthSchemas.HubToken, _ => { }); + + if (configureAuth is not null) + { + configureAuth(authbuilder); + } services.AddAuthorization(options => { From 259b0e13022d6fe6e1825c9b1576605aea35876b Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 15:21:00 +0200 Subject: [PATCH 03/63] Update OAuthAuthenticate.cs --- API/Controller/Account/OAuthAuthenticate.cs | 31 +++++---------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/API/Controller/Account/OAuthAuthenticate.cs b/API/Controller/Account/OAuthAuthenticate.cs index 389dcd01..afaa50d5 100644 --- a/API/Controller/Account/OAuthAuthenticate.cs +++ b/API/Controller/Account/OAuthAuthenticate.cs @@ -1,4 +1,5 @@ using Asp.Versioning; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; @@ -6,7 +7,9 @@ using OpenShock.API.Models.Requests; using OpenShock.API.Models.Response; using OpenShock.API.Services.Account; +using OpenShock.Common.Constants; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; using OpenShock.Common.Models; using OpenShock.Common.Options; using OpenShock.Common.Problems; @@ -22,6 +25,7 @@ public sealed partial class AccountController /// SSO authentication endpoint /// /// Name of the SSO provider to use, supported providers can be fetched from /api/v1/sso/providers + /// /// Not Acceptable, the SSO provider is not supported [EnableRateLimiting("auth")] [EnableCors("allow_sso_providers")] @@ -29,31 +33,10 @@ public sealed partial class AccountController [HttpPost("oauth/{providerName}", Name = "InternalSsoCallback")] [ProducesResponseType(StatusCodes.Status302Found)] [ProducesResponseType(StatusCodes.Status406NotAcceptable)] - public async Task OAuthAuthenticate( - [FromBody] DiscordOAuth body, - [FromServices] IOptions options, - CancellationToken cancellationToken) + public async Task OAuthAuthenticate([FromRoute] string providerName, [FromServices] IAuthenticationSchemeProvider schemesProvider) { - var cookieDomainToUse = options.Value.CookieDomain.Split(',').FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase)); - if (cookieDomainToUse is null) return Problem(LoginError.InvalidDomain); + if (!await schemesProvider.IsSupportedOAuthProviderAsync(providerName)) return HttpErrors.UnsupportedSSOProvider(providerName).ToActionResult(); - var remoteIP = HttpContext.GetRemoteIP(); - - var loginAction = await _accountService.CreateUserLoginSessionViaDiscordAsync(body.Code, new LoginContext - { - Ip = remoteIP.ToString(), - UserAgent = HttpContext.GetUserAgent(), - }, cancellationToken); - - return loginAction.Match( - ok => - { - HttpContext.SetSessionKeyCookie(ok.Token, "." + cookieDomainToUse); - return Ok(LoginV2OkResponse.FromUser(ok.User)); - }, - notActivated => Problem(AccountError.AccountNotActivated), - deactivated => Problem(AccountError.AccountDeactivated), - _ => Problem(LoginError.InvalidCredentials) - ); + return Challenge(providerName); } } From 7fcdfae88a1c9b2da28b837734c5c79a7afbf8ce Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 15:21:55 +0200 Subject: [PATCH 04/63] More cleanup --- API/Program.cs | 2 +- API/Services/Account/AccountService.cs | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/API/Program.cs b/API/Program.cs index 08484046..7ea62d62 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -77,7 +77,7 @@ static ISecureDataFormat GetSecureDataFormat() return $"https://cdn.discordapp.com/avatars/{userId}/{avatar}.png"; }); opt.Validate(); - }) + }); }); builder.Services.AddSignalR() diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 2e5504b8..3430e41b 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -1,15 +1,11 @@ using System.Net.Mail; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Net.Http.Json; using System.Text.Json.Serialization; using OneOf; using OneOf.Types; using OpenShock.API.Services.Email; using OpenShock.API.Services.Email.Mailjet.Mail; -using OpenShock.API.Options; using OpenShock.Common.Constants; using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; @@ -47,8 +43,6 @@ public AccountService(OpenShockContext db, IEmailService emailService, _logger = logger; _frontendConfig = options.Value; _sessionService = sessionService; - _httpClientFactory = httpClientFactory; - _discordOptions = discordOptions.Value; } private async Task IsUserNameBlacklisted(string username) From ee063810908ce02853acf7aa10d3dbdb573b0a73 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 16:51:25 +0200 Subject: [PATCH 05/63] Move some stuff around --- API/Options/OAuth/DiscordOAuthOptions.cs | 11 ++--------- API/Program.cs | 2 ++ Common/Authentication/OpenShockAuthSchemas.cs | 3 +++ Common/Constants/AuthConstants.cs | 3 --- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/API/Options/OAuth/DiscordOAuthOptions.cs b/API/Options/OAuth/DiscordOAuthOptions.cs index 0d4d4583..41723d0c 100644 --- a/API/Options/OAuth/DiscordOAuthOptions.cs +++ b/API/Options/OAuth/DiscordOAuthOptions.cs @@ -1,6 +1,4 @@ -using Microsoft.Extensions.Options; - -namespace OpenShock.API.Options; +namespace OpenShock.API.Options.OAuth; public sealed class DiscordOAuthOptions { @@ -10,9 +8,4 @@ public sealed class DiscordOAuthOptions public required string ClientSecret { get; init; } public required PathString CallbackPath { get; init; } public required PathString AccessDeniedPath { get; init; } -} - -[OptionsValidator] -public partial class DiscordOAuthOptionsValidator : IValidateOptions -{ -} +} \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 7ea62d62..a5cd077c 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -24,6 +24,8 @@ using OpenShock.Common.Swagger; using Serilog; using System.Configuration; +using OpenShock.API.Options.OAuth; +using DiscordOAuthOptionsValidator = OpenShock.API.Options.OAuth.DiscordOAuthOptionsValidator; var builder = OpenShockApplication.CreateDefaultBuilder(args); diff --git a/Common/Authentication/OpenShockAuthSchemas.cs b/Common/Authentication/OpenShockAuthSchemas.cs index 87ebda95..225fc387 100644 --- a/Common/Authentication/OpenShockAuthSchemas.cs +++ b/Common/Authentication/OpenShockAuthSchemas.cs @@ -7,4 +7,7 @@ public static class OpenShockAuthSchemas public const string HubToken = "HubToken"; public const string UserSessionApiTokenCombo = $"{UserSessionCookie},{ApiToken}"; + + public const string DiscordScheme = "discord"; + public static readonly string[] OAuth2Schemes = [DiscordScheme]; } \ No newline at end of file diff --git a/Common/Constants/AuthConstants.cs b/Common/Constants/AuthConstants.cs index d739e1c2..2f10b1a2 100644 --- a/Common/Constants/AuthConstants.cs +++ b/Common/Constants/AuthConstants.cs @@ -7,9 +7,6 @@ public static class AuthConstants public const string ApiTokenHeaderName = "OpenShockToken"; public const string HubTokenHeaderName = "DeviceToken"; - public const string DiscordScheme = "discord"; - public static readonly string[] OAuth2Schemes = [DiscordScheme]; - public const int GeneratedTokenLength = 32; public const int ApiTokenLength = 64; } From 4da7889a3c5ab1a021c21253d07cb1381fede8c5 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 17:16:31 +0200 Subject: [PATCH 06/63] Update IAuthenticationSchemeProviderExtensions.cs --- Common/Extensions/IAuthenticationSchemeProviderExtensions.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Common/Extensions/IAuthenticationSchemeProviderExtensions.cs b/Common/Extensions/IAuthenticationSchemeProviderExtensions.cs index 26539f09..633ad090 100644 --- a/Common/Extensions/IAuthenticationSchemeProviderExtensions.cs +++ b/Common/Extensions/IAuthenticationSchemeProviderExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication; using OpenShock.Common.Constants; using System.Linq; +using OpenShock.Common.Authentication; namespace OpenShock.Common.Extensions; @@ -11,7 +12,7 @@ public static async Task GetOAuthSchemeNamesAsync(this IAuthentication var allSchemes = await provider.GetAllSchemesAsync(); return allSchemes - .Where(scheme => AuthConstants.OAuth2Schemes.Contains(scheme.Name)) + .Where(scheme => OpenShockAuthSchemes.OAuth2Schemes.Contains(scheme.Name)) .Select(scheme => scheme.Name) .ToArray(); } From 2770175edaffd342dd5ad477d6097e7ab527add5 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 17:38:13 +0200 Subject: [PATCH 07/63] Some more cleanup --- API/Controller/Account/OAuthAuthenticate.cs | 42 -------------------- API/Controller/Account/OAuthCallback.cs | 20 ++++++++++ API/Controller/Account/OAuthListProviders.cs | 1 - API/Controller/Account/OAuthStart.cs | 20 ++++++++++ Common/Errors/OAuthError.cs | 10 +++++ 5 files changed, 50 insertions(+), 43 deletions(-) delete mode 100644 API/Controller/Account/OAuthAuthenticate.cs create mode 100644 API/Controller/Account/OAuthCallback.cs create mode 100644 API/Controller/Account/OAuthStart.cs create mode 100644 Common/Errors/OAuthError.cs diff --git a/API/Controller/Account/OAuthAuthenticate.cs b/API/Controller/Account/OAuthAuthenticate.cs deleted file mode 100644 index afaa50d5..00000000 --- a/API/Controller/Account/OAuthAuthenticate.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Asp.Versioning; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Cors; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.RateLimiting; -using Microsoft.Extensions.Options; -using OpenShock.API.Models.Requests; -using OpenShock.API.Models.Response; -using OpenShock.API.Services.Account; -using OpenShock.Common.Constants; -using OpenShock.Common.Errors; -using OpenShock.Common.Extensions; -using OpenShock.Common.Models; -using OpenShock.Common.Options; -using OpenShock.Common.Problems; -using OpenShock.Common.Utils; -using System.Net.Mime; - -namespace OpenShock.API.Controller.Account; - -public sealed partial class AccountController -{ - /// - /// Warning: This endpoint is not meant to be called by API clients, but only by the frontend. - /// SSO authentication endpoint - /// - /// Name of the SSO provider to use, supported providers can be fetched from /api/v1/sso/providers - /// - /// Not Acceptable, the SSO provider is not supported - [EnableRateLimiting("auth")] - [EnableCors("allow_sso_providers")] - [HttpGet("oauth/{providerName}", Name = "InternalSsoAuthenticate")] - [HttpPost("oauth/{providerName}", Name = "InternalSsoCallback")] - [ProducesResponseType(StatusCodes.Status302Found)] - [ProducesResponseType(StatusCodes.Status406NotAcceptable)] - public async Task OAuthAuthenticate([FromRoute] string providerName, [FromServices] IAuthenticationSchemeProvider schemesProvider) - { - if (!await schemesProvider.IsSupportedOAuthProviderAsync(providerName)) return HttpErrors.UnsupportedSSOProvider(providerName).ToActionResult(); - - return Challenge(providerName); - } -} diff --git a/API/Controller/Account/OAuthCallback.cs b/API/Controller/Account/OAuthCallback.cs new file mode 100644 index 00000000..5cb6a3f5 --- /dev/null +++ b/API/Controller/Account/OAuthCallback.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; + +namespace OpenShock.API.Controller.Account; + +public sealed partial class AccountController +{ + [HttpGet("oauth/callback/{provider}")] + [EnableCors("allow_sso_providers")] + public async Task OAuthAuthenticate([FromRoute] string provider, [FromQuery] string code, [FromServices] IAuthenticationSchemeProvider schemesProvider) + { + if (!await schemesProvider.IsSupportedOAuthProviderAsync(provider)) + return Problem(OAuthError.ProviderNotSupported); + + return Challenge(provider); + } +} diff --git a/API/Controller/Account/OAuthListProviders.cs b/API/Controller/Account/OAuthListProviders.cs index 78564c01..048468f6 100644 --- a/API/Controller/Account/OAuthListProviders.cs +++ b/API/Controller/Account/OAuthListProviders.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using OpenShock.Common.Extensions; -using System.Net.Mime; namespace OpenShock.API.Controller.Account; diff --git a/API/Controller/Account/OAuthStart.cs b/API/Controller/Account/OAuthStart.cs new file mode 100644 index 00000000..0fe57a68 --- /dev/null +++ b/API/Controller/Account/OAuthStart.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; + +namespace OpenShock.API.Controller.Account; + +public sealed partial class AccountController +{ + [EnableRateLimiting("auth")] + [HttpGet("oauth/start", Name = "InternalSsoAuthenticate")] + public async Task OAuthAuthenticate([FromQuery] string provider, [FromServices] IAuthenticationSchemeProvider schemesProvider) + { + if (!await schemesProvider.IsSupportedOAuthProviderAsync(provider)) + return Problem(OAuthError.ProviderNotSupported); + + return Challenge(provider); + } +} diff --git a/Common/Errors/OAuthError.cs b/Common/Errors/OAuthError.cs new file mode 100644 index 00000000..6c5a6f0e --- /dev/null +++ b/Common/Errors/OAuthError.cs @@ -0,0 +1,10 @@ +using System.Net; +using OpenShock.Common.Problems; + +namespace OpenShock.Common.Errors; + +public static class OAuthError +{ + public static OpenShockProblem ProviderNotSupported => new OpenShockProblem( + "OAuth.Provider.NotSupported", "This OAuth provider is not supported", HttpStatusCode.Forbidden); +} \ No newline at end of file From d4860dd88bfe658f1ce450ac9d5cf238f7f6baac Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 17:44:02 +0200 Subject: [PATCH 08/63] Remove Microsoft.Authentication base OAuth handler --- API/Program.cs | 38 +------------------------------- Common/OpenShockServiceHelper.cs | 9 ++------ 2 files changed, 3 insertions(+), 44 deletions(-) diff --git a/API/Program.cs b/API/Program.cs index a5cd077c..0e0e1960 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -36,7 +36,6 @@ builder.Services.Configure(builder.Configuration.GetRequiredSection(FrontendOptions.SectionName)); builder.Services.AddSingleton, FrontendOptionsValidator>(); builder.Services.Configure(builder.Configuration.GetRequiredSection(DiscordOAuthOptions.SectionName)); -builder.Services.AddSingleton, DiscordOAuthOptionsValidator>(); var databaseConfig = builder.Configuration.GetDatabaseOptions(); var redisConfig = builder.Configuration.GetRedisConfigurationOptions(); @@ -45,42 +44,7 @@ builder.Services.AddOpenShockMemDB(redisConfig); builder.Services.AddOpenShockDB(databaseConfig); -builder.Services.AddOpenShockServices(auth => -{ - static ISecureDataFormat GetSecureDataFormat() - { - return new DistributedCacheSecureDataFormat(redisConfig, TimeSpan.FromMinutes(1)); - } - - auth.AddDiscord(AuthConstants.DiscordScheme, opt => - { - DiscordOAuthOptions discordOptions = builder.Configuration.GetRequiredSection(DiscordOAuthOptions.SectionName).Get()!; - - opt.ClientId = discordOptions.ClientId; - opt.ClientSecret = discordOptions.ClientSecret; - opt.CallbackPath = discordOptions.CallbackPath; - opt.AccessDeniedPath = discordOptions.AccessDeniedPath; - opt.Scope.Add(); - - opt.Prompt = "none"; - opt.SaveTokens = true; - opt.StateDataFormat = GetSecureDataFormat(); - opt.CorrelationCookie.HttpOnly = true; - opt.CorrelationCookie.SameSite = SameSiteMode.Lax; - opt.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always; - opt.ClaimActions.MapJsonKey(ZapMeClaimTypes.UserEmailVerified, "verified"); - opt.ClaimActions.MapCustomJson(ZapMeClaimTypes.UserAvatarUrl, json => - { - string? userId = json.GetString("id"); - string? avatar = json.GetString("avatar"); - if (String.IsNullOrEmpty(userId) || String.IsNullOrEmpty(avatar)) - return null; - - return $"https://cdn.discordapp.com/avatars/{userId}/{avatar}.png"; - }); - opt.Validate(); - }); -}); +builder.Services.AddOpenShockServices(); builder.Services.AddSignalR() .AddOpenShockStackExchangeRedis(options => { options.Configuration = redisConfig; }) diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index 203a30e3..bf247758 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -108,7 +108,7 @@ public static IServiceCollection AddOpenShockDB(this IServiceCollection services /// /// /// - public static IServiceCollection AddOpenShockServices(this IServiceCollection services, Action? configureAuth = null) + public static IServiceCollection AddOpenShockServices(this IServiceCollection services) { // <---- ASP.NET ----> services.AddExceptionHandler(); @@ -129,18 +129,13 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se services.AddScoped(); services.AddAuthenticationCore(); - var authbuilder = new AuthenticationBuilder(services) + new AuthenticationBuilder(services) .AddScheme( OpenShockAuthSchemes.UserSessionCookie, _ => { }) .AddScheme( OpenShockAuthSchemes.ApiToken, _ => { }) .AddScheme( OpenShockAuthSchemes.HubToken, _ => { }); - - if (configureAuth is not null) - { - configureAuth(authbuilder); - } services.AddAuthorization(options => { From 70eff5687c6b830e45465f6448b032dfe1f3606b Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 17:46:33 +0200 Subject: [PATCH 09/63] Update API.csproj --- API/API.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/API/API.csproj b/API/API.csproj index 79724a3e..71e4d7ea 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -3,7 +3,6 @@ - From 6f2f4d0486771ed257f0ae45ade672abe0b14b6b Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 17:46:58 +0200 Subject: [PATCH 10/63] Clean up imports --- API/Program.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/API/Program.cs b/API/Program.cs index 0e0e1960..1e72d296 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,16 +1,11 @@ -using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.Connections; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; -using OpenShock.API.Options; using OpenShock.API.Realtime; using OpenShock.API.Services; using OpenShock.API.Services.Account; using OpenShock.API.Services.Email; using OpenShock.API.Services.UserService; -using OpenShock.API.Utils; using OpenShock.Common; -using OpenShock.Common.Constants; using OpenShock.Common.DeviceControl; using OpenShock.Common.Extensions; using OpenShock.Common.Hubs; @@ -23,9 +18,7 @@ using OpenShock.Common.Services.Turnstile; using OpenShock.Common.Swagger; using Serilog; -using System.Configuration; using OpenShock.API.Options.OAuth; -using DiscordOAuthOptionsValidator = OpenShock.API.Options.OAuth.DiscordOAuthOptionsValidator; var builder = OpenShockApplication.CreateDefaultBuilder(args); From 352ac96842fc67a89a083f00c38e731f255f394e Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 17:58:39 +0200 Subject: [PATCH 11/63] More cleanup --- API/Controller/Account/OAuthCallback.cs | 13 +++-- API/Controller/Account/OAuthListProviders.cs | 9 ++- API/Controller/Account/OAuthStart.cs | 9 ++- API/Utils/DistributedCacheSecureDataFormat.cs | 56 ------------------- Common/OpenShockServiceHelper.cs | 1 - 5 files changed, 15 insertions(+), 73 deletions(-) delete mode 100644 API/Utils/DistributedCacheSecureDataFormat.cs diff --git a/API/Controller/Account/OAuthCallback.cs b/API/Controller/Account/OAuthCallback.cs index 5cb6a3f5..774ee18c 100644 --- a/API/Controller/Account/OAuthCallback.cs +++ b/API/Controller/Account/OAuthCallback.cs @@ -1,20 +1,21 @@ -using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.Common.Authentication; using OpenShock.Common.Errors; -using OpenShock.Common.Extensions; namespace OpenShock.API.Controller.Account; public sealed partial class AccountController { + [EnableRateLimiting("auth")] [HttpGet("oauth/callback/{provider}")] [EnableCors("allow_sso_providers")] - public async Task OAuthAuthenticate([FromRoute] string provider, [FromQuery] string code, [FromServices] IAuthenticationSchemeProvider schemesProvider) + public async Task OAuthAuthenticate([FromRoute] string provider) { - if (!await schemesProvider.IsSupportedOAuthProviderAsync(provider)) + if (!OpenShockAuthSchemes.OAuth2Schemes.Contains(provider)) return Problem(OAuthError.ProviderNotSupported); - - return Challenge(provider); + + // TODO: Validate OAuth response and fetch user details to create/authenticate account } } diff --git a/API/Controller/Account/OAuthListProviders.cs b/API/Controller/Account/OAuthListProviders.cs index 048468f6..827dcc91 100644 --- a/API/Controller/Account/OAuthListProviders.cs +++ b/API/Controller/Account/OAuthListProviders.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using OpenShock.Common.Extensions; +using OpenShock.Common.Authentication; namespace OpenShock.API.Controller.Account; @@ -10,10 +10,9 @@ public sealed partial class AccountController /// /// Returns a list of supported SSO providers /// - [HttpGet("oauth/providers", Name = "GetOAuthProviderlist")] - [EnableRateLimiting("auth")] - public async Task ListOAuthProviders([FromServices] IAuthenticationSchemeProvider schemesProvider) + [HttpGet("oauth/providers")] + public string[] ListOAuthProviders() { - return await schemesProvider.GetOAuthSchemeNamesAsync(); + return OpenShockAuthSchemes.OAuth2Schemes; } } diff --git a/API/Controller/Account/OAuthStart.cs b/API/Controller/Account/OAuthStart.cs index 0fe57a68..04ec6798 100644 --- a/API/Controller/Account/OAuthStart.cs +++ b/API/Controller/Account/OAuthStart.cs @@ -1,8 +1,7 @@ -using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; +using OpenShock.Common.Authentication; using OpenShock.Common.Errors; -using OpenShock.Common.Extensions; namespace OpenShock.API.Controller.Account; @@ -10,11 +9,11 @@ public sealed partial class AccountController { [EnableRateLimiting("auth")] [HttpGet("oauth/start", Name = "InternalSsoAuthenticate")] - public async Task OAuthAuthenticate([FromQuery] string provider, [FromServices] IAuthenticationSchemeProvider schemesProvider) + public async Task OAuthAuthenticate([FromQuery] string provider, [FromQuery] Uri? redirectUrl) { - if (!await schemesProvider.IsSupportedOAuthProviderAsync(provider)) + if (!OpenShockAuthSchemes.OAuth2Schemes.Contains(provider)) return Problem(OAuthError.ProviderNotSupported); - return Challenge(provider); + // TODO: Generate the provider's OAuth URL } } diff --git a/API/Utils/DistributedCacheSecureDataFormat.cs b/API/Utils/DistributedCacheSecureDataFormat.cs deleted file mode 100644 index 1766d2e4..00000000 --- a/API/Utils/DistributedCacheSecureDataFormat.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Caching.Distributed; -using OpenShock.Common.Utils; -using System.Text.Json; - -namespace OpenShock.API.Utils; - -public sealed class DistributedCacheSecureDataFormat : ISecureDataFormat -{ - private readonly RedisCache _redisCache; - private readonly DistributedCacheEntryOptions _entryOptions; - - public DistributedCacheSecureDataFormat(string connectionString, TimeSpan secretLifeSpan) - { - _redisCache = new RedisCache(new RedisCacheOptions - { - Configuration = connectionString - }); - _entryOptions = new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = secretLifeSpan - }; - } - public string Protect(T data) - { - string key = CryptoUtils.RandomString(32); - _redisCache.Set(key, JsonSerializer.SerializeToUtf8Bytes(data), _entryOptions); - return key; - } - - public string Protect(T data, string? purpose) - { - return Protect(data); - } - - public T? Unprotect(string? protectedText) - { - if (protectedText is null) - { - return default; - } - - byte[]? bytes = _redisCache.Get(protectedText); - if (bytes is null) - { - return default; - } - - return JsonSerializer.Deserialize(bytes); - } - - public T? Unprotect(string? protectedText, string? purpose) - { - return Unprotect(protectedText); - } -} \ No newline at end of file diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index bf247758..a09153d0 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -106,7 +106,6 @@ public static IServiceCollection AddOpenShockDB(this IServiceCollection services /// Register all OpenShock services for PRODUCTION use /// /// - /// /// public static IServiceCollection AddOpenShockServices(this IServiceCollection services) { From 57378bd022cd29adc52b2611c76fc88f19e8caff Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 18:01:30 +0200 Subject: [PATCH 12/63] More cleanup --- API/Models/Requests/DiscordOAuth.cs | 9 --------- API/Services/Account/AccountService.cs | 6 ------ 2 files changed, 15 deletions(-) delete mode 100644 API/Models/Requests/DiscordOAuth.cs diff --git a/API/Models/Requests/DiscordOAuth.cs b/API/Models/Requests/DiscordOAuth.cs deleted file mode 100644 index d3e1c995..00000000 --- a/API/Models/Requests/DiscordOAuth.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace OpenShock.API.Models.Requests; - -public sealed class DiscordOAuth -{ - [Required] - public required string Code { get; init; } -} diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 3430e41b..9bd816f9 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -1,7 +1,6 @@ using System.Net.Mail; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; -using System.Text.Json.Serialization; using OneOf; using OneOf.Types; using OpenShock.API.Services.Email; @@ -280,11 +279,6 @@ public async Task public async Task> CheckPasswordResetExistsAsync(Guid passwordResetId, string secret, CancellationToken cancellationToken = default) From acc56e00fc710251598f56657de893ed24869841 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 20:01:21 +0200 Subject: [PATCH 13/63] VERY basic implementation --- API/Controller/Account/OAuthCallback.cs | 147 +++++++++++++++++++++++- API/Controller/Account/OAuthStart.cs | 70 ++++++++++- Common/OpenShockDb/OAuthConnection.cs | 17 +++ Common/OpenShockDb/OpenShockContext.cs | 28 +++++ Common/OpenShockDb/User.cs | 1 + 5 files changed, 256 insertions(+), 7 deletions(-) create mode 100644 Common/OpenShockDb/OAuthConnection.cs diff --git a/API/Controller/Account/OAuthCallback.cs b/API/Controller/Account/OAuthCallback.cs index 774ee18c..4e806fc2 100644 --- a/API/Controller/Account/OAuthCallback.cs +++ b/API/Controller/Account/OAuthCallback.cs @@ -1,6 +1,10 @@ +using System.Net.Http.Headers; +using System.Text.Json; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Options; +using OpenShock.API.Options.OAuth; using OpenShock.Common.Authentication; using OpenShock.Common.Errors; @@ -8,14 +12,149 @@ namespace OpenShock.API.Controller.Account; public sealed partial class AccountController { + private const string DiscordApiBase = "https://api.openshock.app"; // TODO: move to config + private const string DefaultReturn = "https://app.openshock.app/auth/callback/discord"; // TODO: move to config + + // Very small DTOs for Discord responses + private sealed class DiscordTokenResponse + { + public string access_token { get; set; } = default!; + public string token_type { get; set; } = default!; + public int expires_in { get; set; } + public string? refresh_token { get; set; } + public string? scope { get; set; } + } + + private sealed class DiscordUser + { + public string id { get; set; } = default!; + public string username { get; set; } = default!; + public string discriminator { get; set; } = "0"; + public string? global_name { get; set; } + public string? avatar { get; set; } + } + [EnableRateLimiting("auth")] [HttpGet("oauth/callback/{provider}")] [EnableCors("allow_sso_providers")] - public async Task OAuthAuthenticate([FromRoute] string provider) + public async Task OAuthAuthenticate( + [FromRoute] string provider, + [FromServices] IHttpClientFactory httpClientFactory, + [FromServices] IOptions discordOptionsSnap) { if (!OpenShockAuthSchemes.OAuth2Schemes.Contains(provider)) return Problem(OAuthError.ProviderNotSupported); - - // TODO: Validate OAuth response and fetch user details to create/authenticate account + + if (!string.Equals(provider, "discord", StringComparison.OrdinalIgnoreCase)) // temporary + return Problem(OAuthError.ProviderNotSupported); + + // Read query values dynamically (only code & state are expected for Discord) + var code = Request.Query["code"].ToString(); + var state = Request.Query["state"].ToString(); + + if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state)) + return BadRequest("Missing 'code' or 'state'."); + + // Read & clear state cookie + if (!Request.Cookies.TryGetValue(StateCookieName, out var cookieVal)) + return BadRequest("Missing state cookie."); + + // cookie format from /start: "|" + string cookieState; + string? rawReturnTo = null; + var pipeIdx = cookieVal.IndexOf('|'); + if (pipeIdx >= 0) + { + cookieState = cookieVal[..pipeIdx]; + rawReturnTo = cookieVal[(pipeIdx + 1)..]; + if (string.IsNullOrWhiteSpace(rawReturnTo)) rawReturnTo = null; + } + else + { + cookieState = cookieVal; + } + + Response.Cookies.Delete(StateCookieName, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + Path = "/" + }); + + if (!string.Equals(state, cookieState, StringComparison.Ordinal)) + return BadRequest("Invalid state."); + + // Exchange authorization code for tokens + var discordOptions = discordOptionsSnap.Value; + var callbackUri = new Uri(new Uri(DiscordApiBase), "/1/account/oauth/callback/discord"); + + DiscordTokenResponse? token; + var client = httpClientFactory.CreateClient(); + using (var tokenReq = new HttpRequestMessage(HttpMethod.Post, "https://discord.com/api/oauth2/token")) + { + tokenReq.Content = new FormUrlEncodedContent(new Dictionary + { + ["client_id"] = discordOptions.ClientId, + ["client_secret"] = discordOptions.ClientSecret, + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = callbackUri.ToString() + }); + + using var tokenRes = await client.SendAsync(tokenReq); + if (!tokenRes.IsSuccessStatusCode) + return BadRequest($"Token exchange failed ({(int)tokenRes.StatusCode})."); + + token = JsonSerializer.Deserialize( + await tokenRes.Content.ReadAsStringAsync(), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + + if (token?.access_token is null) + return BadRequest("No access token from provider."); + + // Fetch Discord user + using var meReq = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/api/users/@me"); + meReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.access_token); + using var meRes = await client.SendAsync(meReq); + if (!meRes.IsSuccessStatusCode) + return BadRequest($"Failed to fetch user profile ({(int)meRes.StatusCode})."); + + var user = JsonSerializer.Deserialize( + await meRes.Content.ReadAsStringAsync(), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (user is null) + return BadRequest("Invalid user payload from provider."); + + // TODO: Link/auth the account (by user.id), create session/JWT, set your own auth cookie/header here. + + // Where to redirect next (keep whitelisting off until you add it) + + // If/when you add whitelisting: + // if (Uri.TryCreate(rawReturnTo, UriKind.Absolute, out var rt) && IsAllowedReturnUrl(rt, discordOptions.AllowedReturnHosts)) + // redirectTarget = rt.ToString(); + + return Redirect(DefaultReturn); + } + + // If/when you enable return_to, keep a tiny allow-list like: + /* + private static bool IsAllowedReturnUrl(Uri url, IEnumerable allowedHosts) + { + if (!string.Equals(url.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + return false; + + var host = url.Host; + foreach (var allowed in allowedHosts) + { + if (string.Equals(host, allowed, StringComparison.OrdinalIgnoreCase)) + return true; + if (host.EndsWith("." + allowed, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; } -} + */ +} \ No newline at end of file diff --git a/API/Controller/Account/OAuthStart.cs b/API/Controller/Account/OAuthStart.cs index 04ec6798..cd2d124d 100644 --- a/API/Controller/Account/OAuthStart.cs +++ b/API/Controller/Account/OAuthStart.cs @@ -1,19 +1,83 @@ +using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Options; +using OpenShock.API.Options.OAuth; using OpenShock.Common.Authentication; using OpenShock.Common.Errors; +using OpenShock.Common.Utils; namespace OpenShock.API.Controller.Account; public sealed partial class AccountController { + // Cookie names + private const string StateCookieName = "__openshock_oauth_state"; + [EnableRateLimiting("auth")] [HttpGet("oauth/start", Name = "InternalSsoAuthenticate")] - public async Task OAuthAuthenticate([FromQuery] string provider, [FromQuery] Uri? redirectUrl) + public IActionResult OAuthAuthenticate( + [FromQuery] string provider, + [FromQuery(Name = "return_to")] Uri? returnTo, + [FromServices] IOptions discordOptions) { if (!OpenShockAuthSchemes.OAuth2Schemes.Contains(provider)) return Problem(OAuthError.ProviderNotSupported); - // TODO: Generate the provider's OAuth URL + // Normalize provider + provider = provider.ToLowerInvariant(); + if (provider is not "discord") + return Problem(OAuthError.ProviderNotSupported); // TODO: KEEPME Temporary solution + + // Only Discord for now + var options = discordOptions.Value; + + // Build your absolute callback (don’t hardcode) + // e.g., options: CallbackBase = "https://api.openshock.app" + var callbackUri = new Uri(new Uri(DiscordApiBase), "/1/account/oauth/callback/discord"); + + // TODO: DONTIMPLEMENTYET Optional post-login returnUrl + /* + string? safeReturnUrl = null; + if (returnTo is not null && IsAllowedReturnUrl(returnTo, discordOptions.AllowedReturnHosts)) + { + safeReturnUrl = returnTo.ToString(); + } + */ + + // CSRF state (random nonce) + var cookieContents = $"{CryptoUtils.RandomString(64)}|{returnTo}"; + var stateKeyHash = HashingUtils.HashSha256(cookieContents); + + // Persist cookies (HttpOnly, Secure, SameSite=Lax works for top-level redirects) + SetTempCookie(StateCookieName, cookieContents); + + // Build Discord authorization URL + var authUrl = new UriBuilder("https://discord.com/oauth2/authorize") + { + Query = new QueryBuilder + { + { "response_type", "code" }, + { "client_id", options.ClientId }, + { "scope", "identify" }, + { "redirect_uri", callbackUri.ToString() }, + { "state", stateKeyHash }, + }.ToString() + }.Uri.ToString(); + + return Redirect(authUrl); + } + + private void SetTempCookie(string name, string value) + { + Response.Cookies.Append(name, value, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + IsEssential = true, + Expires = DateTimeOffset.UtcNow.AddMinutes(10), // 10 minutes is plenty for a round-trip + Path = "/" + }); } -} +} \ No newline at end of file diff --git a/Common/OpenShockDb/OAuthConnection.cs b/Common/OpenShockDb/OAuthConnection.cs new file mode 100644 index 00000000..7ff0a642 --- /dev/null +++ b/Common/OpenShockDb/OAuthConnection.cs @@ -0,0 +1,17 @@ +namespace OpenShock.Common.OpenShockDb; + +public sealed class OAuthConnection +{ + public required Guid UserId { get; set; } + + public required string OAuthProvider { get; set; } + + public required string OAuthAccountId { get; set; } + + public required string? OAuthAccountName { get; set; } + + public DateTime CreatedAt { get; set; } + + // Navigations + public User User { get; set; } = null!; +} diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 8d4a433c..c452965b 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -105,6 +105,8 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet PublicShareShockerMappings { get; set; } public DbSet Users { get; set; } + + public DbSet OAuthConnections { get; set; } public DbSet UserActivationRequests { get; set; } @@ -611,6 +613,32 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasColumnName("activated_at"); }); + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.UserId).HasName("user_oauth_connections_pkey"); + + entity.HasIndex(e => new { e.OAuthProvider, e.OAuthAccountId }).IsUnique(); + + entity.ToTable("user_oauth_connections"); + + entity.Property(e => e.UserId) + .HasColumnName("user_id"); + entity.Property(e => e.OAuthProvider) + .UseCollation("C") + .HasColumnName("oauth_provider"); + entity.Property(e => e.OAuthAccountId) + .HasColumnName("oauth_account_id"); + entity.Property(e => e.OAuthAccountName) + .HasColumnName("oauth_account_name"); + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnName("created_at"); + + entity.HasOne(c => c.User).WithMany(u => u.OAuthConnections) + .HasForeignKey(d => d.UserId) + .HasConstraintName("fk_user_oauth_connections_user_id"); + }); + modelBuilder.Entity(entity => { entity.HasKey(e => e.UserId).HasName("user_activation_requests_pkey"); diff --git a/Common/OpenShockDb/User.cs b/Common/OpenShockDb/User.cs index 6d120fc9..0acc2989 100644 --- a/Common/OpenShockDb/User.cs +++ b/Common/OpenShockDb/User.cs @@ -20,6 +20,7 @@ public sealed class User // Navigations public UserActivationRequest? UserActivationRequest { get; set; } public UserDeactivation? UserDeactivation { get; set; } + public ICollection OAuthConnections { get; set; } = []; public ICollection ApiTokens { get; } = []; public ICollection ReportedApiTokens { get; } = []; public ICollection Devices { get; } = []; From 9761179e80a5a2dc8a5b168c37cefd107b4499d6 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 20:27:26 +0200 Subject: [PATCH 14/63] Broader implementation --- API/Controller/Account/OAuthCallback.cs | 150 ++---------------- API/Controller/Account/OAuthListProviders.cs | 7 +- API/Controller/Account/OAuthStart.cs | 78 ++------- API/Program.cs | 11 +- API/Services/OAuth/CookieOAuthStore.cs | 30 ++++ .../OAuth/Discord/DiscordOAuthHandler.cs | 97 +++++++++++ .../OAuth/Discord/DiscordOAuthOptions.cs | 7 + API/Services/OAuth/IOAuthHandler.cs | 27 ++++ API/Services/OAuth/IOAuthHandlerRegistry.cs | 7 + API/Services/OAuth/IOAuthStore.cs | 7 + API/Services/OAuth/OAuthHandlerRegistry.cs | 16 ++ Common/Authentication/OpenShockAuthSchemes.cs | 3 - ...IAuthenticationSchemeProviderExtensions.cs | 28 ---- 13 files changed, 220 insertions(+), 248 deletions(-) create mode 100644 API/Services/OAuth/CookieOAuthStore.cs create mode 100644 API/Services/OAuth/Discord/DiscordOAuthHandler.cs create mode 100644 API/Services/OAuth/Discord/DiscordOAuthOptions.cs create mode 100644 API/Services/OAuth/IOAuthHandler.cs create mode 100644 API/Services/OAuth/IOAuthHandlerRegistry.cs create mode 100644 API/Services/OAuth/IOAuthStore.cs create mode 100644 API/Services/OAuth/OAuthHandlerRegistry.cs delete mode 100644 Common/Extensions/IAuthenticationSchemeProviderExtensions.cs diff --git a/API/Controller/Account/OAuthCallback.cs b/API/Controller/Account/OAuthCallback.cs index 4e806fc2..1af56aec 100644 --- a/API/Controller/Account/OAuthCallback.cs +++ b/API/Controller/Account/OAuthCallback.cs @@ -1,160 +1,28 @@ -using System.Net.Http.Headers; -using System.Text.Json; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using Microsoft.Extensions.Options; -using OpenShock.API.Options.OAuth; -using OpenShock.Common.Authentication; +using OpenShock.API.Services.OAuth; using OpenShock.Common.Errors; namespace OpenShock.API.Controller.Account; public sealed partial class AccountController { - private const string DiscordApiBase = "https://api.openshock.app"; // TODO: move to config - private const string DefaultReturn = "https://app.openshock.app/auth/callback/discord"; // TODO: move to config - - // Very small DTOs for Discord responses - private sealed class DiscordTokenResponse - { - public string access_token { get; set; } = default!; - public string token_type { get; set; } = default!; - public int expires_in { get; set; } - public string? refresh_token { get; set; } - public string? scope { get; set; } - } - - private sealed class DiscordUser - { - public string id { get; set; } = default!; - public string username { get; set; } = default!; - public string discriminator { get; set; } = "0"; - public string? global_name { get; set; } - public string? avatar { get; set; } - } - [EnableRateLimiting("auth")] [HttpGet("oauth/callback/{provider}")] [EnableCors("allow_sso_providers")] - public async Task OAuthAuthenticate( - [FromRoute] string provider, - [FromServices] IHttpClientFactory httpClientFactory, - [FromServices] IOptions discordOptionsSnap) + public async Task OAuthAuthenticate([FromRoute] string provider, [FromServices] IOAuthHandlerRegistry registry) { - if (!OpenShockAuthSchemes.OAuth2Schemes.Contains(provider)) + if (!registry.TryGet(provider, out var handler)) return Problem(OAuthError.ProviderNotSupported); - if (!string.Equals(provider, "discord", StringComparison.OrdinalIgnoreCase)) // temporary - return Problem(OAuthError.ProviderNotSupported); - - // Read query values dynamically (only code & state are expected for Discord) - var code = Request.Query["code"].ToString(); - var state = Request.Query["state"].ToString(); - - if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state)) - return BadRequest("Missing 'code' or 'state'."); - - // Read & clear state cookie - if (!Request.Cookies.TryGetValue(StateCookieName, out var cookieVal)) - return BadRequest("Missing state cookie."); - - // cookie format from /start: "|" - string cookieState; - string? rawReturnTo = null; - var pipeIdx = cookieVal.IndexOf('|'); - if (pipeIdx >= 0) - { - cookieState = cookieVal[..pipeIdx]; - rawReturnTo = cookieVal[(pipeIdx + 1)..]; - if (string.IsNullOrWhiteSpace(rawReturnTo)) rawReturnTo = null; - } - else - { - cookieState = cookieVal; - } - - Response.Cookies.Delete(StateCookieName, new CookieOptions - { - HttpOnly = true, - Secure = true, - SameSite = SameSiteMode.Lax, - Path = "/" - }); - - if (!string.Equals(state, cookieState, StringComparison.Ordinal)) - return BadRequest("Invalid state."); + // Let the handler do everything (state validation, token exchange, user fetch) + var result = await handler.HandleCallbackAsync(HttpContext, Request.Query); - // Exchange authorization code for tokens - var discordOptions = discordOptionsSnap.Value; - var callbackUri = new Uri(new Uri(DiscordApiBase), "/1/account/oauth/callback/discord"); - - DiscordTokenResponse? token; - var client = httpClientFactory.CreateClient(); - using (var tokenReq = new HttpRequestMessage(HttpMethod.Post, "https://discord.com/api/oauth2/token")) - { - tokenReq.Content = new FormUrlEncodedContent(new Dictionary - { - ["client_id"] = discordOptions.ClientId, - ["client_secret"] = discordOptions.ClientSecret, - ["grant_type"] = "authorization_code", - ["code"] = code, - ["redirect_uri"] = callbackUri.ToString() - }); - - using var tokenRes = await client.SendAsync(tokenReq); - if (!tokenRes.IsSuccessStatusCode) - return BadRequest($"Token exchange failed ({(int)tokenRes.StatusCode})."); - - token = JsonSerializer.Deserialize( - await tokenRes.Content.ReadAsStringAsync(), - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - } - - if (token?.access_token is null) - return BadRequest("No access token from provider."); - - // Fetch Discord user - using var meReq = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/api/users/@me"); - meReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.access_token); - using var meRes = await client.SendAsync(meReq); - if (!meRes.IsSuccessStatusCode) - return BadRequest($"Failed to fetch user profile ({(int)meRes.StatusCode})."); - - var user = JsonSerializer.Deserialize( - await meRes.Content.ReadAsStringAsync(), - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - - if (user is null) - return BadRequest("Invalid user payload from provider."); - - // TODO: Link/auth the account (by user.id), create session/JWT, set your own auth cookie/header here. - - // Where to redirect next (keep whitelisting off until you add it) - - // If/when you add whitelisting: - // if (Uri.TryCreate(rawReturnTo, UriKind.Absolute, out var rt) && IsAllowedReturnUrl(rt, discordOptions.AllowedReturnHosts)) - // redirectTarget = rt.ToString(); - - return Redirect(DefaultReturn); - } - - // If/when you enable return_to, keep a tiny allow-list like: - /* - private static bool IsAllowedReturnUrl(Uri url, IEnumerable allowedHosts) - { - if (!string.Equals(url.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) - return false; + // >>> Your app-specific login/linking <<< + // e.g., sign in / create session by result.User - var host = url.Host; - foreach (var allowed in allowedHosts) - { - if (string.Equals(host, allowed, StringComparison.OrdinalIgnoreCase)) - return true; - if (host.EndsWith("." + allowed, StringComparison.OrdinalIgnoreCase)) - return true; - } - return false; + // Decide where to go next (consider a per-provider default or read from state store if you saved return_to) + return Redirect("https://app.openshock.app/auth/callback/" + handler.Key); // or your chosen target } - */ } \ No newline at end of file diff --git a/API/Controller/Account/OAuthListProviders.cs b/API/Controller/Account/OAuthListProviders.cs index 827dcc91..ffa141ed 100644 --- a/API/Controller/Account/OAuthListProviders.cs +++ b/API/Controller/Account/OAuthListProviders.cs @@ -1,6 +1,5 @@ -using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.RateLimiting; +using OpenShock.API.Services.OAuth; using OpenShock.Common.Authentication; namespace OpenShock.API.Controller.Account; @@ -11,8 +10,8 @@ public sealed partial class AccountController /// Returns a list of supported SSO providers /// [HttpGet("oauth/providers")] - public string[] ListOAuthProviders() + public string[] ListOAuthProviders([FromServices] IOAuthHandlerRegistry registry) { - return OpenShockAuthSchemes.OAuth2Schemes; + return registry.ListProviders(); } } diff --git a/API/Controller/Account/OAuthStart.cs b/API/Controller/Account/OAuthStart.cs index cd2d124d..e3e9bda1 100644 --- a/API/Controller/Account/OAuthStart.cs +++ b/API/Controller/Account/OAuthStart.cs @@ -1,83 +1,23 @@ -using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using Microsoft.Extensions.Options; -using OpenShock.API.Options.OAuth; -using OpenShock.Common.Authentication; +using OpenShock.API.Services.OAuth; using OpenShock.Common.Errors; -using OpenShock.Common.Utils; namespace OpenShock.API.Controller.Account; public sealed partial class AccountController { - // Cookie names - private const string StateCookieName = "__openshock_oauth_state"; - [EnableRateLimiting("auth")] - [HttpGet("oauth/start", Name = "InternalSsoAuthenticate")] - public IActionResult OAuthAuthenticate( - [FromQuery] string provider, - [FromQuery(Name = "return_to")] Uri? returnTo, - [FromServices] IOptions discordOptions) + [HttpGet("oauth/start")] + public IActionResult OAuthAuthenticate([FromQuery] string provider, [FromQuery(Name = "return_to")] string? returnTo, [FromServices] IOAuthHandlerRegistry registry) { - if (!OpenShockAuthSchemes.OAuth2Schemes.Contains(provider)) + if (!registry.TryGet(provider, out var handler)) return Problem(OAuthError.ProviderNotSupported); - // Normalize provider - provider = provider.ToLowerInvariant(); - if (provider is not "discord") - return Problem(OAuthError.ProviderNotSupported); // TODO: KEEPME Temporary solution - - // Only Discord for now - var options = discordOptions.Value; - - // Build your absolute callback (don’t hardcode) - // e.g., options: CallbackBase = "https://api.openshock.app" - var callbackUri = new Uri(new Uri(DiscordApiBase), "/1/account/oauth/callback/discord"); - - // TODO: DONTIMPLEMENTYET Optional post-login returnUrl - /* - string? safeReturnUrl = null; - if (returnTo is not null && IsAllowedReturnUrl(returnTo, discordOptions.AllowedReturnHosts)) - { - safeReturnUrl = returnTo.ToString(); - } - */ - - // CSRF state (random nonce) - var cookieContents = $"{CryptoUtils.RandomString(64)}|{returnTo}"; - var stateKeyHash = HashingUtils.HashSha256(cookieContents); - - // Persist cookies (HttpOnly, Secure, SameSite=Lax works for top-level redirects) - SetTempCookie(StateCookieName, cookieContents); - - // Build Discord authorization URL - var authUrl = new UriBuilder("https://discord.com/oauth2/authorize") - { - Query = new QueryBuilder - { - { "response_type", "code" }, - { "client_id", options.ClientId }, - { "scope", "identify" }, - { "redirect_uri", callbackUri.ToString() }, - { "state", stateKeyHash }, - }.ToString() - }.Uri.ToString(); - - return Redirect(authUrl); - } - - private void SetTempCookie(string name, string value) - { - Response.Cookies.Append(name, value, new CookieOptions - { - HttpOnly = true, - Secure = true, - SameSite = SameSiteMode.Strict, - IsEssential = true, - Expires = DateTimeOffset.UtcNow.AddMinutes(10), // 10 minutes is plenty for a round-trip - Path = "/" - }); + var result = handler.BuildAuthorizeUrl(HttpContext, new OAuthStartContext(string.IsNullOrWhiteSpace(returnTo) ? null : returnTo)); + return result.Match( + Redirect, + error => Problem(title: error.Code, detail: error.Description) + ); } } \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 1e72d296..a1155fa0 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -18,7 +18,9 @@ using OpenShock.Common.Services.Turnstile; using OpenShock.Common.Swagger; using Serilog; -using OpenShock.API.Options.OAuth; +using OpenShock.API.Services.OAuth; +using OpenShock.API.Services.OAuth.Discord; +using DiscordOAuthOptions = OpenShock.API.Options.OAuth.DiscordOAuthOptions; var builder = OpenShockApplication.CreateDefaultBuilder(args); @@ -28,7 +30,6 @@ builder.Services.Configure(builder.Configuration.GetRequiredSection(FrontendOptions.SectionName)); builder.Services.AddSingleton, FrontendOptionsValidator>(); -builder.Services.Configure(builder.Configuration.GetRequiredSection(DiscordOAuthOptions.SectionName)); var databaseConfig = builder.Configuration.GetDatabaseOptions(); var redisConfig = builder.Configuration.GetRedisConfigurationOptions(); @@ -54,7 +55,11 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddHttpClient("DiscordOAuth", client => client.BaseAddress = new Uri("https://discord.com/api/")); + +builder.Services.AddSingleton(); +builder.Services.Configure(builder.Configuration.GetRequiredSection(DiscordOAuthOptions.SectionName)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.AddSwaggerExt(); diff --git a/API/Services/OAuth/CookieOAuthStore.cs b/API/Services/OAuth/CookieOAuthStore.cs new file mode 100644 index 00000000..1c0a6e0c --- /dev/null +++ b/API/Services/OAuth/CookieOAuthStore.cs @@ -0,0 +1,30 @@ +namespace OpenShock.API.Services.OAuth; + +public sealed class CookieOAuthStateStore : IOAuthStateStore +{ + private const string CookiePrefix = "__os_oauth_state_"; + + public void Save(HttpContext http, string provider, string state, string? returnTo) + { + var val = $"{state}|{returnTo}"; + http.Response.Cookies.Append(CookiePrefix + provider, val, new CookieOptions + { + HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax, + Expires = DateTimeOffset.UtcNow.AddMinutes(10), Path = "/" + }); + } + + public (string State, string? ReturnTo)? ReadAndClear(HttpContext http, string provider) + { + var name = CookiePrefix + provider; + if (!http.Request.Cookies.TryGetValue(name, out var v)) return null; + + http.Response.Cookies.Delete(name, new CookieOptions { Path = "/", HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax }); + + var i = v.IndexOf('|'); + if (i < 0) return (v, null); + var s = v[..i]; + var r = v[(i + 1)..]; + return (s, string.IsNullOrWhiteSpace(r) ? null : r); + } +} \ No newline at end of file diff --git a/API/Services/OAuth/Discord/DiscordOAuthHandler.cs b/API/Services/OAuth/Discord/DiscordOAuthHandler.cs new file mode 100644 index 00000000..cfba4367 --- /dev/null +++ b/API/Services/OAuth/Discord/DiscordOAuthHandler.cs @@ -0,0 +1,97 @@ +using OneOf; +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.Extensions.Options; +using OpenShock.Common.Utils; + +namespace OpenShock.API.Services.OAuth.Discord; + +public sealed class DiscordOAuthHandler : IOAuthHandler +{ + private const string AuthorizeEndpoint = "https://discord.com/oauth2/authorize"; + private const string TokenEndpoint = "https://discord.com/api/oauth2/token"; + private const string UserInfoEndpoint = "https://discord.com/api/users/@me"; + + private const string CallbackPath ="/1/account/oauth/callback/discord"; + + private readonly IHttpClientFactory _http; + private readonly IOptions _opt; + private readonly IOAuthStateStore _state; + + public DiscordOAuthHandler(IHttpClientFactory http, IOptions opt, IOAuthStateStore state) + { + _http = http; _opt = opt; _state = state; + } + + public string Key => "discord"; + + public OneOf BuildAuthorizeUrl(HttpContext http, OAuthStartContext ctx) + { + var o = _opt.Value; + var callback = new Uri(new Uri(o.CallbackBase.TrimEnd('/')), CallbackPath).ToString(); + + var state = CryptoUtils.RandomString(64); + _state.Save(http, Key, state, ctx.ReturnTo); + + var qb = new QueryBuilder + { + { "response_type", "code" }, + { "client_id", o.ClientId }, + { "scope", "identify" }, + { "redirect_uri", callback }, + { "state", state } + }; + return new UriBuilder(AuthorizeEndpoint) { Query = qb.ToString() }.Uri.ToString(); + } + + public async Task> HandleCallbackAsync(HttpContext http, IQueryCollection query) + { + var o = _opt.Value; + + var code = query["code"].ToString(); + var state = query["state"].ToString(); + if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state)) + throw new InvalidOperationException("Missing code/state"); + + var saved = _state.ReadAndClear(http, Key); + if (saved is null || !string.Equals(saved.Value.State, state, StringComparison.Ordinal)) + throw new InvalidOperationException("Invalid state"); + + var callback = new Uri(new Uri(o.CallbackBase.TrimEnd('/')), CallbackPath).ToString(); + + var client = _http.CreateClient(); + using var tokenReq = new HttpRequestMessage(HttpMethod.Post, TokenEndpoint) + { + Content = new FormUrlEncodedContent(new Dictionary + { + ["client_id"] = o.ClientId, + ["client_secret"] = o.ClientSecret, + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = callback + }) + }; + using var tokenRes = await client.SendAsync(tokenReq); + tokenRes.EnsureSuccessStatusCode(); + + var token = JsonSerializer.Deserialize(await tokenRes.Content.ReadAsStringAsync()); + var access = token.GetProperty("access_token").GetString()!; + + using var meReq = new HttpRequestMessage(HttpMethod.Get, UserInfoEndpoint); + meReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", access); + using var meRes = await client.SendAsync(meReq); + meRes.EnsureSuccessStatusCode(); + + var me = JsonSerializer.Deserialize(await meRes.Content.ReadAsStringAsync()); + var user = new ExternalUser( + Provider: Key, + ExternalId: me.GetProperty("id").GetString()!, + Username: me.GetProperty("username").GetString(), + DisplayName: me.TryGetProperty("global_name", out var gn) ? gn.GetString() : null, + AvatarUrl: null // build if you need it + ); + + return new OAuthCallbackResult(user); + } +} \ No newline at end of file diff --git a/API/Services/OAuth/Discord/DiscordOAuthOptions.cs b/API/Services/OAuth/Discord/DiscordOAuthOptions.cs new file mode 100644 index 00000000..bea2b61e --- /dev/null +++ b/API/Services/OAuth/Discord/DiscordOAuthOptions.cs @@ -0,0 +1,7 @@ +namespace OpenShock.API.Services.OAuth.Discord; + +public sealed class DiscordOAuthOptions +{ + public string ClientId { get; set; } = default!; + public string ClientSecret { get; set; } = default!; +} \ No newline at end of file diff --git a/API/Services/OAuth/IOAuthHandler.cs b/API/Services/OAuth/IOAuthHandler.cs new file mode 100644 index 00000000..a6768940 --- /dev/null +++ b/API/Services/OAuth/IOAuthHandler.cs @@ -0,0 +1,27 @@ +using OneOf; + +namespace OpenShock.API.Services.OAuth; + +public sealed record ExternalUser( + string Provider, // "discord", "github", etc. + string ExternalId, // provider user id + string? Username, + string? DisplayName, + string? AvatarUrl); + +public sealed record OAuthStartContext(string? ReturnTo); +public sealed record OAuthCallbackResult(ExternalUser User); + +public sealed record OAuthErrorResult(string Code, string Description); + +public interface IOAuthHandler +{ + /// A short, case-insensitive key (e.g., "discord"). + string Key { get; } + + /// Build the provider authorize URL and set any cookies you need (state, pkce, return_to). + OneOf BuildAuthorizeUrl(HttpContext http, OAuthStartContext ctx); + + /// Handle callback: validate state, exchange code, fetch user, clear cookies, etc. + Task> HandleCallbackAsync(HttpContext http, IQueryCollection query); +} \ No newline at end of file diff --git a/API/Services/OAuth/IOAuthHandlerRegistry.cs b/API/Services/OAuth/IOAuthHandlerRegistry.cs new file mode 100644 index 00000000..9374350c --- /dev/null +++ b/API/Services/OAuth/IOAuthHandlerRegistry.cs @@ -0,0 +1,7 @@ +namespace OpenShock.API.Services.OAuth; + +public interface IOAuthHandlerRegistry +{ + string[] ListProviders(); + bool TryGet(string key, out IOAuthHandler handler); +} \ No newline at end of file diff --git a/API/Services/OAuth/IOAuthStore.cs b/API/Services/OAuth/IOAuthStore.cs new file mode 100644 index 00000000..bfe70178 --- /dev/null +++ b/API/Services/OAuth/IOAuthStore.cs @@ -0,0 +1,7 @@ +namespace OpenShock.API.Services.OAuth; + +public interface IOAuthStateStore +{ + void Save(HttpContext http, string provider, string state, string? returnTo); + (string State, string? ReturnTo)? ReadAndClear(HttpContext http, string provider); +} \ No newline at end of file diff --git a/API/Services/OAuth/OAuthHandlerRegistry.cs b/API/Services/OAuth/OAuthHandlerRegistry.cs new file mode 100644 index 00000000..f827ef78 --- /dev/null +++ b/API/Services/OAuth/OAuthHandlerRegistry.cs @@ -0,0 +1,16 @@ +namespace OpenShock.API.Services.OAuth; + +public sealed class OAuthHandlerRegistry : IOAuthHandlerRegistry +{ + private readonly Dictionary _handlers; + + public OAuthHandlerRegistry(IEnumerable handlers) + { + _handlers = handlers.ToDictionary(h => h.Key, h => h, StringComparer.OrdinalIgnoreCase); + } + + public string[] ListProviders() => _handlers.Keys.ToArray(); + + public bool TryGet(string key, out IOAuthHandler handler) + => _handlers.TryGetValue(key, out handler!); +} \ No newline at end of file diff --git a/Common/Authentication/OpenShockAuthSchemes.cs b/Common/Authentication/OpenShockAuthSchemes.cs index a67131f4..7d3c8792 100644 --- a/Common/Authentication/OpenShockAuthSchemes.cs +++ b/Common/Authentication/OpenShockAuthSchemes.cs @@ -7,7 +7,4 @@ public static class OpenShockAuthSchemes public const string HubToken = "HubToken"; public const string UserSessionApiTokenCombo = $"{UserSessionCookie},{ApiToken}"; - - public const string DiscordScheme = "discord"; - public static readonly string[] OAuth2Schemes = [DiscordScheme]; } \ No newline at end of file diff --git a/Common/Extensions/IAuthenticationSchemeProviderExtensions.cs b/Common/Extensions/IAuthenticationSchemeProviderExtensions.cs deleted file mode 100644 index 633ad090..00000000 --- a/Common/Extensions/IAuthenticationSchemeProviderExtensions.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.AspNetCore.Authentication; -using OpenShock.Common.Constants; -using System.Linq; -using OpenShock.Common.Authentication; - -namespace OpenShock.Common.Extensions; - -public static class IAuthenticationSchemeProviderExtensions -{ - public static async Task GetOAuthSchemeNamesAsync(this IAuthenticationSchemeProvider provider) - { - var allSchemes = await provider.GetAllSchemesAsync(); - - return allSchemes - .Where(scheme => OpenShockAuthSchemes.OAuth2Schemes.Contains(scheme.Name)) - .Select(scheme => scheme.Name) - .ToArray(); - } - public static async Task IsSupportedOAuthProviderAsync(this IAuthenticationSchemeProvider provider, string scheme) - { - foreach (var supportedScheme in await provider.GetOAuthSchemeNamesAsync()) - { - if (string.Equals(scheme, supportedScheme, StringComparison.InvariantCultureIgnoreCase)) return true; - } - - return false; - } -} From 59d0be0c401c7b411059cf8935a2372f799c2b00 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 20:31:13 +0200 Subject: [PATCH 15/63] More reverts --- API/Options/OAuth/DiscordOAuthOptions.cs | 11 ----------- API/Program.cs | 1 - API/Services/OAuth/Discord/DiscordOAuthOptions.cs | 8 ++++++-- Common/Constants/AuthConstants.cs | 2 +- 4 files changed, 7 insertions(+), 15 deletions(-) delete mode 100644 API/Options/OAuth/DiscordOAuthOptions.cs diff --git a/API/Options/OAuth/DiscordOAuthOptions.cs b/API/Options/OAuth/DiscordOAuthOptions.cs deleted file mode 100644 index 41723d0c..00000000 --- a/API/Options/OAuth/DiscordOAuthOptions.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace OpenShock.API.Options.OAuth; - -public sealed class DiscordOAuthOptions -{ - public const string SectionName = "OpenShock:OAuth2:Discord"; - - public required string ClientId { get; init; } - public required string ClientSecret { get; init; } - public required PathString CallbackPath { get; init; } - public required PathString AccessDeniedPath { get; init; } -} \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index a1155fa0..d2343c50 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -20,7 +20,6 @@ using Serilog; using OpenShock.API.Services.OAuth; using OpenShock.API.Services.OAuth.Discord; -using DiscordOAuthOptions = OpenShock.API.Options.OAuth.DiscordOAuthOptions; var builder = OpenShockApplication.CreateDefaultBuilder(args); diff --git a/API/Services/OAuth/Discord/DiscordOAuthOptions.cs b/API/Services/OAuth/Discord/DiscordOAuthOptions.cs index bea2b61e..228ff042 100644 --- a/API/Services/OAuth/Discord/DiscordOAuthOptions.cs +++ b/API/Services/OAuth/Discord/DiscordOAuthOptions.cs @@ -2,6 +2,10 @@ public sealed class DiscordOAuthOptions { - public string ClientId { get; set; } = default!; - public string ClientSecret { get; set; } = default!; + public const string SectionName = "OpenShock:OAuth2:Discord"; + + public required string ClientId { get; init; } + public required string ClientSecret { get; init; } + public required PathString CallbackPath { get; init; } + public required PathString AccessDeniedPath { get; init; } } \ No newline at end of file diff --git a/Common/Constants/AuthConstants.cs b/Common/Constants/AuthConstants.cs index 2f10b1a2..3f1153d5 100644 --- a/Common/Constants/AuthConstants.cs +++ b/Common/Constants/AuthConstants.cs @@ -6,7 +6,7 @@ public static class AuthConstants public const string UserSessionHeaderName = "OpenShockSession"; public const string ApiTokenHeaderName = "OpenShockToken"; public const string HubTokenHeaderName = "DeviceToken"; - + public const int GeneratedTokenLength = 32; public const int ApiTokenLength = 64; } From 27bf171f76a91b6179fab3da3178f3e4abe6dcbb Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 21:10:12 +0200 Subject: [PATCH 16/63] Fix up some more stuff --- API/Program.cs | 6 ++--- .../OAuth/Discord/DiscordOAuthHandler.cs | 8 ++++--- API/Services/OAuth/IOAuthBuilder.cs | 8 +++++++ API/Services/OAuth/OAuthBuilder.cs | 22 +++++++++++++++++++ .../OAuth/OAuthServiceCollectionExtensions.cs | 18 +++++++++++++++ API/appsettings.json | 10 +++++---- README.md | 10 ++++----- 7 files changed, 66 insertions(+), 16 deletions(-) create mode 100644 API/Services/OAuth/IOAuthBuilder.cs create mode 100644 API/Services/OAuth/OAuthBuilder.cs create mode 100644 API/Services/OAuth/OAuthServiceCollectionExtensions.cs diff --git a/API/Program.cs b/API/Program.cs index d2343c50..4858eda5 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -55,10 +55,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddSingleton(); -builder.Services.Configure(builder.Configuration.GetRequiredSection(DiscordOAuthOptions.SectionName)); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddOAuth() + .AddHandler("discord", builder.Configuration.GetRequiredSection(DiscordOAuthOptions.SectionName)); builder.AddSwaggerExt(); diff --git a/API/Services/OAuth/Discord/DiscordOAuthHandler.cs b/API/Services/OAuth/Discord/DiscordOAuthHandler.cs index cfba4367..7862712d 100644 --- a/API/Services/OAuth/Discord/DiscordOAuthHandler.cs +++ b/API/Services/OAuth/Discord/DiscordOAuthHandler.cs @@ -21,7 +21,9 @@ public sealed class DiscordOAuthHandler : IOAuthHandler public DiscordOAuthHandler(IHttpClientFactory http, IOptions opt, IOAuthStateStore state) { - _http = http; _opt = opt; _state = state; + _http = http; + _opt = opt; + _state = state; } public string Key => "discord"; @@ -29,7 +31,7 @@ public DiscordOAuthHandler(IHttpClientFactory http, IOptions BuildAuthorizeUrl(HttpContext http, OAuthStartContext ctx) { var o = _opt.Value; - var callback = new Uri(new Uri(o.CallbackBase.TrimEnd('/')), CallbackPath).ToString(); + var callback = new Uri(new Uri("https://api.openhshock.dev"), CallbackPath).ToString(); // TODO: Make the base URL dynamic somehow var state = CryptoUtils.RandomString(64); _state.Save(http, Key, state, ctx.ReturnTo); @@ -58,7 +60,7 @@ public async Task> HandleCallbackAs if (saved is null || !string.Equals(saved.Value.State, state, StringComparison.Ordinal)) throw new InvalidOperationException("Invalid state"); - var callback = new Uri(new Uri(o.CallbackBase.TrimEnd('/')), CallbackPath).ToString(); + var callback = new Uri(new Uri("https://api.openhshock.dev"), CallbackPath).ToString(); // TODO: Make the base URL dynamic somehow var client = _http.CreateClient(); using var tokenReq = new HttpRequestMessage(HttpMethod.Post, TokenEndpoint) diff --git a/API/Services/OAuth/IOAuthBuilder.cs b/API/Services/OAuth/IOAuthBuilder.cs new file mode 100644 index 00000000..377ee8c4 --- /dev/null +++ b/API/Services/OAuth/IOAuthBuilder.cs @@ -0,0 +1,8 @@ +namespace OpenShock.API.Services.OAuth; + +public interface IOAuthBuilder +{ + IOAuthBuilder AddHandler(string key, IConfiguration configuration) + where THandler : class, IOAuthHandler + where TOptions : class; +} \ No newline at end of file diff --git a/API/Services/OAuth/OAuthBuilder.cs b/API/Services/OAuth/OAuthBuilder.cs new file mode 100644 index 00000000..95f6ba23 --- /dev/null +++ b/API/Services/OAuth/OAuthBuilder.cs @@ -0,0 +1,22 @@ +namespace OpenShock.API.Services.OAuth; + +internal sealed class OAuthBuilder : IOAuthBuilder +{ + private readonly IServiceCollection _services; + internal OAuthBuilder(IServiceCollection services) => _services = services; + + public IOAuthBuilder AddHandler(string key,IConfiguration configuration) + where THandler : class, IOAuthHandler + where TOptions : class + { + _services.Configure(configuration); + + // Typed HttpClient per handler (unique type = unique client) + _services.AddHttpClient(); + + // Register handler as IOAuthHandler + _services.AddSingleton(); + + return this; + } +} \ No newline at end of file diff --git a/API/Services/OAuth/OAuthServiceCollectionExtensions.cs b/API/Services/OAuth/OAuthServiceCollectionExtensions.cs new file mode 100644 index 00000000..bb19a24b --- /dev/null +++ b/API/Services/OAuth/OAuthServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace OpenShock.API.Services.OAuth; + +public static class OAuthServiceCollectionExtensions +{ + public static IOAuthBuilder AddOAuth(this IServiceCollection services) + { + // Default state store if none registered + services.TryAddSingleton(); + + // Registry built from IEnumerable + services.TryAddSingleton(); + + return new OAuthBuilder(services); + } +} \ No newline at end of file diff --git a/API/appsettings.json b/API/appsettings.json index e4a16e67..88c3bf61 100644 --- a/API/appsettings.json +++ b/API/appsettings.json @@ -37,10 +37,12 @@ ] }, "OpenShock": { - "Discord": { - "ClientId": "", - "ClientSecret": "", - "RedirectUri": "" + "OAuth2": { + "Discord": { + "ClientId": "", + "ClientSecret": "", + "RedirectUri": "" + } } } } diff --git a/README.md b/README.md index fa150f5d..9c76956b 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,11 @@ Refer to [StackExchange.Redis Configuration](https://stackexchange.github.io/Sta ### Discord OAuth -| Variable | Required | Default value | Allowed / Example value | -|----------|----------|---------------|-------------------------| -| `OPENSHOCK__DISCORD__CLIENTID` | x | | | -| `OPENSHOCK__DISCORD__CLIENTSECRET` | x | | | -| `OPENSHOCK__DISCORD__REDIRECTURI` | x | | `https://my-openshock-instance.net/discord/callback` | +| Variable | Required | Default value | Allowed / Example value | +|--------------------------------------------|----------|---------------|------------------------------------------------------| +| `OPENSHOCK__OAUTH2__DISCORD__CLIENTID` | x | | | +| `OPENSHOCK__OAUTH2__DISCORD__CLIENTSECRET` | x | | | +| `OPENSHOCK__OAUTH2__DISCORD__REDIRECTURI` | x | | `https://my-openshock-instance.net/discord/callback` | ## Turnstile From 54744e23bc3b2139cae3c6503842c39f9962ddd2 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 21:19:58 +0200 Subject: [PATCH 17/63] Improve implementation --- API/Controller/Account/OAuthCallback.cs | 7 +++++++ API/Services/OAuth/Discord/DiscordOAuthHandler.cs | 4 ++-- API/Services/OAuth/IOAuthHandler.cs | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/API/Controller/Account/OAuthCallback.cs b/API/Controller/Account/OAuthCallback.cs index 1af56aec..350e0a6b 100644 --- a/API/Controller/Account/OAuthCallback.cs +++ b/API/Controller/Account/OAuthCallback.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; @@ -18,6 +19,12 @@ public async Task OAuthAuthenticate([FromRoute] string provider, // Let the handler do everything (state validation, token exchange, user fetch) var result = await handler.HandleCallbackAsync(HttpContext, Request.Query); + if (!result.TryPickT0(out var contract, out var error)) + { + return BadRequest(); // TODO: Change me + } + + contract.User. // >>> Your app-specific login/linking <<< // e.g., sign in / create session by result.User diff --git a/API/Services/OAuth/Discord/DiscordOAuthHandler.cs b/API/Services/OAuth/Discord/DiscordOAuthHandler.cs index 7862712d..5f0f1a36 100644 --- a/API/Services/OAuth/Discord/DiscordOAuthHandler.cs +++ b/API/Services/OAuth/Discord/DiscordOAuthHandler.cs @@ -40,7 +40,7 @@ public OneOf BuildAuthorizeUrl(HttpContext http, OAuth { { "response_type", "code" }, { "client_id", o.ClientId }, - { "scope", "identify" }, + { "scope", "identify email" }, { "redirect_uri", callback }, { "state", state } }; @@ -90,7 +90,7 @@ public async Task> HandleCallbackAs Provider: Key, ExternalId: me.GetProperty("id").GetString()!, Username: me.GetProperty("username").GetString(), - DisplayName: me.TryGetProperty("global_name", out var gn) ? gn.GetString() : null, + Email: me.GetProperty("email").GetString(), AvatarUrl: null // build if you need it ); diff --git a/API/Services/OAuth/IOAuthHandler.cs b/API/Services/OAuth/IOAuthHandler.cs index a6768940..5c64ed61 100644 --- a/API/Services/OAuth/IOAuthHandler.cs +++ b/API/Services/OAuth/IOAuthHandler.cs @@ -6,7 +6,7 @@ public sealed record ExternalUser( string Provider, // "discord", "github", etc. string ExternalId, // provider user id string? Username, - string? DisplayName, + string? Email, // provider email string? AvatarUrl); public sealed record OAuthStartContext(string? ReturnTo); From bd46c451428c61cf49fc7bd858a0a92014514d9a Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 21:24:11 +0200 Subject: [PATCH 18/63] Attempt to fix integration test failure --- API/Services/OAuth/Discord/DiscordOAuthOptions.cs | 2 -- API/appsettings.json | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/API/Services/OAuth/Discord/DiscordOAuthOptions.cs b/API/Services/OAuth/Discord/DiscordOAuthOptions.cs index 228ff042..8650ade3 100644 --- a/API/Services/OAuth/Discord/DiscordOAuthOptions.cs +++ b/API/Services/OAuth/Discord/DiscordOAuthOptions.cs @@ -6,6 +6,4 @@ public sealed class DiscordOAuthOptions public required string ClientId { get; init; } public required string ClientSecret { get; init; } - public required PathString CallbackPath { get; init; } - public required PathString AccessDeniedPath { get; init; } } \ No newline at end of file diff --git a/API/appsettings.json b/API/appsettings.json index 88c3bf61..b17e3152 100644 --- a/API/appsettings.json +++ b/API/appsettings.json @@ -40,8 +40,7 @@ "OAuth2": { "Discord": { "ClientId": "", - "ClientSecret": "", - "RedirectUri": "" + "ClientSecret": "" } } } From 20d0baa129ad58e2642c9456bd0cf5dd1ce3d5d4 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 21:45:08 +0200 Subject: [PATCH 19/63] Fail password logins for OAuth accounts --- .../Account/Authenticated/ChangePassword.cs | 2 +- API/Controller/Account/Login.cs | 3 ++- API/Controller/Account/LoginV2.cs | 3 ++- API/Controller/Account/OAuthCallback.cs | 2 -- API/Services/Account/AccountService.cs | 19 ++++++++++++++----- API/Services/Account/IAccountService.cs | 7 ++++--- Common/Errors/AccountError.cs | 2 ++ Common/OpenShockDb/User.cs | 2 +- 8 files changed, 26 insertions(+), 14 deletions(-) diff --git a/API/Controller/Account/Authenticated/ChangePassword.cs b/API/Controller/Account/Authenticated/ChangePassword.cs index d25c2f43..1bd71e4a 100644 --- a/API/Controller/Account/Authenticated/ChangePassword.cs +++ b/API/Controller/Account/Authenticated/ChangePassword.cs @@ -17,7 +17,7 @@ public sealed partial class AuthenticatedAccountController [ProducesResponseType(StatusCodes.Status200OK)] public async Task ChangePassword(ChangePasswordRequest data) { - if (!HashingUtils.VerifyPassword(data.OldPassword, CurrentUser.PasswordHash).Verified) + if (!string.IsNullOrEmpty(CurrentUser.PasswordHash) && !HashingUtils.VerifyPassword(data.OldPassword, CurrentUser.PasswordHash).Verified) { return Problem(AccountError.PasswordChangeInvalidPassword); } diff --git a/API/Controller/Account/Login.cs b/API/Controller/Account/Login.cs index f8027a12..f6238070 100644 --- a/API/Controller/Account/Login.cs +++ b/API/Controller/Account/Login.cs @@ -46,8 +46,9 @@ public async Task Login( HttpContext.SetSessionKeyCookie(ok.Token, "." + cookieDomainToUse); return LegacyEmptyOk("Successfully logged in"); }, - notActivated => Problem(AccountError.AccountNotActivated), deactivated => Problem(AccountError.AccountDeactivated), + oauthOnly => Problem(AccountError.AccountOAuthOnly), + notActivated => Problem(AccountError.AccountNotActivated), notFound => Problem(LoginError.InvalidCredentials) ); } diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index b426cf61..5a6edecf 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -62,8 +62,9 @@ public async Task LoginV2( HttpContext.SetSessionKeyCookie(ok.Token, "." + cookieDomainToUse); return Ok(LoginV2OkResponse.FromUser(ok.User)); }, - notActivated => Problem(AccountError.AccountNotActivated), deactivated => Problem(AccountError.AccountDeactivated), + oauthOnly => Problem(AccountError.AccountOAuthOnly), + notActivated => Problem(AccountError.AccountNotActivated), notFound => Problem(LoginError.InvalidCredentials) ); } diff --git a/API/Controller/Account/OAuthCallback.cs b/API/Controller/Account/OAuthCallback.cs index 350e0a6b..bab383fa 100644 --- a/API/Controller/Account/OAuthCallback.cs +++ b/API/Controller/Account/OAuthCallback.cs @@ -23,8 +23,6 @@ public async Task OAuthAuthenticate([FromRoute] string provider, { return BadRequest(); // TODO: Change me } - - contract.User. // >>> Your app-specific login/linking <<< // e.g., sign in / create session by result.User diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 9bd816f9..0c3bbf1a 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -250,7 +250,7 @@ public async Task - public async Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password, + public async Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password, LoginContext loginContext, CancellationToken cancellationToken = default) { var lowercaseUsernameOrEmail = usernameOrEmail.ToLowerInvariant(); @@ -263,14 +263,18 @@ public async Task TryVerifyEmailAsync(string token, CancellationToken canc private async Task CheckPassword(string password, User user) { + if (string.IsNullOrEmpty(user.PasswordHash)) + { + return false; + } + var result = HashingUtils.VerifyPassword(password, user.PasswordHash); if (!result.Verified) diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 52e74fa1..3c86018b 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -50,7 +50,7 @@ public interface IAccountService /// /// /// - public Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password, LoginContext loginContext, CancellationToken cancellationToken = default); + public Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password, LoginContext loginContext, CancellationToken cancellationToken = default); /// /// Check if a password reset request exists and the secret is valid @@ -113,8 +113,9 @@ public interface IAccountService } public sealed record CreateUserLoginSessionSuccess(User User, string Token); -public readonly record struct AccountNotActivated; -public readonly record struct AccountDeactivated; +public readonly struct AccountIsOAuthOnly; +public readonly struct AccountNotActivated; +public readonly struct AccountDeactivated; public readonly struct AccountWithEmailOrUsernameExists; public readonly struct CannotDeactivatePrivilegedAccount; public readonly struct AccountDeactivationAlreadyInProgress; diff --git a/Common/Errors/AccountError.cs b/Common/Errors/AccountError.cs index aca57860..03429815 100644 --- a/Common/Errors/AccountError.cs +++ b/Common/Errors/AccountError.cs @@ -27,4 +27,6 @@ public static class AccountError public static OpenShockProblem AccountNotActivated => new OpenShockProblem("Account.AccountNotActivated", "Your account has not been activated", HttpStatusCode.Unauthorized); public static OpenShockProblem AccountDeactivated => new OpenShockProblem("Account.Deactivated", "Your account has been deactivated", HttpStatusCode.Unauthorized); + + public static OpenShockProblem AccountOAuthOnly => new OpenShockProblem("Account.OAuthOnly", "This account is only accessible via OAuth", HttpStatusCode.Unauthorized); } \ No newline at end of file diff --git a/Common/OpenShockDb/User.cs b/Common/OpenShockDb/User.cs index 0acc2989..8dfd5b74 100644 --- a/Common/OpenShockDb/User.cs +++ b/Common/OpenShockDb/User.cs @@ -10,7 +10,7 @@ public sealed class User public required string Email { get; set; } - public required string PasswordHash { get; set; } + public string? PasswordHash { get; set; } public List Roles { get; set; } = []; From 3d96883fa727b5f84d97b49b204da1f4258a251e Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 21:55:59 +0200 Subject: [PATCH 20/63] Add endpoint to list OAuth connections for current account --- .../Authenticated/OAuthListConnections.cs | 25 +++++++++++++++++++ .../Response/OAuthConnectionResponse.cs | 7 ++++++ API/Services/Account/AccountService.cs | 5 ++++ API/Services/Account/IAccountService.cs | 2 ++ 4 files changed, 39 insertions(+) create mode 100644 API/Controller/Account/Authenticated/OAuthListConnections.cs create mode 100644 API/Models/Response/OAuthConnectionResponse.cs diff --git a/API/Controller/Account/Authenticated/OAuthListConnections.cs b/API/Controller/Account/Authenticated/OAuthListConnections.cs new file mode 100644 index 00000000..630e3924 --- /dev/null +++ b/API/Controller/Account/Authenticated/OAuthListConnections.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc; +using OpenShock.API.Models.Response; + +namespace OpenShock.API.Controller.Account.Authenticated; + +public sealed partial class AuthenticatedAccountController +{ + /// + /// List OAuth connections + /// + [HttpGet("connections")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task ListOAuthConnections() + { + var connections = await _accountService.GetOAuthConnectionsAsync(CurrentUser.Id); + + return connections + .Select(c => new OAuthConnectionResponse + { + ProviderName = c.OAuthProvider, + ProviderAccountName = c.OAuthAccountName + }) + .ToArray(); + } +} \ No newline at end of file diff --git a/API/Models/Response/OAuthConnectionResponse.cs b/API/Models/Response/OAuthConnectionResponse.cs new file mode 100644 index 00000000..0104d85e --- /dev/null +++ b/API/Models/Response/OAuthConnectionResponse.cs @@ -0,0 +1,7 @@ +namespace OpenShock.API.Models.Response; + +public sealed class OAuthConnectionResponse +{ + public required string ProviderName { get; init; } + public required string? ProviderAccountName { get; init; } +} \ No newline at end of file diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 0c3bbf1a..ea2fb04f 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -445,6 +445,11 @@ public async Task TryVerifyEmailAsync(string token, CancellationToken canc return nChanges > 0; } + public Task GetOAuthConnectionsAsync(Guid accountId) + { + return _db.OAuthConnections.AsNoTracking().Where(c => c.UserId == accountId).ToArrayAsync(); + } + private async Task CheckPassword(string password, User user) { if (string.IsNullOrEmpty(user.PasswordHash)) diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 3c86018b..42473ca6 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -110,6 +110,8 @@ public interface IAccountService /// /// Task TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default); + + Task GetOAuthConnectionsAsync(Guid accountId); } public sealed record CreateUserLoginSessionSuccess(User User, string Token); From debcb1890517fe9d0276785c2326c1a68e4a5909 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 22:01:11 +0200 Subject: [PATCH 21/63] Add connection delete endpoint --- .../Authenticated/OAuthListConnections.cs | 16 ++++++++++++++++ API/Services/Account/AccountService.cs | 16 ++++++++++++++-- API/Services/Account/IAccountService.cs | 1 + 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/API/Controller/Account/Authenticated/OAuthListConnections.cs b/API/Controller/Account/Authenticated/OAuthListConnections.cs index 630e3924..6d1c7b03 100644 --- a/API/Controller/Account/Authenticated/OAuthListConnections.cs +++ b/API/Controller/Account/Authenticated/OAuthListConnections.cs @@ -22,4 +22,20 @@ public async Task ListOAuthConnections() }) .ToArray(); } + + /// + /// Delete an OAuth connection by provider + /// + [HttpDelete("connections/{provider}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteOAuthConnection([FromRoute] string provider) + { + var deleted = await _accountService.DeleteOAuthConnectionAsync(CurrentUser.Id, provider); + + if (!deleted) + return NotFound(); + + return NoContent(); + } } \ No newline at end of file diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index ea2fb04f..d270e444 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -445,9 +445,21 @@ public async Task TryVerifyEmailAsync(string token, CancellationToken canc return nChanges > 0; } - public Task GetOAuthConnectionsAsync(Guid accountId) + public async Task GetOAuthConnectionsAsync(Guid accountId) { - return _db.OAuthConnections.AsNoTracking().Where(c => c.UserId == accountId).ToArrayAsync(); + return await _db.OAuthConnections + .AsNoTracking() + .Where(c => c.UserId == accountId) + .ToArrayAsync(); + } + + public async Task DeleteOAuthConnectionAsync(Guid currentUserId, string provider) + { + var nDeleted = await _db.OAuthConnections + .Where(c => c.UserId == currentUserId && c.OAuthProvider == provider) + .ExecuteDeleteAsync(); + + return nDeleted > 0; } private async Task CheckPassword(string password, User user) diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 42473ca6..abb4e1c2 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -112,6 +112,7 @@ public interface IAccountService Task TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default); Task GetOAuthConnectionsAsync(Guid accountId); + Task DeleteOAuthConnectionAsync(Guid currentUserId, string provider); } public sealed record CreateUserLoginSessionSuccess(User User, string Token); From 7d8e2f3dfe4998538ed8023f12e8348b6b92e878 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Tue, 2 Sep 2025 22:25:50 +0200 Subject: [PATCH 22/63] Create AddConnection endpoint --- .../Authenticated/OAuthConnectionAdd.cs | 27 +++++++++++++++++++ .../Authenticated/OAuthConnectionRemove.cs | 22 +++++++++++++++ .../Authenticated/OAuthListConnections.cs | 16 ----------- API/Services/Account/AccountService.cs | 13 ++++++--- API/Services/Account/IAccountService.cs | 5 ++-- Common/Errors/OAuthError.cs | 3 +++ 6 files changed, 64 insertions(+), 22 deletions(-) create mode 100644 API/Controller/Account/Authenticated/OAuthConnectionAdd.cs create mode 100644 API/Controller/Account/Authenticated/OAuthConnectionRemove.cs diff --git a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs new file mode 100644 index 00000000..77948405 --- /dev/null +++ b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.API.Services.OAuth; +using OpenShock.Common.Errors; + +namespace OpenShock.API.Controller.Account.Authenticated; + +public sealed partial class AuthenticatedAccountController +{ + [HttpGet("connections/{provider}")] + public async Task AddOAuthConnection([FromQuery] string provider, [FromQuery(Name = "return_to")] string? returnTo, [FromServices] IOAuthHandlerRegistry registry) + { + if (!registry.TryGet(provider, out var handler)) + return Problem(OAuthError.ProviderNotSupported); + + if (await _accountService.HasOAuthConnectionAsync(CurrentUser.Id, provider)) + { + return Problem(OAuthError.AlreadyExists); + } + + var result = handler.BuildAuthorizeUrl(HttpContext, new OAuthStartContext(string.IsNullOrWhiteSpace(returnTo) ? null : returnTo)); + return result.Match( + Redirect, + error => Problem(title: error.Code, detail: error.Description) + ); + } +} \ No newline at end of file diff --git a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs new file mode 100644 index 00000000..b1e3755d --- /dev/null +++ b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; + +namespace OpenShock.API.Controller.Account.Authenticated; + +public sealed partial class AuthenticatedAccountController +{ + /// + /// Delete an OAuth connection by provider + /// + [HttpDelete("connections/{provider}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteOAuthConnection([FromRoute] string provider) + { + var deleted = await _accountService.DeleteOAuthConnectionAsync(CurrentUser.Id, provider); + + if (!deleted) + return NotFound(); + + return NoContent(); + } +} \ No newline at end of file diff --git a/API/Controller/Account/Authenticated/OAuthListConnections.cs b/API/Controller/Account/Authenticated/OAuthListConnections.cs index 6d1c7b03..630e3924 100644 --- a/API/Controller/Account/Authenticated/OAuthListConnections.cs +++ b/API/Controller/Account/Authenticated/OAuthListConnections.cs @@ -22,20 +22,4 @@ public async Task ListOAuthConnections() }) .ToArray(); } - - /// - /// Delete an OAuth connection by provider - /// - [HttpDelete("connections/{provider}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task DeleteOAuthConnection([FromRoute] string provider) - { - var deleted = await _accountService.DeleteOAuthConnectionAsync(CurrentUser.Id, provider); - - if (!deleted) - return NotFound(); - - return NoContent(); - } } \ No newline at end of file diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index d270e444..74a83c37 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -445,18 +445,23 @@ public async Task TryVerifyEmailAsync(string token, CancellationToken canc return nChanges > 0; } - public async Task GetOAuthConnectionsAsync(Guid accountId) + public async Task GetOAuthConnectionsAsync(Guid userId) { return await _db.OAuthConnections .AsNoTracking() - .Where(c => c.UserId == accountId) + .Where(c => c.UserId == userId) .ToArrayAsync(); } - public async Task DeleteOAuthConnectionAsync(Guid currentUserId, string provider) + public async Task HasOAuthConnectionAsync(Guid userId, string provider) + { + return await _db.OAuthConnections.AnyAsync(c => c.UserId == userId && c.OAuthProvider == provider); + } + + public async Task DeleteOAuthConnectionAsync(Guid userId, string provider) { var nDeleted = await _db.OAuthConnections - .Where(c => c.UserId == currentUserId && c.OAuthProvider == provider) + .Where(c => c.UserId == userId && c.OAuthProvider == provider) .ExecuteDeleteAsync(); return nDeleted > 0; diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index abb4e1c2..9e921763 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -111,8 +111,9 @@ public interface IAccountService /// Task TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default); - Task GetOAuthConnectionsAsync(Guid accountId); - Task DeleteOAuthConnectionAsync(Guid currentUserId, string provider); + Task GetOAuthConnectionsAsync(Guid userId); + Task HasOAuthConnectionAsync(Guid userId, string provider); + Task DeleteOAuthConnectionAsync(Guid userId, string provider); } public sealed record CreateUserLoginSessionSuccess(User User, string Token); diff --git a/Common/Errors/OAuthError.cs b/Common/Errors/OAuthError.cs index 6c5a6f0e..f648726d 100644 --- a/Common/Errors/OAuthError.cs +++ b/Common/Errors/OAuthError.cs @@ -7,4 +7,7 @@ public static class OAuthError { public static OpenShockProblem ProviderNotSupported => new OpenShockProblem( "OAuth.Provider.NotSupported", "This OAuth provider is not supported", HttpStatusCode.Forbidden); + + public static OpenShockProblem AlreadyExists => new OpenShockProblem( + "OAuth.Connections.AlreadyExists", "There is already an OAuth connection of this type in your account", HttpStatusCode.Conflict); } \ No newline at end of file From 19c2e9d920c128b183fd965cfb699cdcbbef7c0e Mon Sep 17 00:00:00 2001 From: hhvrc Date: Wed, 3 Sep 2025 00:23:43 +0200 Subject: [PATCH 23/63] Oops --- API/Controller/Account/Authenticated/OAuthConnectionAdd.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs index 77948405..07a2eccd 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs @@ -8,7 +8,7 @@ namespace OpenShock.API.Controller.Account.Authenticated; public sealed partial class AuthenticatedAccountController { [HttpGet("connections/{provider}")] - public async Task AddOAuthConnection([FromQuery] string provider, [FromQuery(Name = "return_to")] string? returnTo, [FromServices] IOAuthHandlerRegistry registry) + public async Task AddOAuthConnection([FromRoute] string provider, [FromQuery(Name = "return_to")] string? returnTo, [FromServices] IOAuthHandlerRegistry registry) { if (!registry.TryGet(provider, out var handler)) return Problem(OAuthError.ProviderNotSupported); From 094d43a8b0902a9154f9a8d221cce4c1cb683213 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Wed, 3 Sep 2025 00:52:16 +0200 Subject: [PATCH 24/63] Add TryAdd --- .../Authenticated/OAuthConnectionRemove.cs | 2 +- API/Services/Account/AccountService.cs | 24 ++++++++++++++++++- API/Services/Account/IAccountService.cs | 4 +++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs index b1e3755d..ca4c354a 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs @@ -12,7 +12,7 @@ public sealed partial class AuthenticatedAccountController [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task DeleteOAuthConnection([FromRoute] string provider) { - var deleted = await _accountService.DeleteOAuthConnectionAsync(CurrentUser.Id, provider); + var deleted = await _accountService.TryRemoveOAuthConnectionAsync(CurrentUser.Id, provider); if (!deleted) return NotFound(); diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 74a83c37..20b04d2d 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -1,6 +1,7 @@ using System.Net.Mail; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using Npgsql; using OneOf; using OneOf.Types; using OpenShock.API.Services.Email; @@ -458,7 +459,28 @@ public async Task HasOAuthConnectionAsync(Guid userId, string provider) return await _db.OAuthConnections.AnyAsync(c => c.UserId == userId && c.OAuthProvider == provider); } - public async Task DeleteOAuthConnectionAsync(Guid userId, string provider) + public async Task TryAddOAuthConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName) + { + try + { + _db.OAuthConnections.Add(new OAuthConnection + { + UserId = userId, + OAuthProvider = provider, + OAuthAccountId = providerAccountId, + OAuthAccountName = providerAccountName + }); + await _db.SaveChangesAsync(); + } + catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" }) // Unique constaint violation + { + return false; + } + + return true; + } + + public async Task TryRemoveOAuthConnectionAsync(Guid userId, string provider) { var nDeleted = await _db.OAuthConnections .Where(c => c.UserId == userId && c.OAuthProvider == provider) diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 9e921763..669b9340 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -113,7 +113,9 @@ public interface IAccountService Task GetOAuthConnectionsAsync(Guid userId); Task HasOAuthConnectionAsync(Guid userId, string provider); - Task DeleteOAuthConnectionAsync(Guid userId, string provider); + Task TryAddOAuthConnectionAsync(Guid userId, string provider, string providerAccountId, + string? providerAccountName); + Task TryRemoveOAuthConnectionAsync(Guid userId, string provider); } public sealed record CreateUserLoginSessionSuccess(User User, string Token); From eb53769ce6423f88b4f0d173dcf43662f48286da Mon Sep 17 00:00:00 2001 From: hhvrc Date: Wed, 3 Sep 2025 01:10:38 +0200 Subject: [PATCH 25/63] Fix FK issue and rename DB Model --- API/Services/Account/AccountService.cs | 10 +++++----- API/Services/Account/IAccountService.cs | 2 +- Common/OpenShockDb/OpenShockContext.cs | 8 ++++---- Common/OpenShockDb/User.cs | 2 +- .../{OAuthConnection.cs => UserOAuthConnection.cs} | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) rename Common/OpenShockDb/{OAuthConnection.cs => UserOAuthConnection.cs} (90%) diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 20b04d2d..702460d4 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -446,9 +446,9 @@ public async Task TryVerifyEmailAsync(string token, CancellationToken canc return nChanges > 0; } - public async Task GetOAuthConnectionsAsync(Guid userId) + public async Task GetOAuthConnectionsAsync(Guid userId) { - return await _db.OAuthConnections + return await _db.UserOAuthConnections .AsNoTracking() .Where(c => c.UserId == userId) .ToArrayAsync(); @@ -456,14 +456,14 @@ public async Task GetOAuthConnectionsAsync(Guid userId) public async Task HasOAuthConnectionAsync(Guid userId, string provider) { - return await _db.OAuthConnections.AnyAsync(c => c.UserId == userId && c.OAuthProvider == provider); + return await _db.UserOAuthConnections.AnyAsync(c => c.UserId == userId && c.OAuthProvider == provider); } public async Task TryAddOAuthConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName) { try { - _db.OAuthConnections.Add(new OAuthConnection + _db.UserOAuthConnections.Add(new UserOAuthConnection { UserId = userId, OAuthProvider = provider, @@ -482,7 +482,7 @@ public async Task TryAddOAuthConnectionAsync(Guid userId, string provider, public async Task TryRemoveOAuthConnectionAsync(Guid userId, string provider) { - var nDeleted = await _db.OAuthConnections + var nDeleted = await _db.UserOAuthConnections .Where(c => c.UserId == userId && c.OAuthProvider == provider) .ExecuteDeleteAsync(); diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 669b9340..c254e191 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -111,7 +111,7 @@ public interface IAccountService /// Task TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default); - Task GetOAuthConnectionsAsync(Guid userId); + Task GetOAuthConnectionsAsync(Guid userId); Task HasOAuthConnectionAsync(Guid userId, string provider); Task TryAddOAuthConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName); diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index c452965b..2cb8f7f4 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -106,7 +106,7 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet Users { get; set; } - public DbSet OAuthConnections { get; set; } + public DbSet UserOAuthConnections { get; set; } public DbSet UserActivationRequests { get; set; } @@ -613,11 +613,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasColumnName("activated_at"); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { - entity.HasKey(e => e.UserId).HasName("user_oauth_connections_pkey"); + entity.HasKey(e => new { e.OAuthProvider, e.OAuthAccountId }).HasName("user_oauth_connections_pkey"); - entity.HasIndex(e => new { e.OAuthProvider, e.OAuthAccountId }).IsUnique(); + entity.HasIndex(e => e.UserId); entity.ToTable("user_oauth_connections"); diff --git a/Common/OpenShockDb/User.cs b/Common/OpenShockDb/User.cs index 8dfd5b74..9594ecfc 100644 --- a/Common/OpenShockDb/User.cs +++ b/Common/OpenShockDb/User.cs @@ -20,7 +20,7 @@ public sealed class User // Navigations public UserActivationRequest? UserActivationRequest { get; set; } public UserDeactivation? UserDeactivation { get; set; } - public ICollection OAuthConnections { get; set; } = []; + public ICollection OAuthConnections { get; set; } = []; public ICollection ApiTokens { get; } = []; public ICollection ReportedApiTokens { get; } = []; public ICollection Devices { get; } = []; diff --git a/Common/OpenShockDb/OAuthConnection.cs b/Common/OpenShockDb/UserOAuthConnection.cs similarity index 90% rename from Common/OpenShockDb/OAuthConnection.cs rename to Common/OpenShockDb/UserOAuthConnection.cs index 7ff0a642..78723905 100644 --- a/Common/OpenShockDb/OAuthConnection.cs +++ b/Common/OpenShockDb/UserOAuthConnection.cs @@ -1,6 +1,6 @@ namespace OpenShock.Common.OpenShockDb; -public sealed class OAuthConnection +public sealed class UserOAuthConnection { public required Guid UserId { get; set; } From 0df75802192c1b12c10c58c9523b09c45cdfa372 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Wed, 3 Sep 2025 13:57:36 +0200 Subject: [PATCH 26/63] Move controllers around a bit --- .../Authenticated/OAuthConnectionAdd.cs | 3 +- .../Authenticated/OAuthConnectionRemove.cs | 2 +- ...Connections.cs => OAuthConnectionsList.cs} | 6 ++-- API/Controller/Account/OAuthListProviders.cs | 17 ----------- .../OAuthStart.cs => OAuth/Authorize.cs} | 10 +++---- .../OAuthCallback.cs => OAuth/Callback.cs} | 16 ++++------- API/Controller/OAuth/ListProviders.cs | 15 ++++++++++ API/Controller/OAuth/_ApiController.cs | 28 +++++++++++++++++++ .../Response/OAuthConnectionResponse.cs | 6 ++-- API/Services/Account/AccountService.cs | 10 +++---- API/Services/OAuth/IOAuthHandlerRegistry.cs | 6 ++-- API/Services/OAuth/OAuthHandlerRegistry.cs | 9 ++++-- Common/OpenShockDb/OpenShockContext.cs | 14 +++++----- Common/OpenShockDb/UserOAuthConnection.cs | 6 ++-- 14 files changed, 89 insertions(+), 59 deletions(-) rename API/Controller/Account/Authenticated/{OAuthListConnections.cs => OAuthConnectionsList.cs} (79%) delete mode 100644 API/Controller/Account/OAuthListProviders.cs rename API/Controller/{Account/OAuthStart.cs => OAuth/Authorize.cs} (61%) rename API/Controller/{Account/OAuthCallback.cs => OAuth/Callback.cs} (59%) create mode 100644 API/Controller/OAuth/ListProviders.cs create mode 100644 API/Controller/OAuth/_ApiController.cs diff --git a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs index 07a2eccd..231fa864 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Services.OAuth; using OpenShock.Common.Errors; @@ -7,7 +6,7 @@ namespace OpenShock.API.Controller.Account.Authenticated; public sealed partial class AuthenticatedAccountController { - [HttpGet("connections/{provider}")] + [HttpPost("connections/{provider}/authorize")] public async Task AddOAuthConnection([FromRoute] string provider, [FromQuery(Name = "return_to")] string? returnTo, [FromServices] IOAuthHandlerRegistry registry) { if (!registry.TryGet(provider, out var handler)) diff --git a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs index ca4c354a..a9b2c5bd 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs @@ -10,7 +10,7 @@ public sealed partial class AuthenticatedAccountController [HttpDelete("connections/{provider}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task DeleteOAuthConnection([FromRoute] string provider) + public async Task RemoveOAuthConnection([FromRoute] string provider) { var deleted = await _accountService.TryRemoveOAuthConnectionAsync(CurrentUser.Id, provider); diff --git a/API/Controller/Account/Authenticated/OAuthListConnections.cs b/API/Controller/Account/Authenticated/OAuthConnectionsList.cs similarity index 79% rename from API/Controller/Account/Authenticated/OAuthListConnections.cs rename to API/Controller/Account/Authenticated/OAuthConnectionsList.cs index 630e3924..67094e29 100644 --- a/API/Controller/Account/Authenticated/OAuthListConnections.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionsList.cs @@ -17,8 +17,10 @@ public async Task ListOAuthConnections() return connections .Select(c => new OAuthConnectionResponse { - ProviderName = c.OAuthProvider, - ProviderAccountName = c.OAuthAccountName + ProviderKey = c.ProviderKey, + ExternalId = c.ExternalId, + DisplayName = c.DisplayName, + LinkedAt = c.CreatedAt }) .ToArray(); } diff --git a/API/Controller/Account/OAuthListProviders.cs b/API/Controller/Account/OAuthListProviders.cs deleted file mode 100644 index ffa141ed..00000000 --- a/API/Controller/Account/OAuthListProviders.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using OpenShock.API.Services.OAuth; -using OpenShock.Common.Authentication; - -namespace OpenShock.API.Controller.Account; - -public sealed partial class AccountController -{ - /// - /// Returns a list of supported SSO providers - /// - [HttpGet("oauth/providers")] - public string[] ListOAuthProviders([FromServices] IOAuthHandlerRegistry registry) - { - return registry.ListProviders(); - } -} diff --git a/API/Controller/Account/OAuthStart.cs b/API/Controller/OAuth/Authorize.cs similarity index 61% rename from API/Controller/Account/OAuthStart.cs rename to API/Controller/OAuth/Authorize.cs index e3e9bda1..40b71c1e 100644 --- a/API/Controller/Account/OAuthStart.cs +++ b/API/Controller/OAuth/Authorize.cs @@ -3,15 +3,15 @@ using OpenShock.API.Services.OAuth; using OpenShock.Common.Errors; -namespace OpenShock.API.Controller.Account; +namespace OpenShock.API.Controller.OAuth; -public sealed partial class AccountController +public sealed partial class OAuthController { [EnableRateLimiting("auth")] - [HttpGet("oauth/start")] - public IActionResult OAuthAuthenticate([FromQuery] string provider, [FromQuery(Name = "return_to")] string? returnTo, [FromServices] IOAuthHandlerRegistry registry) + [HttpPost("{provider}/authorize")] + public IActionResult OAuthAuthorize([FromRoute] string provider, [FromQuery(Name = "return_to")] string? returnTo) { - if (!registry.TryGet(provider, out var handler)) + if (!_registry.TryGet(provider, out var handler)) return Problem(OAuthError.ProviderNotSupported); var result = handler.BuildAuthorizeUrl(HttpContext, new OAuthStartContext(string.IsNullOrWhiteSpace(returnTo) ? null : returnTo)); diff --git a/API/Controller/Account/OAuthCallback.cs b/API/Controller/OAuth/Callback.cs similarity index 59% rename from API/Controller/Account/OAuthCallback.cs rename to API/Controller/OAuth/Callback.cs index bab383fa..110d2a2c 100644 --- a/API/Controller/Account/OAuthCallback.cs +++ b/API/Controller/OAuth/Callback.cs @@ -1,20 +1,14 @@ -using System.Diagnostics; -using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.RateLimiting; -using OpenShock.API.Services.OAuth; using OpenShock.Common.Errors; -namespace OpenShock.API.Controller.Account; +namespace OpenShock.API.Controller.OAuth; -public sealed partial class AccountController +public sealed partial class OAuthController { - [EnableRateLimiting("auth")] - [HttpGet("oauth/callback/{provider}")] - [EnableCors("allow_sso_providers")] - public async Task OAuthAuthenticate([FromRoute] string provider, [FromServices] IOAuthHandlerRegistry registry) + [HttpGet("{provider}/callback")] + public async Task OAuthCallback([FromRoute] string provider) { - if (!registry.TryGet(provider, out var handler)) + if (!_registry.TryGet(provider, out var handler)) return Problem(OAuthError.ProviderNotSupported); // Let the handler do everything (state validation, token exchange, user fetch) diff --git a/API/Controller/OAuth/ListProviders.cs b/API/Controller/OAuth/ListProviders.cs new file mode 100644 index 00000000..a1eb26f0 --- /dev/null +++ b/API/Controller/OAuth/ListProviders.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Mvc; + +namespace OpenShock.API.Controller.OAuth; + +public sealed partial class OAuthController +{ + /// + /// Returns a list of supported SSO provider keys + /// + [HttpGet("providers")] + public string[] ListOAuthProviders() + { + return _registry.ListProviderKeys(); + } +} diff --git a/API/Controller/OAuth/_ApiController.cs b/API/Controller/OAuth/_ApiController.cs new file mode 100644 index 00000000..f9deb463 --- /dev/null +++ b/API/Controller/OAuth/_ApiController.cs @@ -0,0 +1,28 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using OpenShock.API.Services.Account; +using OpenShock.API.Services.OAuth; +using OpenShock.Common; + +namespace OpenShock.API.Controller.OAuth; + +/// +/// OAuth management +/// +[ApiController] +[Tags("OAuth")] +[ApiVersion("1")] +[Route("/{version:apiVersion}/oauth")] +public sealed partial class OAuthController : OpenShockControllerBase +{ + private readonly IAccountService _accountService; + private readonly IOAuthHandlerRegistry _registry; + private readonly ILogger _logger; + + public OAuthController(IAccountService accountService, IOAuthHandlerRegistry registry, ILogger logger) + { + _accountService = accountService; + _registry = registry; + _logger = logger; + } +} \ No newline at end of file diff --git a/API/Models/Response/OAuthConnectionResponse.cs b/API/Models/Response/OAuthConnectionResponse.cs index 0104d85e..313b1bdc 100644 --- a/API/Models/Response/OAuthConnectionResponse.cs +++ b/API/Models/Response/OAuthConnectionResponse.cs @@ -2,6 +2,8 @@ public sealed class OAuthConnectionResponse { - public required string ProviderName { get; init; } - public required string? ProviderAccountName { get; init; } + public required string ProviderKey { get; init; } + public required string ExternalId { get; init; } + public required string? DisplayName { get; init; } + public required DateTime LinkedAt { get; init; } } \ No newline at end of file diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 702460d4..7815897e 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -456,7 +456,7 @@ public async Task GetOAuthConnectionsAsync(Guid userId) public async Task HasOAuthConnectionAsync(Guid userId, string provider) { - return await _db.UserOAuthConnections.AnyAsync(c => c.UserId == userId && c.OAuthProvider == provider); + return await _db.UserOAuthConnections.AnyAsync(c => c.UserId == userId && c.ProviderKey == provider); } public async Task TryAddOAuthConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName) @@ -466,9 +466,9 @@ public async Task TryAddOAuthConnectionAsync(Guid userId, string provider, _db.UserOAuthConnections.Add(new UserOAuthConnection { UserId = userId, - OAuthProvider = provider, - OAuthAccountId = providerAccountId, - OAuthAccountName = providerAccountName + ProviderKey = provider, + ExternalId = providerAccountId, + DisplayName = providerAccountName }); await _db.SaveChangesAsync(); } @@ -483,7 +483,7 @@ public async Task TryAddOAuthConnectionAsync(Guid userId, string provider, public async Task TryRemoveOAuthConnectionAsync(Guid userId, string provider) { var nDeleted = await _db.UserOAuthConnections - .Where(c => c.UserId == userId && c.OAuthProvider == provider) + .Where(c => c.UserId == userId && c.ProviderKey == provider) .ExecuteDeleteAsync(); return nDeleted > 0; diff --git a/API/Services/OAuth/IOAuthHandlerRegistry.cs b/API/Services/OAuth/IOAuthHandlerRegistry.cs index 9374350c..84a4b4ed 100644 --- a/API/Services/OAuth/IOAuthHandlerRegistry.cs +++ b/API/Services/OAuth/IOAuthHandlerRegistry.cs @@ -1,7 +1,9 @@ -namespace OpenShock.API.Services.OAuth; +using OpenShock.API.Models.Response; + +namespace OpenShock.API.Services.OAuth; public interface IOAuthHandlerRegistry { - string[] ListProviders(); + string[] ListProviderKeys(); bool TryGet(string key, out IOAuthHandler handler); } \ No newline at end of file diff --git a/API/Services/OAuth/OAuthHandlerRegistry.cs b/API/Services/OAuth/OAuthHandlerRegistry.cs index f827ef78..ab43a466 100644 --- a/API/Services/OAuth/OAuthHandlerRegistry.cs +++ b/API/Services/OAuth/OAuthHandlerRegistry.cs @@ -1,4 +1,6 @@ -namespace OpenShock.API.Services.OAuth; +using OpenShock.API.Models.Response; + +namespace OpenShock.API.Services.OAuth; public sealed class OAuthHandlerRegistry : IOAuthHandlerRegistry { @@ -9,7 +11,10 @@ public OAuthHandlerRegistry(IEnumerable handlers) _handlers = handlers.ToDictionary(h => h.Key, h => h, StringComparer.OrdinalIgnoreCase); } - public string[] ListProviders() => _handlers.Keys.ToArray(); + public string[] ListProviderKeys() + { + return _handlers.Keys.ToArray(); + } public bool TryGet(string key, out IOAuthHandler handler) => _handlers.TryGetValue(key, out handler!); diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 2cb8f7f4..f210b1ba 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -615,7 +615,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { - entity.HasKey(e => new { e.OAuthProvider, e.OAuthAccountId }).HasName("user_oauth_connections_pkey"); + entity.HasKey(e => new { e.ProviderKey, e.ExternalId }).HasName("user_oauth_connections_pkey"); entity.HasIndex(e => e.UserId); @@ -623,13 +623,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.UserId) .HasColumnName("user_id"); - entity.Property(e => e.OAuthProvider) + entity.Property(e => e.ProviderKey) .UseCollation("C") - .HasColumnName("oauth_provider"); - entity.Property(e => e.OAuthAccountId) - .HasColumnName("oauth_account_id"); - entity.Property(e => e.OAuthAccountName) - .HasColumnName("oauth_account_name"); + .HasColumnName("provider_key"); + entity.Property(e => e.ExternalId) + .HasColumnName("external_id"); + entity.Property(e => e.DisplayName) + .HasColumnName("display_name"); entity.Property(e => e.CreatedAt) .HasDefaultValueSql("CURRENT_TIMESTAMP") .HasColumnName("created_at"); diff --git a/Common/OpenShockDb/UserOAuthConnection.cs b/Common/OpenShockDb/UserOAuthConnection.cs index 78723905..a20091a9 100644 --- a/Common/OpenShockDb/UserOAuthConnection.cs +++ b/Common/OpenShockDb/UserOAuthConnection.cs @@ -4,11 +4,11 @@ public sealed class UserOAuthConnection { public required Guid UserId { get; set; } - public required string OAuthProvider { get; set; } + public required string ProviderKey { get; set; } - public required string OAuthAccountId { get; set; } + public required string ExternalId { get; set; } - public required string? OAuthAccountName { get; set; } + public required string? DisplayName { get; set; } public DateTime CreatedAt { get; set; } From 98ea99efa2c2456bd47b2ce77c020cc877f92580 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Wed, 3 Sep 2025 14:39:54 +0200 Subject: [PATCH 27/63] More improvements --- .../Authenticated/OAuthConnectionAdd.cs | 8 +- API/Controller/OAuth/Authorize.cs | 11 +- API/Services/OAuth/CookieOAuthStore.cs | 30 --- .../OAuth/Discord/DiscordOAuthHandler.cs | 182 +++++++++++++----- API/Services/OAuth/IOAuthHandler.cs | 4 +- API/Services/OAuth/IOAuthStateStore.cs | 22 +++ API/Services/OAuth/IOAuthStore.cs | 7 - .../OAuth/OAuthServiceCollectionExtensions.cs | 2 +- API/Services/OAuth/RedisOAuthStateStore.cs | 101 ++++++++++ 9 files changed, 275 insertions(+), 92 deletions(-) delete mode 100644 API/Services/OAuth/CookieOAuthStore.cs create mode 100644 API/Services/OAuth/IOAuthStateStore.cs delete mode 100644 API/Services/OAuth/IOAuthStore.cs create mode 100644 API/Services/OAuth/RedisOAuthStateStore.cs diff --git a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs index 231fa864..0ad3aae9 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs @@ -17,7 +17,13 @@ public async Task AddOAuthConnection([FromRoute] string provider, return Problem(OAuthError.AlreadyExists); } - var result = handler.BuildAuthorizeUrl(HttpContext, new OAuthStartContext(string.IsNullOrWhiteSpace(returnTo) ? null : returnTo)); + // Private authorize endpoint => Link flow + var ctx = new OAuthStartContext( + ReturnTo: string.IsNullOrWhiteSpace(returnTo) ? null : returnTo, + Flow: OAuthFlow.Link + ); + + var result = await handler.BuildAuthorizeUrlAsync(HttpContext, ctx); return result.Match( Redirect, error => Problem(title: error.Code, detail: error.Description) diff --git a/API/Controller/OAuth/Authorize.cs b/API/Controller/OAuth/Authorize.cs index 40b71c1e..7f40534a 100644 --- a/API/Controller/OAuth/Authorize.cs +++ b/API/Controller/OAuth/Authorize.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Services.OAuth; using OpenShock.Common.Errors; +using System.Threading.Tasks; namespace OpenShock.API.Controller.OAuth; @@ -9,12 +10,18 @@ public sealed partial class OAuthController { [EnableRateLimiting("auth")] [HttpPost("{provider}/authorize")] - public IActionResult OAuthAuthorize([FromRoute] string provider, [FromQuery(Name = "return_to")] string? returnTo) + public async Task OAuthAuthorize([FromRoute] string provider, [FromQuery(Name = "return_to")] string? returnTo) { if (!_registry.TryGet(provider, out var handler)) return Problem(OAuthError.ProviderNotSupported); - var result = handler.BuildAuthorizeUrl(HttpContext, new OAuthStartContext(string.IsNullOrWhiteSpace(returnTo) ? null : returnTo)); + // Public authorize endpoint => SignIn flow + var ctx = new OAuthStartContext( + ReturnTo: string.IsNullOrWhiteSpace(returnTo) ? null : returnTo, + Flow: OAuthFlow.SignIn + ); + + var result = await handler.BuildAuthorizeUrlAsync(HttpContext, ctx); return result.Match( Redirect, error => Problem(title: error.Code, detail: error.Description) diff --git a/API/Services/OAuth/CookieOAuthStore.cs b/API/Services/OAuth/CookieOAuthStore.cs deleted file mode 100644 index 1c0a6e0c..00000000 --- a/API/Services/OAuth/CookieOAuthStore.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace OpenShock.API.Services.OAuth; - -public sealed class CookieOAuthStateStore : IOAuthStateStore -{ - private const string CookiePrefix = "__os_oauth_state_"; - - public void Save(HttpContext http, string provider, string state, string? returnTo) - { - var val = $"{state}|{returnTo}"; - http.Response.Cookies.Append(CookiePrefix + provider, val, new CookieOptions - { - HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax, - Expires = DateTimeOffset.UtcNow.AddMinutes(10), Path = "/" - }); - } - - public (string State, string? ReturnTo)? ReadAndClear(HttpContext http, string provider) - { - var name = CookiePrefix + provider; - if (!http.Request.Cookies.TryGetValue(name, out var v)) return null; - - http.Response.Cookies.Delete(name, new CookieOptions { Path = "/", HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax }); - - var i = v.IndexOf('|'); - if (i < 0) return (v, null); - var s = v[..i]; - var r = v[(i + 1)..]; - return (s, string.IsNullOrWhiteSpace(r) ? null : r); - } -} \ No newline at end of file diff --git a/API/Services/OAuth/Discord/DiscordOAuthHandler.cs b/API/Services/OAuth/Discord/DiscordOAuthHandler.cs index 5f0f1a36..f0a09320 100644 --- a/API/Services/OAuth/Discord/DiscordOAuthHandler.cs +++ b/API/Services/OAuth/Discord/DiscordOAuthHandler.cs @@ -1,9 +1,9 @@ -using OneOf; -using System.Net.Http.Headers; -using System.Text.Json; -using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Http.Extensions; using Microsoft.Extensions.Options; +using OneOf; using OpenShock.Common.Utils; +using System.Net.Http.Headers; +using System.Text.Json; namespace OpenShock.API.Services.OAuth.Discord; @@ -12,88 +12,172 @@ public sealed class DiscordOAuthHandler : IOAuthHandler private const string AuthorizeEndpoint = "https://discord.com/oauth2/authorize"; private const string TokenEndpoint = "https://discord.com/api/oauth2/token"; private const string UserInfoEndpoint = "https://discord.com/api/users/@me"; - - private const string CallbackPath ="/1/account/oauth/callback/discord"; - + + private const string CallbackPath = "/1/account/oauth/callback/discord"; + private readonly IHttpClientFactory _http; - private readonly IOptions _opt; - private readonly IOAuthStateStore _state; + private readonly DiscordOAuthOptions _opt; + private readonly IOAuthStateStore _stateStore; - public DiscordOAuthHandler(IHttpClientFactory http, IOptions opt, IOAuthStateStore state) + public DiscordOAuthHandler( + IHttpClientFactory http, + IOptions opt, + IOAuthStateStore stateStore) { _http = http; - _opt = opt; - _state = state; + _opt = opt.Value; + _stateStore = stateStore; } public string Key => "discord"; - public OneOf BuildAuthorizeUrl(HttpContext http, OAuthStartContext ctx) + public async Task> BuildAuthorizeUrlAsync(HttpContext http, OAuthStartContext ctx) { - var o = _opt.Value; - var callback = new Uri(new Uri("https://api.openhshock.dev"), CallbackPath).ToString(); // TODO: Make the base URL dynamic somehow + if (string.IsNullOrWhiteSpace(_opt.ClientId)) + return new OAuthErrorResult("config_error", "Discord OAuth is not configured."); + + var callback = BuildCallbackUrl(); + if (callback is null) + return new OAuthErrorResult("config_error", "Callback base URL is not configured."); + + // Opaque nonce for state + var nonce = CryptoUtils.RandomString(64); + + // Save full envelope in Redis with TTL + var env = new OAuthStateEnvelope( + Provider: Key, + State: nonce, + Flow: ctx.Flow, + ReturnTo: ctx.ReturnTo, + UserId: null, // set if you add an authenticated “link” endpoint + CodeVerifier: null, // add PKCE later if desired + CreatedAt: DateTimeOffset.UtcNow + ); - var state = CryptoUtils.RandomString(64); - _state.Save(http, Key, state, ctx.ReturnTo); + // 10 minutes is plenty + await _stateStore.SaveAsync(http, env, TimeSpan.FromMinutes(10)); + // Build Discord authorize URL var qb = new QueryBuilder { { "response_type", "code" }, - { "client_id", o.ClientId }, + { "client_id", _opt.ClientId }, { "scope", "identify email" }, { "redirect_uri", callback }, - { "state", state } + { "state", nonce } }; - return new UriBuilder(AuthorizeEndpoint) { Query = qb.ToString() }.Uri.ToString(); + + var url = new UriBuilder(AuthorizeEndpoint) { Query = qb.ToString() }.Uri.ToString(); + return url; } public async Task> HandleCallbackAsync(HttpContext http, IQueryCollection query) { - var o = _opt.Value; + if (string.IsNullOrWhiteSpace(_opt.ClientId) || string.IsNullOrWhiteSpace(_opt.ClientSecret)) + return new OAuthErrorResult("config_error", "Discord OAuth is not configured."); - var code = query["code"].ToString(); + var code = query["code"].ToString(); var state = query["state"].ToString(); + if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state)) - throw new InvalidOperationException("Missing code/state"); + return new OAuthErrorResult("invalid_request", "Missing 'code' or 'state'."); - var saved = _state.ReadAndClear(http, Key); - if (saved is null || !string.Equals(saved.Value.State, state, StringComparison.Ordinal)) - throw new InvalidOperationException("Invalid state"); + var env = await _stateStore.ReadAndClearAsync(http, Key, state); + if (env is null) + return new OAuthErrorResult("state_invalid", "Invalid or expired state."); - var callback = new Uri(new Uri("https://api.openhshock.dev"), CallbackPath).ToString(); // TODO: Make the base URL dynamic somehow + var callback = BuildCallbackUrl(); + if (callback is null) + return new OAuthErrorResult("config_error", "Callback base URL is not configured."); + var ct = http.RequestAborted; var client = _http.CreateClient(); - using var tokenReq = new HttpRequestMessage(HttpMethod.Post, TokenEndpoint) + + // Exchange code for token + var accessResult = await ExchangeCodeForAccessTokenAsync(client, code, callback, ct); + if (accessResult.TryPickT1(out var tokenErr, out var accessToken)) + return tokenErr; + + // Fetch user info + var userResult = await FetchDiscordUserAsync(client, accessToken, ct); + if (userResult.TryPickT1(out var userErr, out var me)) + return userErr; + + var externalId = me.GetProperty("id").GetString()!; + var username = me.GetProperty("username").GetString(); + string? email = me.TryGetProperty("email", out var emailEl) ? emailEl.GetString() : null; + + var user = new ExternalUser( + Provider: Key, + ExternalId: externalId, + Username: username, + Email: email, + AvatarUrl: null + ); + + http.Items["oauth_flow"] = env.Flow; + + return new OAuthCallbackResult(user); + } + + // ------------------ + // Helper methods + // ------------------ + + private string? BuildCallbackUrl() + { + try + { + return new Uri(new Uri("https://api.openhshock.dev"), CallbackPath).ToString(); + } + catch + { + return null; + } + } + + private async Task> ExchangeCodeForAccessTokenAsync( + HttpClient client, + string code, + string callback, + CancellationToken ct) + { + using var request = new HttpRequestMessage(HttpMethod.Post, TokenEndpoint) { - Content = new FormUrlEncodedContent(new Dictionary + Content = new FormUrlEncodedContent(new Dictionary { - ["client_id"] = o.ClientId, - ["client_secret"] = o.ClientSecret, + ["client_id"] = _opt.ClientId, + ["client_secret"] = _opt.ClientSecret, ["grant_type"] = "authorization_code", ["code"] = code, ["redirect_uri"] = callback }) }; - using var tokenRes = await client.SendAsync(tokenReq); - tokenRes.EnsureSuccessStatusCode(); + using var response = await client.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + return new OAuthErrorResult("token_exchange_failed", $"Token exchange failed ({(int)response.StatusCode})."); - var token = JsonSerializer.Deserialize(await tokenRes.Content.ReadAsStringAsync()); - var access = token.GetProperty("access_token").GetString()!; + var tokenEl = await response.Content.ReadFromJsonAsync(ct); - using var meReq = new HttpRequestMessage(HttpMethod.Get, UserInfoEndpoint); - meReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", access); - using var meRes = await client.SendAsync(meReq); - meRes.EnsureSuccessStatusCode(); + if (!tokenEl.TryGetProperty("access_token", out var accessEl) || + string.IsNullOrWhiteSpace(accessEl.GetString())) + return new OAuthErrorResult("token_exchange_failed", "No access token from provider."); - var me = JsonSerializer.Deserialize(await meRes.Content.ReadAsStringAsync()); - var user = new ExternalUser( - Provider: Key, - ExternalId: me.GetProperty("id").GetString()!, - Username: me.GetProperty("username").GetString(), - Email: me.GetProperty("email").GetString(), - AvatarUrl: null // build if you need it - ); + return accessEl.GetString()!; + } - return new OAuthCallbackResult(user); + private async Task> FetchDiscordUserAsync( + HttpClient client, + string accessToken, + CancellationToken ct) + { + using var request = new HttpRequestMessage(HttpMethod.Get, UserInfoEndpoint); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + using var response = await client.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + return new OAuthErrorResult("profile_fetch_failed", $"Failed to fetch user profile ({(int)response.StatusCode})."); + + return await response.Content.ReadFromJsonAsync(ct); } -} \ No newline at end of file +} diff --git a/API/Services/OAuth/IOAuthHandler.cs b/API/Services/OAuth/IOAuthHandler.cs index 5c64ed61..a8c708f3 100644 --- a/API/Services/OAuth/IOAuthHandler.cs +++ b/API/Services/OAuth/IOAuthHandler.cs @@ -9,7 +9,7 @@ public sealed record ExternalUser( string? Email, // provider email string? AvatarUrl); -public sealed record OAuthStartContext(string? ReturnTo); +public sealed record OAuthStartContext(string? ReturnTo, OAuthFlow Flow); public sealed record OAuthCallbackResult(ExternalUser User); public sealed record OAuthErrorResult(string Code, string Description); @@ -20,7 +20,7 @@ public interface IOAuthHandler string Key { get; } /// Build the provider authorize URL and set any cookies you need (state, pkce, return_to). - OneOf BuildAuthorizeUrl(HttpContext http, OAuthStartContext ctx); + Task> BuildAuthorizeUrlAsync(HttpContext http, OAuthStartContext ctx); /// Handle callback: validate state, exchange code, fetch user, clear cookies, etc. Task> HandleCallbackAsync(HttpContext http, IQueryCollection query); diff --git a/API/Services/OAuth/IOAuthStateStore.cs b/API/Services/OAuth/IOAuthStateStore.cs new file mode 100644 index 00000000..39b15f6a --- /dev/null +++ b/API/Services/OAuth/IOAuthStateStore.cs @@ -0,0 +1,22 @@ +namespace OpenShock.API.Services.OAuth; + +public interface IOAuthStateStore +{ + Task SaveAsync(HttpContext http, OAuthStateEnvelope envelope, TimeSpan ttl); + Task ReadAndClearAsync(HttpContext http, string provider, string state); +} + +public enum OAuthFlow +{ + SignIn, + Link +} +public sealed record OAuthStateEnvelope( + string Provider, + string State, // opaque nonce + OAuthFlow Flow, // SignIn | Link + string? ReturnTo, // optional allow-listed redirect + Guid? UserId, // set for Link flow + string? CodeVerifier, // if using PKCE + DateTimeOffset CreatedAt +); \ No newline at end of file diff --git a/API/Services/OAuth/IOAuthStore.cs b/API/Services/OAuth/IOAuthStore.cs deleted file mode 100644 index bfe70178..00000000 --- a/API/Services/OAuth/IOAuthStore.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace OpenShock.API.Services.OAuth; - -public interface IOAuthStateStore -{ - void Save(HttpContext http, string provider, string state, string? returnTo); - (string State, string? ReturnTo)? ReadAndClear(HttpContext http, string provider); -} \ No newline at end of file diff --git a/API/Services/OAuth/OAuthServiceCollectionExtensions.cs b/API/Services/OAuth/OAuthServiceCollectionExtensions.cs index bb19a24b..0caec263 100644 --- a/API/Services/OAuth/OAuthServiceCollectionExtensions.cs +++ b/API/Services/OAuth/OAuthServiceCollectionExtensions.cs @@ -8,7 +8,7 @@ public static class OAuthServiceCollectionExtensions public static IOAuthBuilder AddOAuth(this IServiceCollection services) { // Default state store if none registered - services.TryAddSingleton(); + services.TryAddSingleton(); // Registry built from IEnumerable services.TryAddSingleton(); diff --git a/API/Services/OAuth/RedisOAuthStateStore.cs b/API/Services/OAuth/RedisOAuthStateStore.cs new file mode 100644 index 00000000..35055826 --- /dev/null +++ b/API/Services/OAuth/RedisOAuthStateStore.cs @@ -0,0 +1,101 @@ +using Redis.OM.Contracts; +using Redis.OM.Modeling; +using Redis.OM.Searching; + +namespace OpenShock.API.Services.OAuth; + +public sealed class RedisOAuthStateStore : IOAuthStateStore +{ + private const string CookiePrefix = "__os_oauth_state_"; + + private readonly IRedisCollection _states; + + public RedisOAuthStateStore(IRedisConnectionProvider redis) + { + // No indexing needed for lookups by Id, but JSON storage is convenient + _states = redis.RedisCollection(false); + } + + public async Task SaveAsync(HttpContext http, OAuthStateEnvelope env, TimeSpan ttl) + { + // Persist server-side (source of truth) + var entry = Map(env); + await _states.InsertAsync(entry, ttl); + + // Double-submit cookie with the same nonce (no signing/encryption needed) + http.Response.Cookies.Append(CookiePrefix + env.Provider, env.State, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + Expires = DateTimeOffset.UtcNow.Add(ttl), + Path = "/" + }); + } + + public async Task ReadAndClearAsync(HttpContext http, string provider, string state) + { + // Optional: verify cookie matches the returned state (defense-in-depth) + var cookieName = CookiePrefix + provider; + if (!http.Request.Cookies.TryGetValue(cookieName, out var cookieState) || + !string.Equals(cookieState, state, StringComparison.Ordinal)) + { + return null; + } + + // Remove cookie regardless of Redis hit (one-time use) + http.Response.Cookies.Delete(cookieName, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + Path = "/" + }); + + // Load & delete atomically-ish (best-effort; Redis OM lacks multi here, but TTL + delete is fine) + var id = OAuthStateEntry.MakeId(provider, state); + var entry = await _states.FindByIdAsync(id); + if (entry is null) + return null; + + await _states.DeleteAsync(entry); + return Map(entry); + } + + private static OAuthStateEntry Map(OAuthStateEnvelope e) => new() + { + Id = OAuthStateEntry.MakeId(e.Provider, e.State), + Provider = e.Provider, + State = e.State, + Flow = e.Flow, + ReturnTo = e.ReturnTo, + UserId = e.UserId, + CodeVerifier = e.CodeVerifier, + CreatedAt = e.CreatedAt.UtcDateTime + }; + + private static OAuthStateEnvelope Map(OAuthStateEntry e) => new( + Provider: e.Provider, + State: e.State, + Flow: e.Flow, + ReturnTo: e.ReturnTo, + UserId: e.UserId, + CodeVerifier: e.CodeVerifier, + CreatedAt: DateTime.SpecifyKind(e.CreatedAt, DateTimeKind.Utc)); + + // Redis JSON document + [Document(StorageType = StorageType.Json, Prefixes = new[] { "oauth:state" })] + public sealed class OAuthStateEntry + { + [RedisIdField] public string Id { get; set; } = default!; // oauth:state:{provider}:{state} + public string Provider { get; set; } = default!; + public string State { get; set; } = default!; + public OAuthFlow Flow { get; set; } + public string? ReturnTo { get; set; } + public Guid? UserId { get; set; } + public string? CodeVerifier { get; set; } + public DateTime CreatedAt { get; set; } + + public static string MakeId(string provider, string state) => $"{provider}:{state}"; + } +} \ No newline at end of file From 065b6a449a18e5eb386a7e738dc26ec3921d356d Mon Sep 17 00:00:00 2001 From: hhvrc Date: Wed, 3 Sep 2025 15:47:26 +0200 Subject: [PATCH 28/63] Clean up more stuff --- .../Authenticated/OAuthConnectionAdd.cs | 18 ++-- API/Controller/OAuth/Authorize.cs | 18 ++-- API/Controller/OAuth/Callback.cs | 24 +++-- API/Program.cs | 2 +- .../OAuth/Discord/DiscordOAuthHandler.cs | 89 +++++-------------- API/Services/OAuth/IOAuthBuilder.cs | 2 +- API/Services/OAuth/IOAuthHandler.cs | 13 +-- API/Services/OAuth/IOAuthHandlerRegistry.cs | 8 +- API/Services/OAuth/IOAuthStateStore.cs | 3 +- API/Services/OAuth/OAuthBuilder.cs | 2 +- API/Services/OAuth/OAuthHandlerRegistry.cs | 54 ++++++++++- API/Services/OAuth/RedisOAuthStateStore.cs | 17 ++-- 12 files changed, 117 insertions(+), 133 deletions(-) diff --git a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs index 0ad3aae9..c760ee41 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs @@ -7,26 +7,18 @@ namespace OpenShock.API.Controller.Account.Authenticated; public sealed partial class AuthenticatedAccountController { [HttpPost("connections/{provider}/authorize")] - public async Task AddOAuthConnection([FromRoute] string provider, [FromQuery(Name = "return_to")] string? returnTo, [FromServices] IOAuthHandlerRegistry registry) + public async Task AddOAuthConnection([FromRoute] string provider, [FromQuery(Name = "return_to")] string returnTo, [FromServices] IOAuthHandlerRegistry registry) { - if (!registry.TryGet(provider, out var handler)) - return Problem(OAuthError.ProviderNotSupported); - if (await _accountService.HasOAuthConnectionAsync(CurrentUser.Id, provider)) { return Problem(OAuthError.AlreadyExists); } - // Private authorize endpoint => Link flow - var ctx = new OAuthStartContext( - ReturnTo: string.IsNullOrWhiteSpace(returnTo) ? null : returnTo, - Flow: OAuthFlow.Link - ); - - var result = await handler.BuildAuthorizeUrlAsync(HttpContext, ctx); + var result = await registry.StartAuthorizeAsync(HttpContext, provider, OAuthFlow.Link, returnTo); return result.Match( - Redirect, - error => Problem(title: error.Code, detail: error.Description) + uri => Redirect(uri.ToString()), + error => Problem(title: error.Code, detail: error.Description), + notSupported => Problem(OAuthError.ProviderNotSupported) ); } } \ No newline at end of file diff --git a/API/Controller/OAuth/Authorize.cs b/API/Controller/OAuth/Authorize.cs index 7f40534a..6ae94c1c 100644 --- a/API/Controller/OAuth/Authorize.cs +++ b/API/Controller/OAuth/Authorize.cs @@ -10,21 +10,13 @@ public sealed partial class OAuthController { [EnableRateLimiting("auth")] [HttpPost("{provider}/authorize")] - public async Task OAuthAuthorize([FromRoute] string provider, [FromQuery(Name = "return_to")] string? returnTo) + public async Task OAuthAuthorize([FromRoute] string provider, [FromQuery(Name = "return_to")] string returnTo) { - if (!_registry.TryGet(provider, out var handler)) - return Problem(OAuthError.ProviderNotSupported); - - // Public authorize endpoint => SignIn flow - var ctx = new OAuthStartContext( - ReturnTo: string.IsNullOrWhiteSpace(returnTo) ? null : returnTo, - Flow: OAuthFlow.SignIn - ); - - var result = await handler.BuildAuthorizeUrlAsync(HttpContext, ctx); + var result = await _registry.StartAuthorizeAsync(HttpContext, provider, OAuthFlow.SignIn, returnTo); return result.Match( - Redirect, - error => Problem(title: error.Code, detail: error.Description) + uri => Redirect(uri.ToString()), + error => Problem(title: error.Code, detail: error.Description), + notSupported => Problem(OAuthError.ProviderNotSupported) ); } } \ No newline at end of file diff --git a/API/Controller/OAuth/Callback.cs b/API/Controller/OAuth/Callback.cs index 110d2a2c..6f14231a 100644 --- a/API/Controller/OAuth/Callback.cs +++ b/API/Controller/OAuth/Callback.cs @@ -8,20 +8,18 @@ public sealed partial class OAuthController [HttpGet("{provider}/callback")] public async Task OAuthCallback([FromRoute] string provider) { - if (!_registry.TryGet(provider, out var handler)) - return Problem(OAuthError.ProviderNotSupported); - // Let the handler do everything (state validation, token exchange, user fetch) - var result = await handler.HandleCallbackAsync(HttpContext, Request.Query); - if (!result.TryPickT0(out var contract, out var error)) - { - return BadRequest(); // TODO: Change me - } - - // >>> Your app-specific login/linking <<< - // e.g., sign in / create session by result.User + var result = await _registry.HandleCallbackAsync(HttpContext, provider, Request.Query); + return result.Match( + ok => + { + // >>> Your app-specific login/linking <<< + // e.g., sign in / create session by result.User - // Decide where to go next (consider a per-provider default or read from state store if you saved return_to) - return Redirect("https://app.openshock.app/auth/callback/" + handler.Key); // or your chosen target + return Redirect(ok.CallbackUrl); + }, + error => BadRequest(), // TODO: Change me + notSupported => Problem(OAuthError.ProviderNotSupported) + ); } } \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 4858eda5..707e1802 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -56,7 +56,7 @@ builder.Services.AddScoped(); builder.Services.AddOAuth() - .AddHandler("discord", builder.Configuration.GetRequiredSection(DiscordOAuthOptions.SectionName)); + .AddHandler(builder.Configuration.GetRequiredSection(DiscordOAuthOptions.SectionName)); builder.AddSwaggerExt(); diff --git a/API/Services/OAuth/Discord/DiscordOAuthHandler.cs b/API/Services/OAuth/Discord/DiscordOAuthHandler.cs index f0a09320..62dba317 100644 --- a/API/Services/OAuth/Discord/DiscordOAuthHandler.cs +++ b/API/Services/OAuth/Discord/DiscordOAuthHandler.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Http.Extensions; using Microsoft.Extensions.Options; using OneOf; -using OpenShock.Common.Utils; using System.Net.Http.Headers; using System.Text.Json; @@ -9,11 +8,7 @@ namespace OpenShock.API.Services.OAuth.Discord; public sealed class DiscordOAuthHandler : IOAuthHandler { - private const string AuthorizeEndpoint = "https://discord.com/oauth2/authorize"; - private const string TokenEndpoint = "https://discord.com/api/oauth2/token"; - private const string UserInfoEndpoint = "https://discord.com/api/users/@me"; - - private const string CallbackPath = "/1/account/oauth/callback/discord"; + private static readonly string[] Scopes = ["identify", "email"]; private readonly IHttpClientFactory _http; private readonly DiscordOAuthOptions _opt; @@ -30,52 +25,32 @@ public DiscordOAuthHandler( } public string Key => "discord"; + public string AuthorizeEndpoint => "https://discord.com/oauth2/authorize"; + public string TokenEndpoint => "https://discord.com/api/oauth2/token"; + public string UserInfoEndpoint => "https://discord.com/api/users/@me"; - public async Task> BuildAuthorizeUrlAsync(HttpContext http, OAuthStartContext ctx) + public Uri BuildAuthorizeUrl(string state, Uri callbackUrl) { - if (string.IsNullOrWhiteSpace(_opt.ClientId)) - return new OAuthErrorResult("config_error", "Discord OAuth is not configured."); - - var callback = BuildCallbackUrl(); - if (callback is null) - return new OAuthErrorResult("config_error", "Callback base URL is not configured."); - - // Opaque nonce for state - var nonce = CryptoUtils.RandomString(64); - - // Save full envelope in Redis with TTL - var env = new OAuthStateEnvelope( - Provider: Key, - State: nonce, - Flow: ctx.Flow, - ReturnTo: ctx.ReturnTo, - UserId: null, // set if you add an authenticated “link” endpoint - CodeVerifier: null, // add PKCE later if desired - CreatedAt: DateTimeOffset.UtcNow - ); - - // 10 minutes is plenty - await _stateStore.SaveAsync(http, env, TimeSpan.FromMinutes(10)); - - // Build Discord authorize URL - var qb = new QueryBuilder + var queryBuilder = new QueryBuilder { { "response_type", "code" }, - { "client_id", _opt.ClientId }, - { "scope", "identify email" }, - { "redirect_uri", callback }, - { "state", nonce } + { "client_id", _opt.ClientId }, + { "scope", string.Join(' ', Scopes) }, + { "redirect_uri", callbackUrl.ToString() }, + { "prompt", "none" }, + { "state", state } }; - var url = new UriBuilder(AuthorizeEndpoint) { Query = qb.ToString() }.Uri.ToString(); - return url; + var uriBuilder = new UriBuilder(AuthorizeEndpoint) + { + Query = queryBuilder.ToString() + }; + + return uriBuilder.Uri; } - public async Task> HandleCallbackAsync(HttpContext http, IQueryCollection query) + public async Task> HandleCallbackAsync(HttpContext http, IQueryCollection query, Uri callbackUrl) { - if (string.IsNullOrWhiteSpace(_opt.ClientId) || string.IsNullOrWhiteSpace(_opt.ClientSecret)) - return new OAuthErrorResult("config_error", "Discord OAuth is not configured."); - var code = query["code"].ToString(); var state = query["state"].ToString(); @@ -86,15 +61,11 @@ public async Task> HandleCallbackAs if (env is null) return new OAuthErrorResult("state_invalid", "Invalid or expired state."); - var callback = BuildCallbackUrl(); - if (callback is null) - return new OAuthErrorResult("config_error", "Callback base URL is not configured."); - var ct = http.RequestAborted; var client = _http.CreateClient(); // Exchange code for token - var accessResult = await ExchangeCodeForAccessTokenAsync(client, code, callback, ct); + var accessResult = await ExchangeCodeForAccessTokenAsync(client, code, callbackUrl, ct); if (accessResult.TryPickT1(out var tokenErr, out var accessToken)) return tokenErr; @@ -117,29 +88,13 @@ public async Task> HandleCallbackAs http.Items["oauth_flow"] = env.Flow; - return new OAuthCallbackResult(user); - } - - // ------------------ - // Helper methods - // ------------------ - - private string? BuildCallbackUrl() - { - try - { - return new Uri(new Uri("https://api.openhshock.dev"), CallbackPath).ToString(); - } - catch - { - return null; - } + return new OAuthCallbackResult(user, env.ReturnTo); } private async Task> ExchangeCodeForAccessTokenAsync( HttpClient client, string code, - string callback, + Uri callbackUrl, CancellationToken ct) { using var request = new HttpRequestMessage(HttpMethod.Post, TokenEndpoint) @@ -150,7 +105,7 @@ private async Task> ExchangeCodeForAccessTokenAs ["client_secret"] = _opt.ClientSecret, ["grant_type"] = "authorization_code", ["code"] = code, - ["redirect_uri"] = callback + ["redirect_uri"] = callbackUrl.ToString(), }) }; using var response = await client.SendAsync(request, ct); diff --git a/API/Services/OAuth/IOAuthBuilder.cs b/API/Services/OAuth/IOAuthBuilder.cs index 377ee8c4..df13500f 100644 --- a/API/Services/OAuth/IOAuthBuilder.cs +++ b/API/Services/OAuth/IOAuthBuilder.cs @@ -2,7 +2,7 @@ public interface IOAuthBuilder { - IOAuthBuilder AddHandler(string key, IConfiguration configuration) + IOAuthBuilder AddHandler(IConfiguration configuration) where THandler : class, IOAuthHandler where TOptions : class; } \ No newline at end of file diff --git a/API/Services/OAuth/IOAuthHandler.cs b/API/Services/OAuth/IOAuthHandler.cs index a8c708f3..fbc16e7f 100644 --- a/API/Services/OAuth/IOAuthHandler.cs +++ b/API/Services/OAuth/IOAuthHandler.cs @@ -9,19 +9,20 @@ public sealed record ExternalUser( string? Email, // provider email string? AvatarUrl); -public sealed record OAuthStartContext(string? ReturnTo, OAuthFlow Flow); -public sealed record OAuthCallbackResult(ExternalUser User); +public sealed record OAuthCallbackResult(ExternalUser User, string CallbackUrl); public sealed record OAuthErrorResult(string Code, string Description); public interface IOAuthHandler { - /// A short, case-insensitive key (e.g., "discord"). string Key { get; } + string AuthorizeEndpoint { get; } + string TokenEndpoint { get; } + string UserInfoEndpoint { get; } - /// Build the provider authorize URL and set any cookies you need (state, pkce, return_to). - Task> BuildAuthorizeUrlAsync(HttpContext http, OAuthStartContext ctx); + /// Build the provider authorize URL + Uri BuildAuthorizeUrl(string state, Uri callbackUrl); /// Handle callback: validate state, exchange code, fetch user, clear cookies, etc. - Task> HandleCallbackAsync(HttpContext http, IQueryCollection query); + Task> HandleCallbackAsync(HttpContext http, IQueryCollection query, Uri callbackUrl); } \ No newline at end of file diff --git a/API/Services/OAuth/IOAuthHandlerRegistry.cs b/API/Services/OAuth/IOAuthHandlerRegistry.cs index 84a4b4ed..8aad4a4e 100644 --- a/API/Services/OAuth/IOAuthHandlerRegistry.cs +++ b/API/Services/OAuth/IOAuthHandlerRegistry.cs @@ -1,4 +1,4 @@ -using OpenShock.API.Models.Response; +using OneOf; namespace OpenShock.API.Services.OAuth; @@ -6,4 +6,8 @@ public interface IOAuthHandlerRegistry { string[] ListProviderKeys(); bool TryGet(string key, out IOAuthHandler handler); -} \ No newline at end of file + Task> StartAuthorizeAsync(HttpContext http, string provider, OAuthFlow flow, string returnTo); + Task> HandleCallbackAsync(HttpContext http, string provider, IQueryCollection query); +} + +public readonly record struct OAuthProviderNotSupported; \ No newline at end of file diff --git a/API/Services/OAuth/IOAuthStateStore.cs b/API/Services/OAuth/IOAuthStateStore.cs index 39b15f6a..1ed69218 100644 --- a/API/Services/OAuth/IOAuthStateStore.cs +++ b/API/Services/OAuth/IOAuthStateStore.cs @@ -15,8 +15,7 @@ public sealed record OAuthStateEnvelope( string Provider, string State, // opaque nonce OAuthFlow Flow, // SignIn | Link - string? ReturnTo, // optional allow-listed redirect + string ReturnTo, Guid? UserId, // set for Link flow - string? CodeVerifier, // if using PKCE DateTimeOffset CreatedAt ); \ No newline at end of file diff --git a/API/Services/OAuth/OAuthBuilder.cs b/API/Services/OAuth/OAuthBuilder.cs index 95f6ba23..b140b129 100644 --- a/API/Services/OAuth/OAuthBuilder.cs +++ b/API/Services/OAuth/OAuthBuilder.cs @@ -5,7 +5,7 @@ internal sealed class OAuthBuilder : IOAuthBuilder private readonly IServiceCollection _services; internal OAuthBuilder(IServiceCollection services) => _services = services; - public IOAuthBuilder AddHandler(string key,IConfiguration configuration) + public IOAuthBuilder AddHandler(IConfiguration configuration) where THandler : class, IOAuthHandler where TOptions : class { diff --git a/API/Services/OAuth/OAuthHandlerRegistry.cs b/API/Services/OAuth/OAuthHandlerRegistry.cs index ab43a466..d131b72b 100644 --- a/API/Services/OAuth/OAuthHandlerRegistry.cs +++ b/API/Services/OAuth/OAuthHandlerRegistry.cs @@ -1,21 +1,67 @@ -using OpenShock.API.Models.Response; +using OneOf; +using OpenShock.Common.Utils; namespace OpenShock.API.Services.OAuth; public sealed class OAuthHandlerRegistry : IOAuthHandlerRegistry { private readonly Dictionary _handlers; + private readonly IOAuthStateStore _state; - public OAuthHandlerRegistry(IEnumerable handlers) + public OAuthHandlerRegistry(IEnumerable handlers, IOAuthStateStore state) { _handlers = handlers.ToDictionary(h => h.Key, h => h, StringComparer.OrdinalIgnoreCase); + _state = state; } - + public string[] ListProviderKeys() { return _handlers.Keys.ToArray(); } public bool TryGet(string key, out IOAuthHandler handler) - => _handlers.TryGetValue(key, out handler!); + { + return _handlers.TryGetValue(key, out handler!); + } + + private Uri GetCallbackUri(string provider) + { + return new Uri($"https://api.openshock.app/1/oauth/{provider}/callback"); + } + + public async Task> StartAuthorizeAsync(HttpContext http, string provider, OAuthFlow flow, string returnTo) + { + if (!TryGet(provider, out var handler)) + return new OAuthProviderNotSupported(); + + // Generate state and persist in Redis (+ double-submit cookie inside store) + var stateNonce = CryptoUtils.RandomString(64); + var env = new OAuthStateEnvelope( + Provider: handler.Key, + State: stateNonce, + Flow: flow, + ReturnTo: returnTo, + UserId: null, + CreatedAt: DateTimeOffset.UtcNow + ); + + await _state.SaveAsync(http, env, TimeSpan.FromMinutes(10)); + + // Delegate URL construction to the handler (includes redirect_uri & scopes) + return handler.BuildAuthorizeUrl(stateNonce, GetCallbackUri(provider)); + } + + public async Task> HandleCallbackAsync(HttpContext http, string provider, IQueryCollection query) + { + if (!TryGet(provider, out var handler)) + return new OAuthProviderNotSupported(); + + var result = await handler.HandleCallbackAsync(http, query, GetCallbackUri(provider)); + if (result.TryPickT1(out var error, out var info)) + { + return error; + } + + return info; + } } \ No newline at end of file diff --git a/API/Services/OAuth/RedisOAuthStateStore.cs b/API/Services/OAuth/RedisOAuthStateStore.cs index 35055826..8ffc1bff 100644 --- a/API/Services/OAuth/RedisOAuthStateStore.cs +++ b/API/Services/OAuth/RedisOAuthStateStore.cs @@ -70,7 +70,6 @@ public async Task SaveAsync(HttpContext http, OAuthStateEnvelope env, TimeSpan t Flow = e.Flow, ReturnTo = e.ReturnTo, UserId = e.UserId, - CodeVerifier = e.CodeVerifier, CreatedAt = e.CreatedAt.UtcDateTime }; @@ -80,21 +79,19 @@ public async Task SaveAsync(HttpContext http, OAuthStateEnvelope env, TimeSpan t Flow: e.Flow, ReturnTo: e.ReturnTo, UserId: e.UserId, - CodeVerifier: e.CodeVerifier, CreatedAt: DateTime.SpecifyKind(e.CreatedAt, DateTimeKind.Utc)); // Redis JSON document [Document(StorageType = StorageType.Json, Prefixes = new[] { "oauth:state" })] public sealed class OAuthStateEntry { - [RedisIdField] public string Id { get; set; } = default!; // oauth:state:{provider}:{state} - public string Provider { get; set; } = default!; - public string State { get; set; } = default!; - public OAuthFlow Flow { get; set; } - public string? ReturnTo { get; set; } - public Guid? UserId { get; set; } - public string? CodeVerifier { get; set; } - public DateTime CreatedAt { get; set; } + [RedisIdField] public required string Id { get; set; } // oauth:state:{provider}:{state} + public required string Provider { get; set; } + public required string State { get; set; } + public required OAuthFlow Flow { get; set; } + public required string ReturnTo { get; set; } + public required Guid? UserId { get; set; } + public required DateTime CreatedAt { get; set; } public static string MakeId(string provider, string state) => $"{provider}:{state}"; } From a89bff0eb99164b4e203c109d3db6cb20125bf2a Mon Sep 17 00:00:00 2001 From: hhvrc Date: Wed, 3 Sep 2025 15:54:11 +0200 Subject: [PATCH 29/63] Reduce filecount --- API/Services/OAuth/IOAuthBuilder.cs | 8 ---- API/Services/OAuth/IOAuthHandlerRegistry.cs | 1 - API/Services/OAuth/OAuthBuilder.cs | 22 --------- API/Services/OAuth/OAuthHandlerRegistry.cs | 9 +--- .../OAuth/OAuthServiceCollectionExtensions.cs | 18 -------- .../OAuth/ServiceCollectionHelpers.cs | 45 +++++++++++++++++++ 6 files changed, 47 insertions(+), 56 deletions(-) delete mode 100644 API/Services/OAuth/IOAuthBuilder.cs delete mode 100644 API/Services/OAuth/OAuthBuilder.cs delete mode 100644 API/Services/OAuth/OAuthServiceCollectionExtensions.cs create mode 100644 API/Services/OAuth/ServiceCollectionHelpers.cs diff --git a/API/Services/OAuth/IOAuthBuilder.cs b/API/Services/OAuth/IOAuthBuilder.cs deleted file mode 100644 index df13500f..00000000 --- a/API/Services/OAuth/IOAuthBuilder.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace OpenShock.API.Services.OAuth; - -public interface IOAuthBuilder -{ - IOAuthBuilder AddHandler(IConfiguration configuration) - where THandler : class, IOAuthHandler - where TOptions : class; -} \ No newline at end of file diff --git a/API/Services/OAuth/IOAuthHandlerRegistry.cs b/API/Services/OAuth/IOAuthHandlerRegistry.cs index 8aad4a4e..a7ee982a 100644 --- a/API/Services/OAuth/IOAuthHandlerRegistry.cs +++ b/API/Services/OAuth/IOAuthHandlerRegistry.cs @@ -5,7 +5,6 @@ namespace OpenShock.API.Services.OAuth; public interface IOAuthHandlerRegistry { string[] ListProviderKeys(); - bool TryGet(string key, out IOAuthHandler handler); Task> StartAuthorizeAsync(HttpContext http, string provider, OAuthFlow flow, string returnTo); Task> HandleCallbackAsync(HttpContext http, string provider, IQueryCollection query); } diff --git a/API/Services/OAuth/OAuthBuilder.cs b/API/Services/OAuth/OAuthBuilder.cs deleted file mode 100644 index b140b129..00000000 --- a/API/Services/OAuth/OAuthBuilder.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace OpenShock.API.Services.OAuth; - -internal sealed class OAuthBuilder : IOAuthBuilder -{ - private readonly IServiceCollection _services; - internal OAuthBuilder(IServiceCollection services) => _services = services; - - public IOAuthBuilder AddHandler(IConfiguration configuration) - where THandler : class, IOAuthHandler - where TOptions : class - { - _services.Configure(configuration); - - // Typed HttpClient per handler (unique type = unique client) - _services.AddHttpClient(); - - // Register handler as IOAuthHandler - _services.AddSingleton(); - - return this; - } -} \ No newline at end of file diff --git a/API/Services/OAuth/OAuthHandlerRegistry.cs b/API/Services/OAuth/OAuthHandlerRegistry.cs index d131b72b..3ac1acca 100644 --- a/API/Services/OAuth/OAuthHandlerRegistry.cs +++ b/API/Services/OAuth/OAuthHandlerRegistry.cs @@ -19,11 +19,6 @@ public string[] ListProviderKeys() return _handlers.Keys.ToArray(); } - public bool TryGet(string key, out IOAuthHandler handler) - { - return _handlers.TryGetValue(key, out handler!); - } - private Uri GetCallbackUri(string provider) { return new Uri($"https://api.openshock.app/1/oauth/{provider}/callback"); @@ -31,7 +26,7 @@ private Uri GetCallbackUri(string provider) public async Task> StartAuthorizeAsync(HttpContext http, string provider, OAuthFlow flow, string returnTo) { - if (!TryGet(provider, out var handler)) + if (!_handlers.TryGetValue(provider, out var handler)) return new OAuthProviderNotSupported(); // Generate state and persist in Redis (+ double-submit cookie inside store) @@ -53,7 +48,7 @@ public async Task> Start public async Task> HandleCallbackAsync(HttpContext http, string provider, IQueryCollection query) { - if (!TryGet(provider, out var handler)) + if (!_handlers.TryGetValue(provider, out var handler)) return new OAuthProviderNotSupported(); var result = await handler.HandleCallbackAsync(http, query, GetCallbackUri(provider)); diff --git a/API/Services/OAuth/OAuthServiceCollectionExtensions.cs b/API/Services/OAuth/OAuthServiceCollectionExtensions.cs deleted file mode 100644 index 0caec263..00000000 --- a/API/Services/OAuth/OAuthServiceCollectionExtensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.AspNetCore.Authentication.OAuth; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace OpenShock.API.Services.OAuth; - -public static class OAuthServiceCollectionExtensions -{ - public static IOAuthBuilder AddOAuth(this IServiceCollection services) - { - // Default state store if none registered - services.TryAddSingleton(); - - // Registry built from IEnumerable - services.TryAddSingleton(); - - return new OAuthBuilder(services); - } -} \ No newline at end of file diff --git a/API/Services/OAuth/ServiceCollectionHelpers.cs b/API/Services/OAuth/ServiceCollectionHelpers.cs new file mode 100644 index 00000000..ea274311 --- /dev/null +++ b/API/Services/OAuth/ServiceCollectionHelpers.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace OpenShock.API.Services.OAuth; + +public interface IOAuthBuilder +{ + IOAuthBuilder AddHandler(IConfiguration configuration) + where THandler : class, IOAuthHandler + where TOptions : class; +} + +internal sealed class OAuthBuilder : IOAuthBuilder +{ + private readonly IServiceCollection _services; + internal OAuthBuilder(IServiceCollection services) => _services = services; + + public IOAuthBuilder AddHandler(IConfiguration configuration) + where THandler : class, IOAuthHandler + where TOptions : class + { + _services.Configure(configuration); + + // Typed HttpClient per handler (unique type = unique client) + _services.AddHttpClient(); + + // Register handler as IOAuthHandler + _services.AddSingleton(); + + return this; + } +} + +public static class ServiceCollectionHelpers +{ + public static IOAuthBuilder AddOAuth(this IServiceCollection services) + { + // Default state store if none registered + services.TryAddSingleton(); + + // Registry built from IEnumerable + services.TryAddSingleton(); + + return new OAuthBuilder(services); + } +} \ No newline at end of file From 0059a65eb860c1b4528ae612997895eba28a6d7b Mon Sep 17 00:00:00 2001 From: hhvrc Date: Wed, 3 Sep 2025 17:58:50 +0200 Subject: [PATCH 30/63] Let's not reinvent the wheel... --- API/API.csproj | 1 + .../Authenticated/OAuthConnectionAdd.cs | 20 +-- API/Controller/OAuth/Authorize.cs | 22 --- API/Controller/OAuth/Callback.cs | 25 ---- API/Controller/OAuth/Complete.cs | 114 +++++++++++++++ API/Controller/OAuth/GetData.cs | 67 +++++++++ API/Controller/OAuth/ListProviders.cs | 6 +- API/Controller/OAuth/Login.cs | 20 +++ API/Controller/OAuth/_ApiController.cs | 5 +- .../AuthenticationSchemeProviderExtensions.cs | 27 ++++ API/OAuth/FlowStore/CacheOAuthFlowStore.cs | 29 ++++ API/OAuth/FlowStore/IOAuthFlowStore.cs | 8 + API/OAuth/OAuthPublic.cs | 10 ++ API/OAuth/OAuthSnapshot.cs | 9 ++ .../OAuth}/DiscordOAuthOptions.cs | 7 +- API/Program.cs | 38 ++++- API/Services/Account/AccountService.cs | 5 + API/Services/Account/IAccountService.cs | 1 + .../OAuth/Discord/DiscordOAuthHandler.cs | 138 ------------------ API/Services/OAuth/IOAuthHandler.cs | 28 ---- API/Services/OAuth/IOAuthHandlerRegistry.cs | 12 -- API/Services/OAuth/IOAuthStateStore.cs | 21 --- API/Services/OAuth/OAuthHandlerRegistry.cs | 62 -------- API/Services/OAuth/RedisOAuthStateStore.cs | 98 ------------- .../OAuth/ServiceCollectionHelpers.cs | 45 ------ Common/Authentication/OpenShockAuthSchemes.cs | 5 + Common/OpenShockServiceHelper.cs | 10 +- 27 files changed, 354 insertions(+), 479 deletions(-) delete mode 100644 API/Controller/OAuth/Authorize.cs delete mode 100644 API/Controller/OAuth/Callback.cs create mode 100644 API/Controller/OAuth/Complete.cs create mode 100644 API/Controller/OAuth/GetData.cs create mode 100644 API/Controller/OAuth/Login.cs create mode 100644 API/Extensions/AuthenticationSchemeProviderExtensions.cs create mode 100644 API/OAuth/FlowStore/CacheOAuthFlowStore.cs create mode 100644 API/OAuth/FlowStore/IOAuthFlowStore.cs create mode 100644 API/OAuth/OAuthPublic.cs create mode 100644 API/OAuth/OAuthSnapshot.cs rename API/{Services/OAuth/Discord => Options/OAuth}/DiscordOAuthOptions.cs (50%) delete mode 100644 API/Services/OAuth/Discord/DiscordOAuthHandler.cs delete mode 100644 API/Services/OAuth/IOAuthHandler.cs delete mode 100644 API/Services/OAuth/IOAuthHandlerRegistry.cs delete mode 100644 API/Services/OAuth/IOAuthStateStore.cs delete mode 100644 API/Services/OAuth/OAuthHandlerRegistry.cs delete mode 100644 API/Services/OAuth/RedisOAuthStateStore.cs delete mode 100644 API/Services/OAuth/ServiceCollectionHelpers.cs diff --git a/API/API.csproj b/API/API.csproj index 71e4d7ea..79724a3e 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -3,6 +3,7 @@ + diff --git a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs index c760ee41..0e32ff6e 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs @@ -1,24 +1,18 @@ +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; -using OpenShock.API.Services.OAuth; +using OpenShock.API.Extensions; using OpenShock.Common.Errors; namespace OpenShock.API.Controller.Account.Authenticated; public sealed partial class AuthenticatedAccountController { - [HttpPost("connections/{provider}/authorize")] - public async Task AddOAuthConnection([FromRoute] string provider, [FromQuery(Name = "return_to")] string returnTo, [FromServices] IOAuthHandlerRegistry registry) + [HttpGet("connections/{provider}/link")] + public async Task AddOAuthConnection([FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider) { - if (await _accountService.HasOAuthConnectionAsync(CurrentUser.Id, provider)) - { - return Problem(OAuthError.AlreadyExists); - } + if (!await schemeProvider.IsSupportedOAuthScheme(provider)) + return Problem(OAuthError.ProviderNotSupported); - var result = await registry.StartAuthorizeAsync(HttpContext, provider, OAuthFlow.Link, returnTo); - return result.Match( - uri => Redirect(uri.ToString()), - error => Problem(title: error.Code, detail: error.Description), - notSupported => Problem(OAuthError.ProviderNotSupported) - ); + return Challenge(new AuthenticationProperties { RedirectUri = $"/oauth/{provider}/complete", Parameters = {{ "flow", "link" }} }, authenticationSchemes: [provider]); } } \ No newline at end of file diff --git a/API/Controller/OAuth/Authorize.cs b/API/Controller/OAuth/Authorize.cs deleted file mode 100644 index 6ae94c1c..00000000 --- a/API/Controller/OAuth/Authorize.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.RateLimiting; -using OpenShock.API.Services.OAuth; -using OpenShock.Common.Errors; -using System.Threading.Tasks; - -namespace OpenShock.API.Controller.OAuth; - -public sealed partial class OAuthController -{ - [EnableRateLimiting("auth")] - [HttpPost("{provider}/authorize")] - public async Task OAuthAuthorize([FromRoute] string provider, [FromQuery(Name = "return_to")] string returnTo) - { - var result = await _registry.StartAuthorizeAsync(HttpContext, provider, OAuthFlow.SignIn, returnTo); - return result.Match( - uri => Redirect(uri.ToString()), - error => Problem(title: error.Code, detail: error.Description), - notSupported => Problem(OAuthError.ProviderNotSupported) - ); - } -} \ No newline at end of file diff --git a/API/Controller/OAuth/Callback.cs b/API/Controller/OAuth/Callback.cs deleted file mode 100644 index 6f14231a..00000000 --- a/API/Controller/OAuth/Callback.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using OpenShock.Common.Errors; - -namespace OpenShock.API.Controller.OAuth; - -public sealed partial class OAuthController -{ - [HttpGet("{provider}/callback")] - public async Task OAuthCallback([FromRoute] string provider) - { - // Let the handler do everything (state validation, token exchange, user fetch) - var result = await _registry.HandleCallbackAsync(HttpContext, provider, Request.Query); - return result.Match( - ok => - { - // >>> Your app-specific login/linking <<< - // e.g., sign in / create session by result.User - - return Redirect(ok.CallbackUrl); - }, - error => BadRequest(), // TODO: Change me - notSupported => Problem(OAuthError.ProviderNotSupported) - ); - } -} \ No newline at end of file diff --git a/API/Controller/OAuth/Complete.cs b/API/Controller/OAuth/Complete.cs new file mode 100644 index 00000000..ebcead7d --- /dev/null +++ b/API/Controller/OAuth/Complete.cs @@ -0,0 +1,114 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.API.Extensions; +using OpenShock.API.OAuth; +using OpenShock.API.Services.Account; +using OpenShock.Common.Authentication; +using OpenShock.Common.Errors; +using Scalar.AspNetCore; +using System.Security.Claims; + +namespace OpenShock.API.Controller.OAuth; + +public sealed partial class OAuthController +{ + [EnableRateLimiting("auth")] + [HttpGet("{provider}/complete")] + public async Task OAuthComplete([FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider, [FromServices] IAccountService accountService) + { + if (!await schemeProvider.IsSupportedOAuthScheme(provider)) + return Problem(OAuthError.ProviderNotSupported); + + // External principal placed by the OAuth handler (SaveTokens=true, SignInScheme=OAuthFlowScheme) + var auth = await HttpContext.AuthenticateAsync(OpenShockAuthSchemes.OAuthFlowScheme); + if (!auth.Succeeded || auth.Principal is null) + return BadRequest("OAuth sign-in not found or expired."); + + var ext = auth.Principal; + var props = auth.Properties; + + // Essentials from external identity + var externalId = ext.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? ext.FindFirst("sub")?.Value + ?? ext.FindFirst("id")?.Value; + + if (string.IsNullOrEmpty(externalId)) + return Problem("Missing external subject.", statusCode: 400); + + var email = ext.FindFirst(ClaimTypes.Email)?.Value; + var userName = ext.Identity?.Name; + + var tokens = (props?.GetTokens() ?? Enumerable.Empty()) + .ToDictionary(t => t.Name!, t => t.Value!); + + // Who (if anyone) is currently signed into OUR site? + var currentUserId = HttpContext.User?.FindFirst("uid")?.Value; + + // Is this external already linked to someone? + var connection = await accountService.GetOAuthConnectionAsync(provider, externalId); + + // CASE A: External already linked + if (connection is not null) + { + if (!string.IsNullOrEmpty(currentUserId)) + { + // Already logged in locally. + if (connection.UserId == currentUserId) + { + // Happy path: ensure session is fresh and go home. + await sessionIssuer.SignInAsync(HttpContext, connection.UserId); + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Redirect("/"); + } + + // Linked to a different local account → fail explicitly. + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem( + detail: "This external account is already linked to another user.", + statusCode: 409, + title: "Account already linked"); + } + + // Anonymous user: sign in as the linked account and go home. + await sessionIssuer.SignInAsync(HttpContext, connection.UserId); + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Redirect("/"); + } + + // CASE B: Not linked yet → create flow snapshot and send to frontend for link/create + var snapshot = new OAuthSnapshot( + Provider: provider, + ExternalId: externalId, + Email: email, + UserName: userName, + Tokens: tokens, + IssuedUtc: DateTimeOffset.UtcNow); + + var flowId = await store.SaveAsync(snapshot, OAuthFlow.Ttl); + + // Short-lived, non-HttpOnly cookie so the frontend can call /oauth/{provider}/data + Response.Cookies.Append( + OAuthFlow.TempCookie, + flowId, + new CookieOptions + { + Secure = HttpContext.Request.IsHttps, + HttpOnly = false, // readable by frontend JS for one fetch + SameSite = SameSiteMode.Lax, + Expires = DateTimeOffset.UtcNow.Add(OAuthFlow.Ttl), + Path = "/" + }); + + // Clean up the temp external principal + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + + // Decide which UI route to send them to + var frontend = Environment.GetEnvironmentVariable("FRONTEND_ORIGIN") ?? "https://app.example.com"; + var nextPath = (!string.IsNullOrEmpty(currentUserId)) + ? $"/{provider}/link" + : $"/{provider}/create"; + + return Redirect(frontend + nextPath); + } +} \ No newline at end of file diff --git a/API/Controller/OAuth/GetData.cs b/API/Controller/OAuth/GetData.cs new file mode 100644 index 00000000..aa7c5549 --- /dev/null +++ b/API/Controller/OAuth/GetData.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.API.Extensions; +using OpenShock.API.OAuth; +using OpenShock.API.OAuth.FlowStore; +using OpenShock.Common.Authentication; +using OpenShock.Common.Errors; +using Scalar.AspNetCore; + +namespace OpenShock.API.Controller.OAuth; + +public sealed partial class OAuthController +{ + [EnableRateLimiting("auth")] + [HttpGet("{provider}/data")] + public async Task OAuthGetData( + [FromRoute] string provider, + [FromServices] IAuthenticationSchemeProvider schemeProvider, + [FromServices] IOAuthFlowStore store) + { + if (!await schemeProvider.IsSupportedOAuthScheme(provider)) + return Problem(OAuthError.ProviderNotSupported); + + if (!Request.Cookies.TryGetValue(OpenShockAuthSchemes.OAuthFlowCookie, out var flowId) || + string.IsNullOrWhiteSpace(flowId)) + return NotFound(new { error = "no_flow" }); + + var snap = await store.GetAsync(flowId); + + if (snap is null) + { + // Clean up stale cookie to avoid client polling loops + Response.Cookies.Delete(OpenShockAuthSchemes.OAuthFlowCookie, new CookieOptions { Path = "/" }); + return NotFound(new { error = "expired" }); + } + + // Defensive: ensure the snapshot belongs to this provider + if (!string.Equals(snap.Provider, provider, StringComparison.OrdinalIgnoreCase)) + { + // Optional: you may also delete the cookie if you consider this a poisoned flow + Response.Cookies.Delete(OpenShockAuthSchemes.OAuthFlowCookie, new CookieOptions { Path = "/" }); + // Prefer NotFound to avoid leaking existence across providers + return NotFound(new { error = "provider_mismatch" }); + // Or: return Conflict(new { error = "provider_mismatch" }); + } + + var now = DateTimeOffset.UtcNow; + var expiresAt = snap.IssuedUtc.Add(OAuthFlow.Ttl); + var expiresIn = (int)Math.Max(0, (expiresAt - now).TotalSeconds); + + var dto = new OAuthPublic( + provider: snap.Provider, + externalId: snap.ExternalId, + email: snap.Email, + userName: snap.UserName, + flowId: flowId, + expiresInSeconds: expiresIn + ); + + // Don’t let proxies/browsers cache this + Response.Headers.CacheControl = "no-store"; + Response.Headers.Pragma = "no-cache"; + + return Ok(dto); + } +} diff --git a/API/Controller/OAuth/ListProviders.cs b/API/Controller/OAuth/ListProviders.cs index a1eb26f0..9fef8c98 100644 --- a/API/Controller/OAuth/ListProviders.cs +++ b/API/Controller/OAuth/ListProviders.cs @@ -1,4 +1,6 @@ +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; +using OpenShock.API.Extensions; namespace OpenShock.API.Controller.OAuth; @@ -8,8 +10,8 @@ public sealed partial class OAuthController /// Returns a list of supported SSO provider keys /// [HttpGet("providers")] - public string[] ListOAuthProviders() + public async Task ListOAuthProviders([FromServices] IAuthenticationSchemeProvider schemeProvider) { - return _registry.ListProviderKeys(); + return await schemeProvider.GetAllOAuthSchemesAsync(); } } diff --git a/API/Controller/OAuth/Login.cs b/API/Controller/OAuth/Login.cs new file mode 100644 index 00000000..c9dc6901 --- /dev/null +++ b/API/Controller/OAuth/Login.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.API.Extensions; +using OpenShock.Common.Errors; + +namespace OpenShock.API.Controller.OAuth; + +public sealed partial class OAuthController +{ + [EnableRateLimiting("auth")] + [HttpGet("{provider}/login")] + public async Task OAuthLogin([FromRoute] string provider, [FromQuery(Name = "return_to")] string returnTo, [FromServices] IAuthenticationSchemeProvider schemeProvider) + { + if (!await schemeProvider.IsSupportedOAuthScheme(provider)) + return Problem(OAuthError.ProviderNotSupported); + + return Challenge(new AuthenticationProperties { RedirectUri = $"/oauth/{provider}/complete", Parameters = { { "flow", "login" } } }, authenticationSchemes: [provider]); + } +} \ No newline at end of file diff --git a/API/Controller/OAuth/_ApiController.cs b/API/Controller/OAuth/_ApiController.cs index f9deb463..8ace3879 100644 --- a/API/Controller/OAuth/_ApiController.cs +++ b/API/Controller/OAuth/_ApiController.cs @@ -1,7 +1,6 @@ using Asp.Versioning; using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Account; -using OpenShock.API.Services.OAuth; using OpenShock.Common; namespace OpenShock.API.Controller.OAuth; @@ -16,13 +15,11 @@ namespace OpenShock.API.Controller.OAuth; public sealed partial class OAuthController : OpenShockControllerBase { private readonly IAccountService _accountService; - private readonly IOAuthHandlerRegistry _registry; private readonly ILogger _logger; - public OAuthController(IAccountService accountService, IOAuthHandlerRegistry registry, ILogger logger) + public OAuthController(IAccountService accountService, ILogger logger) { _accountService = accountService; - _registry = registry; _logger = logger; } } \ No newline at end of file diff --git a/API/Extensions/AuthenticationSchemeProviderExtensions.cs b/API/Extensions/AuthenticationSchemeProviderExtensions.cs new file mode 100644 index 00000000..a9715c4e --- /dev/null +++ b/API/Extensions/AuthenticationSchemeProviderExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Authentication; +using NRedisStack.Search; +using OpenShock.Common.Authentication; + +namespace OpenShock.API.Extensions; + +public static class AuthenticationSchemeProviderExtensions +{ + public static async Task GetAllOAuthSchemesAsync(this IAuthenticationSchemeProvider provider) + { + var schemes = await provider.GetAllSchemesAsync(); + + return schemes + .Select(scheme => scheme.Name) + .Where(scheme => OpenShockAuthSchemes.OAuth2Schemes.Contains(scheme)) + .ToArray(); + } + public static async Task IsSupportedOAuthScheme(this IAuthenticationSchemeProvider provider, string scheme) + { + if (!OpenShockAuthSchemes.OAuth2Schemes.Contains(scheme)) + return false; + + var schemes = await provider.GetAllSchemesAsync(); + + return schemes.Any(s => s.Name == scheme); + } +} diff --git a/API/OAuth/FlowStore/CacheOAuthFlowStore.cs b/API/OAuth/FlowStore/CacheOAuthFlowStore.cs new file mode 100644 index 00000000..b846310d --- /dev/null +++ b/API/OAuth/FlowStore/CacheOAuthFlowStore.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Caching.Distributed; +using System.Security.Cryptography; +using System.Text.Json; + +namespace OpenShock.API.OAuth.FlowStore; + +public sealed class CacheOAuthFlowStore(IDistributedCache cache) : IOAuthFlowStore +{ + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + private static string Key(string id) => $"oauth:flow:{id}"; + + public async Task SaveAsync(OAuthSnapshot snap, TimeSpan ttl, CancellationToken ct = default) + { + var id = Convert.ToBase64String(RandomNumberGenerator.GetBytes(18)) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); // url-safe + var json = JsonSerializer.Serialize(snap, JsonOpts); + await cache.SetStringAsync(Key(id), json, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl }, ct); + return id; + } + + public async Task GetAsync(string flowId, CancellationToken ct = default) + { + var json = await cache.GetStringAsync(Key(flowId), ct); + return json is null ? null : JsonSerializer.Deserialize(json, JsonOpts); + } + + public Task DeleteAsync(string flowId, CancellationToken ct = default) + => cache.RemoveAsync(Key(flowId), ct); +} \ No newline at end of file diff --git a/API/OAuth/FlowStore/IOAuthFlowStore.cs b/API/OAuth/FlowStore/IOAuthFlowStore.cs new file mode 100644 index 00000000..c205f33c --- /dev/null +++ b/API/OAuth/FlowStore/IOAuthFlowStore.cs @@ -0,0 +1,8 @@ +namespace OpenShock.API.OAuth.FlowStore; + +public interface IOAuthFlowStore +{ + Task SaveAsync(OAuthSnapshot snap, TimeSpan ttl, CancellationToken ct = default); + Task GetAsync(string flowId, CancellationToken ct = default); + Task DeleteAsync(string flowId, CancellationToken ct = default); +} \ No newline at end of file diff --git a/API/OAuth/OAuthPublic.cs b/API/OAuth/OAuthPublic.cs new file mode 100644 index 00000000..9722ab1b --- /dev/null +++ b/API/OAuth/OAuthPublic.cs @@ -0,0 +1,10 @@ +namespace OpenShock.API.OAuth; + +// what we return to frontend at /oauth/discord/data +public sealed record OAuthPublic( + string provider, + string externalId, + string? email, + string? userName, + string flowId, // opaque id the frontend will POST back to finalize + int expiresInSeconds); \ No newline at end of file diff --git a/API/OAuth/OAuthSnapshot.cs b/API/OAuth/OAuthSnapshot.cs new file mode 100644 index 00000000..948ecb8b --- /dev/null +++ b/API/OAuth/OAuthSnapshot.cs @@ -0,0 +1,9 @@ +namespace OpenShock.API.OAuth; + +public sealed record OAuthSnapshot( + string Provider, + string ExternalId, + string? Email, + string? UserName, + IDictionary Tokens, + DateTimeOffset IssuedUtc); \ No newline at end of file diff --git a/API/Services/OAuth/Discord/DiscordOAuthOptions.cs b/API/Options/OAuth/DiscordOAuthOptions.cs similarity index 50% rename from API/Services/OAuth/Discord/DiscordOAuthOptions.cs rename to API/Options/OAuth/DiscordOAuthOptions.cs index 8650ade3..00bc7a9d 100644 --- a/API/Services/OAuth/Discord/DiscordOAuthOptions.cs +++ b/API/Options/OAuth/DiscordOAuthOptions.cs @@ -1,4 +1,6 @@ -namespace OpenShock.API.Services.OAuth.Discord; + + +namespace OpenShock.API.Options.OAuth; public sealed class DiscordOAuthOptions { @@ -6,4 +8,7 @@ public sealed class DiscordOAuthOptions public required string ClientId { get; init; } public required string ClientSecret { get; init; } + public required PathString CallbackPath { get; init; } + public required PathString AccessDeniedPath { get; init; } + public required string[] Scopes { get; init; } } \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 707e1802..74854483 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,11 +1,15 @@ +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.Connections; using Microsoft.Extensions.Options; +using OpenShock.API.OAuth.FlowStore; +using OpenShock.API.Options.OAuth; using OpenShock.API.Realtime; using OpenShock.API.Services; using OpenShock.API.Services.Account; using OpenShock.API.Services.Email; using OpenShock.API.Services.UserService; using OpenShock.Common; +using OpenShock.Common.Authentication; using OpenShock.Common.DeviceControl; using OpenShock.Common.Extensions; using OpenShock.Common.Hubs; @@ -18,8 +22,6 @@ using OpenShock.Common.Services.Turnstile; using OpenShock.Common.Swagger; using Serilog; -using OpenShock.API.Services.OAuth; -using OpenShock.API.Services.OAuth.Discord; var builder = OpenShockApplication.CreateDefaultBuilder(args); @@ -37,7 +39,33 @@ builder.Services.AddOpenShockMemDB(redisConfig); builder.Services.AddOpenShockDB(databaseConfig); -builder.Services.AddOpenShockServices(); +builder.Services.AddOpenShockServices(auth => auth + .AddCookie(OpenShockAuthSchemes.OAuthFlowScheme, o => + { + o.Cookie.Name = OpenShockAuthSchemes.OAuthFlowCookie; + o.ExpireTimeSpan = TimeSpan.FromMinutes(10); + o.SlidingExpiration = false; + }) + .AddDiscord(OpenShockAuthSchemes.DiscordScheme, o => + { + o.SignInScheme = OpenShockAuthSchemes.OAuthFlowScheme; + + var options = builder.Configuration.GetRequiredSection(DiscordOAuthOptions.SectionName).Get()!; + + o.ClientId = options.ClientId; + o.ClientSecret = options.ClientSecret; + o.CallbackPath = options.CallbackPath; + o.AccessDeniedPath = options.AccessDeniedPath; + foreach (var scope in options.Scopes) o.Scope.Add(scope); + + o.Prompt = "none"; + o.SaveTokens = true; + + o.ClaimActions.MapJsonKey("email-verified", "verified"); + + o.Validate(); + }) +); builder.Services.AddSignalR() .AddOpenShockStackExchangeRedis(options => { options.Configuration = redisConfig; }) @@ -47,6 +75,7 @@ options.PayloadSerializerOptions.Converters.Add(new SemVersionJsonConverter()); }); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -55,9 +84,6 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddOAuth() - .AddHandler(builder.Configuration.GetRequiredSection(DiscordOAuthOptions.SectionName)); - builder.AddSwaggerExt(); builder.AddCloudflareTurnstileService(); diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 7815897e..190f8db6 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -454,6 +454,11 @@ public async Task GetOAuthConnectionsAsync(Guid userId) .ToArrayAsync(); } + public async Task GetOAuthConnectionAsync(string provider, string providerAccountId) + { + return await _db.UserOAuthConnections.FirstOrDefaultAsync(c => c.ProviderKey == provider && c.ExternalId == providerAccountId); + } + public async Task HasOAuthConnectionAsync(Guid userId, string provider) { return await _db.UserOAuthConnections.AnyAsync(c => c.UserId == userId && c.ProviderKey == provider); diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index c254e191..65652313 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -112,6 +112,7 @@ public interface IAccountService Task TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default); Task GetOAuthConnectionsAsync(Guid userId); + Task GetOAuthConnectionAsync(string provider, string providerAccountId); Task HasOAuthConnectionAsync(Guid userId, string provider); Task TryAddOAuthConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName); diff --git a/API/Services/OAuth/Discord/DiscordOAuthHandler.cs b/API/Services/OAuth/Discord/DiscordOAuthHandler.cs deleted file mode 100644 index 62dba317..00000000 --- a/API/Services/OAuth/Discord/DiscordOAuthHandler.cs +++ /dev/null @@ -1,138 +0,0 @@ -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.Extensions.Options; -using OneOf; -using System.Net.Http.Headers; -using System.Text.Json; - -namespace OpenShock.API.Services.OAuth.Discord; - -public sealed class DiscordOAuthHandler : IOAuthHandler -{ - private static readonly string[] Scopes = ["identify", "email"]; - - private readonly IHttpClientFactory _http; - private readonly DiscordOAuthOptions _opt; - private readonly IOAuthStateStore _stateStore; - - public DiscordOAuthHandler( - IHttpClientFactory http, - IOptions opt, - IOAuthStateStore stateStore) - { - _http = http; - _opt = opt.Value; - _stateStore = stateStore; - } - - public string Key => "discord"; - public string AuthorizeEndpoint => "https://discord.com/oauth2/authorize"; - public string TokenEndpoint => "https://discord.com/api/oauth2/token"; - public string UserInfoEndpoint => "https://discord.com/api/users/@me"; - - public Uri BuildAuthorizeUrl(string state, Uri callbackUrl) - { - var queryBuilder = new QueryBuilder - { - { "response_type", "code" }, - { "client_id", _opt.ClientId }, - { "scope", string.Join(' ', Scopes) }, - { "redirect_uri", callbackUrl.ToString() }, - { "prompt", "none" }, - { "state", state } - }; - - var uriBuilder = new UriBuilder(AuthorizeEndpoint) - { - Query = queryBuilder.ToString() - }; - - return uriBuilder.Uri; - } - - public async Task> HandleCallbackAsync(HttpContext http, IQueryCollection query, Uri callbackUrl) - { - var code = query["code"].ToString(); - var state = query["state"].ToString(); - - if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state)) - return new OAuthErrorResult("invalid_request", "Missing 'code' or 'state'."); - - var env = await _stateStore.ReadAndClearAsync(http, Key, state); - if (env is null) - return new OAuthErrorResult("state_invalid", "Invalid or expired state."); - - var ct = http.RequestAborted; - var client = _http.CreateClient(); - - // Exchange code for token - var accessResult = await ExchangeCodeForAccessTokenAsync(client, code, callbackUrl, ct); - if (accessResult.TryPickT1(out var tokenErr, out var accessToken)) - return tokenErr; - - // Fetch user info - var userResult = await FetchDiscordUserAsync(client, accessToken, ct); - if (userResult.TryPickT1(out var userErr, out var me)) - return userErr; - - var externalId = me.GetProperty("id").GetString()!; - var username = me.GetProperty("username").GetString(); - string? email = me.TryGetProperty("email", out var emailEl) ? emailEl.GetString() : null; - - var user = new ExternalUser( - Provider: Key, - ExternalId: externalId, - Username: username, - Email: email, - AvatarUrl: null - ); - - http.Items["oauth_flow"] = env.Flow; - - return new OAuthCallbackResult(user, env.ReturnTo); - } - - private async Task> ExchangeCodeForAccessTokenAsync( - HttpClient client, - string code, - Uri callbackUrl, - CancellationToken ct) - { - using var request = new HttpRequestMessage(HttpMethod.Post, TokenEndpoint) - { - Content = new FormUrlEncodedContent(new Dictionary - { - ["client_id"] = _opt.ClientId, - ["client_secret"] = _opt.ClientSecret, - ["grant_type"] = "authorization_code", - ["code"] = code, - ["redirect_uri"] = callbackUrl.ToString(), - }) - }; - using var response = await client.SendAsync(request, ct); - if (!response.IsSuccessStatusCode) - return new OAuthErrorResult("token_exchange_failed", $"Token exchange failed ({(int)response.StatusCode})."); - - var tokenEl = await response.Content.ReadFromJsonAsync(ct); - - if (!tokenEl.TryGetProperty("access_token", out var accessEl) || - string.IsNullOrWhiteSpace(accessEl.GetString())) - return new OAuthErrorResult("token_exchange_failed", "No access token from provider."); - - return accessEl.GetString()!; - } - - private async Task> FetchDiscordUserAsync( - HttpClient client, - string accessToken, - CancellationToken ct) - { - using var request = new HttpRequestMessage(HttpMethod.Get, UserInfoEndpoint); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - - using var response = await client.SendAsync(request, ct); - if (!response.IsSuccessStatusCode) - return new OAuthErrorResult("profile_fetch_failed", $"Failed to fetch user profile ({(int)response.StatusCode})."); - - return await response.Content.ReadFromJsonAsync(ct); - } -} diff --git a/API/Services/OAuth/IOAuthHandler.cs b/API/Services/OAuth/IOAuthHandler.cs deleted file mode 100644 index fbc16e7f..00000000 --- a/API/Services/OAuth/IOAuthHandler.cs +++ /dev/null @@ -1,28 +0,0 @@ -using OneOf; - -namespace OpenShock.API.Services.OAuth; - -public sealed record ExternalUser( - string Provider, // "discord", "github", etc. - string ExternalId, // provider user id - string? Username, - string? Email, // provider email - string? AvatarUrl); - -public sealed record OAuthCallbackResult(ExternalUser User, string CallbackUrl); - -public sealed record OAuthErrorResult(string Code, string Description); - -public interface IOAuthHandler -{ - string Key { get; } - string AuthorizeEndpoint { get; } - string TokenEndpoint { get; } - string UserInfoEndpoint { get; } - - /// Build the provider authorize URL - Uri BuildAuthorizeUrl(string state, Uri callbackUrl); - - /// Handle callback: validate state, exchange code, fetch user, clear cookies, etc. - Task> HandleCallbackAsync(HttpContext http, IQueryCollection query, Uri callbackUrl); -} \ No newline at end of file diff --git a/API/Services/OAuth/IOAuthHandlerRegistry.cs b/API/Services/OAuth/IOAuthHandlerRegistry.cs deleted file mode 100644 index a7ee982a..00000000 --- a/API/Services/OAuth/IOAuthHandlerRegistry.cs +++ /dev/null @@ -1,12 +0,0 @@ -using OneOf; - -namespace OpenShock.API.Services.OAuth; - -public interface IOAuthHandlerRegistry -{ - string[] ListProviderKeys(); - Task> StartAuthorizeAsync(HttpContext http, string provider, OAuthFlow flow, string returnTo); - Task> HandleCallbackAsync(HttpContext http, string provider, IQueryCollection query); -} - -public readonly record struct OAuthProviderNotSupported; \ No newline at end of file diff --git a/API/Services/OAuth/IOAuthStateStore.cs b/API/Services/OAuth/IOAuthStateStore.cs deleted file mode 100644 index 1ed69218..00000000 --- a/API/Services/OAuth/IOAuthStateStore.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace OpenShock.API.Services.OAuth; - -public interface IOAuthStateStore -{ - Task SaveAsync(HttpContext http, OAuthStateEnvelope envelope, TimeSpan ttl); - Task ReadAndClearAsync(HttpContext http, string provider, string state); -} - -public enum OAuthFlow -{ - SignIn, - Link -} -public sealed record OAuthStateEnvelope( - string Provider, - string State, // opaque nonce - OAuthFlow Flow, // SignIn | Link - string ReturnTo, - Guid? UserId, // set for Link flow - DateTimeOffset CreatedAt -); \ No newline at end of file diff --git a/API/Services/OAuth/OAuthHandlerRegistry.cs b/API/Services/OAuth/OAuthHandlerRegistry.cs deleted file mode 100644 index 3ac1acca..00000000 --- a/API/Services/OAuth/OAuthHandlerRegistry.cs +++ /dev/null @@ -1,62 +0,0 @@ -using OneOf; -using OpenShock.Common.Utils; - -namespace OpenShock.API.Services.OAuth; - -public sealed class OAuthHandlerRegistry : IOAuthHandlerRegistry -{ - private readonly Dictionary _handlers; - private readonly IOAuthStateStore _state; - - public OAuthHandlerRegistry(IEnumerable handlers, IOAuthStateStore state) - { - _handlers = handlers.ToDictionary(h => h.Key, h => h, StringComparer.OrdinalIgnoreCase); - _state = state; - } - - public string[] ListProviderKeys() - { - return _handlers.Keys.ToArray(); - } - - private Uri GetCallbackUri(string provider) - { - return new Uri($"https://api.openshock.app/1/oauth/{provider}/callback"); - } - - public async Task> StartAuthorizeAsync(HttpContext http, string provider, OAuthFlow flow, string returnTo) - { - if (!_handlers.TryGetValue(provider, out var handler)) - return new OAuthProviderNotSupported(); - - // Generate state and persist in Redis (+ double-submit cookie inside store) - var stateNonce = CryptoUtils.RandomString(64); - var env = new OAuthStateEnvelope( - Provider: handler.Key, - State: stateNonce, - Flow: flow, - ReturnTo: returnTo, - UserId: null, - CreatedAt: DateTimeOffset.UtcNow - ); - - await _state.SaveAsync(http, env, TimeSpan.FromMinutes(10)); - - // Delegate URL construction to the handler (includes redirect_uri & scopes) - return handler.BuildAuthorizeUrl(stateNonce, GetCallbackUri(provider)); - } - - public async Task> HandleCallbackAsync(HttpContext http, string provider, IQueryCollection query) - { - if (!_handlers.TryGetValue(provider, out var handler)) - return new OAuthProviderNotSupported(); - - var result = await handler.HandleCallbackAsync(http, query, GetCallbackUri(provider)); - if (result.TryPickT1(out var error, out var info)) - { - return error; - } - - return info; - } -} \ No newline at end of file diff --git a/API/Services/OAuth/RedisOAuthStateStore.cs b/API/Services/OAuth/RedisOAuthStateStore.cs deleted file mode 100644 index 8ffc1bff..00000000 --- a/API/Services/OAuth/RedisOAuthStateStore.cs +++ /dev/null @@ -1,98 +0,0 @@ -using Redis.OM.Contracts; -using Redis.OM.Modeling; -using Redis.OM.Searching; - -namespace OpenShock.API.Services.OAuth; - -public sealed class RedisOAuthStateStore : IOAuthStateStore -{ - private const string CookiePrefix = "__os_oauth_state_"; - - private readonly IRedisCollection _states; - - public RedisOAuthStateStore(IRedisConnectionProvider redis) - { - // No indexing needed for lookups by Id, but JSON storage is convenient - _states = redis.RedisCollection(false); - } - - public async Task SaveAsync(HttpContext http, OAuthStateEnvelope env, TimeSpan ttl) - { - // Persist server-side (source of truth) - var entry = Map(env); - await _states.InsertAsync(entry, ttl); - - // Double-submit cookie with the same nonce (no signing/encryption needed) - http.Response.Cookies.Append(CookiePrefix + env.Provider, env.State, new CookieOptions - { - HttpOnly = true, - Secure = true, - SameSite = SameSiteMode.Lax, - Expires = DateTimeOffset.UtcNow.Add(ttl), - Path = "/" - }); - } - - public async Task ReadAndClearAsync(HttpContext http, string provider, string state) - { - // Optional: verify cookie matches the returned state (defense-in-depth) - var cookieName = CookiePrefix + provider; - if (!http.Request.Cookies.TryGetValue(cookieName, out var cookieState) || - !string.Equals(cookieState, state, StringComparison.Ordinal)) - { - return null; - } - - // Remove cookie regardless of Redis hit (one-time use) - http.Response.Cookies.Delete(cookieName, new CookieOptions - { - HttpOnly = true, - Secure = true, - SameSite = SameSiteMode.Lax, - Path = "/" - }); - - // Load & delete atomically-ish (best-effort; Redis OM lacks multi here, but TTL + delete is fine) - var id = OAuthStateEntry.MakeId(provider, state); - var entry = await _states.FindByIdAsync(id); - if (entry is null) - return null; - - await _states.DeleteAsync(entry); - return Map(entry); - } - - private static OAuthStateEntry Map(OAuthStateEnvelope e) => new() - { - Id = OAuthStateEntry.MakeId(e.Provider, e.State), - Provider = e.Provider, - State = e.State, - Flow = e.Flow, - ReturnTo = e.ReturnTo, - UserId = e.UserId, - CreatedAt = e.CreatedAt.UtcDateTime - }; - - private static OAuthStateEnvelope Map(OAuthStateEntry e) => new( - Provider: e.Provider, - State: e.State, - Flow: e.Flow, - ReturnTo: e.ReturnTo, - UserId: e.UserId, - CreatedAt: DateTime.SpecifyKind(e.CreatedAt, DateTimeKind.Utc)); - - // Redis JSON document - [Document(StorageType = StorageType.Json, Prefixes = new[] { "oauth:state" })] - public sealed class OAuthStateEntry - { - [RedisIdField] public required string Id { get; set; } // oauth:state:{provider}:{state} - public required string Provider { get; set; } - public required string State { get; set; } - public required OAuthFlow Flow { get; set; } - public required string ReturnTo { get; set; } - public required Guid? UserId { get; set; } - public required DateTime CreatedAt { get; set; } - - public static string MakeId(string provider, string state) => $"{provider}:{state}"; - } -} \ No newline at end of file diff --git a/API/Services/OAuth/ServiceCollectionHelpers.cs b/API/Services/OAuth/ServiceCollectionHelpers.cs deleted file mode 100644 index ea274311..00000000 --- a/API/Services/OAuth/ServiceCollectionHelpers.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace OpenShock.API.Services.OAuth; - -public interface IOAuthBuilder -{ - IOAuthBuilder AddHandler(IConfiguration configuration) - where THandler : class, IOAuthHandler - where TOptions : class; -} - -internal sealed class OAuthBuilder : IOAuthBuilder -{ - private readonly IServiceCollection _services; - internal OAuthBuilder(IServiceCollection services) => _services = services; - - public IOAuthBuilder AddHandler(IConfiguration configuration) - where THandler : class, IOAuthHandler - where TOptions : class - { - _services.Configure(configuration); - - // Typed HttpClient per handler (unique type = unique client) - _services.AddHttpClient(); - - // Register handler as IOAuthHandler - _services.AddSingleton(); - - return this; - } -} - -public static class ServiceCollectionHelpers -{ - public static IOAuthBuilder AddOAuth(this IServiceCollection services) - { - // Default state store if none registered - services.TryAddSingleton(); - - // Registry built from IEnumerable - services.TryAddSingleton(); - - return new OAuthBuilder(services); - } -} \ No newline at end of file diff --git a/Common/Authentication/OpenShockAuthSchemes.cs b/Common/Authentication/OpenShockAuthSchemes.cs index 7d3c8792..43e7b981 100644 --- a/Common/Authentication/OpenShockAuthSchemes.cs +++ b/Common/Authentication/OpenShockAuthSchemes.cs @@ -6,5 +6,10 @@ public static class OpenShockAuthSchemes public const string ApiToken = "ApiToken"; public const string HubToken = "HubToken"; + public const string OAuthFlowScheme = "OAuthFlowCookie"; + public const string OAuthFlowCookie = ".OpenShock.OAuthFlow"; + public const string DiscordScheme = "discord"; + public static readonly string[] OAuth2Schemes = [DiscordScheme]; + public const string UserSessionApiTokenCombo = $"{UserSessionCookie},{ApiToken}"; } \ No newline at end of file diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index a09153d0..dc8c9a58 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -106,8 +106,9 @@ public static IServiceCollection AddOpenShockDB(this IServiceCollection services /// Register all OpenShock services for PRODUCTION use /// /// + /// /// - public static IServiceCollection AddOpenShockServices(this IServiceCollection services) + public static IServiceCollection AddOpenShockServices(this IServiceCollection services, Action? configureAuth = null) { // <---- ASP.NET ----> services.AddExceptionHandler(); @@ -128,13 +129,18 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se services.AddScoped(); services.AddAuthenticationCore(); - new AuthenticationBuilder(services) + var authBuilder = new AuthenticationBuilder(services) .AddScheme( OpenShockAuthSchemes.UserSessionCookie, _ => { }) .AddScheme( OpenShockAuthSchemes.ApiToken, _ => { }) .AddScheme( OpenShockAuthSchemes.HubToken, _ => { }); + + if (configureAuth is not null) + { + configureAuth(authBuilder); + } services.AddAuthorization(options => { From 254bbf1a3cea0ed99befe4d4f45b0094a1fee5e4 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Wed, 3 Sep 2025 18:19:59 +0200 Subject: [PATCH 31/63] Clean up Complete endpoint logic a bit --- API/Controller/OAuth/Complete.cs | 46 +++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/API/Controller/OAuth/Complete.cs b/API/Controller/OAuth/Complete.cs index ebcead7d..464f6314 100644 --- a/API/Controller/OAuth/Complete.cs +++ b/API/Controller/OAuth/Complete.cs @@ -3,9 +3,13 @@ using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Extensions; using OpenShock.API.OAuth; +using OpenShock.API.OAuth.FlowStore; using OpenShock.API.Services.Account; using OpenShock.Common.Authentication; +using OpenShock.Common.Authentication.Services; using OpenShock.Common.Errors; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Utils; using Scalar.AspNetCore; using System.Security.Claims; @@ -15,7 +19,13 @@ public sealed partial class OAuthController { [EnableRateLimiting("auth")] [HttpGet("{provider}/complete")] - public async Task OAuthComplete([FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider, [FromServices] IAccountService accountService) + public async Task OAuthComplete( + [FromRoute] string provider, + [FromServices] IAuthenticationSchemeProvider schemeProvider, + [FromServices] IUserReferenceService userReferenceService, + [FromServices] IAccountService accountService, + [FromServices] IOAuthFlowStore store + ) { if (!await schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.ProviderNotSupported); @@ -43,7 +53,11 @@ public async Task OAuthComplete([FromRoute] string provider, [Fro .ToDictionary(t => t.Name!, t => t.Value!); // Who (if anyone) is currently signed into OUR site? - var currentUserId = HttpContext.User?.FindFirst("uid")?.Value; + User? currentUser = null; + if (userReferenceService.AuthReference is not null && userReferenceService.AuthReference.Value.IsT0) + { + currentUser = HttpContext.RequestServices.GetRequiredService>().CurrentClient; + } // Is this external already linked to someone? var connection = await accountService.GetOAuthConnectionAsync(provider, externalId); @@ -51,13 +65,12 @@ public async Task OAuthComplete([FromRoute] string provider, [Fro // CASE A: External already linked if (connection is not null) { - if (!string.IsNullOrEmpty(currentUserId)) + if (currentUser is not null) { // Already logged in locally. - if (connection.UserId == currentUserId) + if (connection.UserId == currentUser.Id) { // Happy path: ensure session is fresh and go home. - await sessionIssuer.SignInAsync(HttpContext, connection.UserId); await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); return Redirect("/"); } @@ -71,9 +84,24 @@ public async Task OAuthComplete([FromRoute] string provider, [Fro } // Anonymous user: sign in as the linked account and go home. - await sessionIssuer.SignInAsync(HttpContext, connection.UserId); - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); - return Redirect("/"); + var loginAction = await _accountService.CreateUserLoginSessionAsync(/* ....... */, new LoginContext + { + Ip = HttpContext.GetRemoteIP().ToString(), + UserAgent = HttpContext.GetUserAgent(), + }, cancellationToken); + + return loginAction.Match( + ok => + { + HttpContext.SetSessionKeyCookie(ok.Token, "." + cookieDomainToUse); + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Redirect("/"); + }, + deactivated => Problem(AccountError.AccountDeactivated), + oauthOnly => Problem(AccountError.AccountOAuthOnly), + notActivated => Problem(AccountError.AccountNotActivated), + notFound => Problem(LoginError.InvalidCredentials) + ); } // CASE B: Not linked yet → create flow snapshot and send to frontend for link/create @@ -89,7 +117,7 @@ public async Task OAuthComplete([FromRoute] string provider, [Fro // Short-lived, non-HttpOnly cookie so the frontend can call /oauth/{provider}/data Response.Cookies.Append( - OAuthFlow.TempCookie, + OpenShockAuthSchemes.OAuthFlowCookie, flowId, new CookieOptions { From 53884b3af644aa17e49dab08fcd6f581a40d9f8c Mon Sep 17 00:00:00 2001 From: hhvrc Date: Wed, 3 Sep 2025 18:30:36 +0200 Subject: [PATCH 32/63] Better? --- .../Authenticated/OAuthConnectionAdd.cs | 2 +- API/Controller/OAuth/Complete.cs | 171 +++++++++--------- API/Controller/OAuth/Login.cs | 2 +- 3 files changed, 83 insertions(+), 92 deletions(-) diff --git a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs index 0e32ff6e..306965dc 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs @@ -13,6 +13,6 @@ public async Task AddOAuthConnection([FromRoute] string provider, if (!await schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.ProviderNotSupported); - return Challenge(new AuthenticationProperties { RedirectUri = $"/oauth/{provider}/complete", Parameters = {{ "flow", "link" }} }, authenticationSchemes: [provider]); + return Challenge(new AuthenticationProperties { RedirectUri = $"/oauth/{provider}/complete", Items = {{ "flow", "link" }} }, authenticationSchemes: [provider]); } } \ No newline at end of file diff --git a/API/Controller/OAuth/Complete.cs b/API/Controller/OAuth/Complete.cs index 464f6314..1dd001bf 100644 --- a/API/Controller/OAuth/Complete.cs +++ b/API/Controller/OAuth/Complete.cs @@ -6,10 +6,7 @@ using OpenShock.API.OAuth.FlowStore; using OpenShock.API.Services.Account; using OpenShock.Common.Authentication; -using OpenShock.Common.Authentication.Services; using OpenShock.Common.Errors; -using OpenShock.Common.OpenShockDb; -using OpenShock.Common.Utils; using Scalar.AspNetCore; using System.Security.Claims; @@ -22,121 +19,115 @@ public sealed partial class OAuthController public async Task OAuthComplete( [FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider, - [FromServices] IUserReferenceService userReferenceService, [FromServices] IAccountService accountService, - [FromServices] IOAuthFlowStore store - ) + [FromServices] IOAuthFlowStore store) { if (!await schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.ProviderNotSupported); - // External principal placed by the OAuth handler (SaveTokens=true, SignInScheme=OAuthFlowScheme) + // Temp external principal (set by OAuth handler with SignInScheme=OAuthFlowScheme, SaveTokens=true) var auth = await HttpContext.AuthenticateAsync(OpenShockAuthSchemes.OAuthFlowScheme); if (!auth.Succeeded || auth.Principal is null) return BadRequest("OAuth sign-in not found or expired."); - var ext = auth.Principal; var props = auth.Properties; + if (props is null || !props.Items.TryGetValue("flow", out var flow) || string.IsNullOrWhiteSpace(flow)) + { + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + Response.Cookies.Delete(OpenShockAuthSchemes.OAuthFlowCookie, new CookieOptions { Path = "/" }); + return BadRequest(new { error = "missing_flow" }); + } + flow = flow.ToLowerInvariant(); - // Essentials from external identity - var externalId = ext.FindFirst(ClaimTypes.NameIdentifier)?.Value - ?? ext.FindFirst("sub")?.Value - ?? ext.FindFirst("id")?.Value; - + var ext = auth.Principal; + var externalId = + ext.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? + ext.FindFirst("sub")?.Value ?? + ext.FindFirst("id")?.Value; if (string.IsNullOrEmpty(externalId)) return Problem("Missing external subject.", statusCode: 400); var email = ext.FindFirst(ClaimTypes.Email)?.Value; var userName = ext.Identity?.Name; - - var tokens = (props?.GetTokens() ?? Enumerable.Empty()) + var tokens = (props.GetTokens() ?? Enumerable.Empty()) .ToDictionary(t => t.Name!, t => t.Value!); - // Who (if anyone) is currently signed into OUR site? - User? currentUser = null; - if (userReferenceService.AuthReference is not null && userReferenceService.AuthReference.Value.IsT0) - { - currentUser = HttpContext.RequestServices.GetRequiredService>().CurrentClient; - } - - // Is this external already linked to someone? var connection = await accountService.GetOAuthConnectionAsync(provider, externalId); - // CASE A: External already linked - if (connection is not null) + switch (flow) { - if (currentUser is not null) - { - // Already logged in locally. - if (connection.UserId == currentUser.Id) + case "login": { - // Happy path: ensure session is fresh and go home. + if (connection is not null) + { + // Already linked -> sign in and go home. + // TODO: issue your UserSessionCookie/session here for connection.UserId + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Redirect("/"); + } + + var flowId = await SaveSnapshotAsync(store, provider, externalId, email, userName, tokens); + SetFlowCookie(flowId); await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); - return Redirect("/"); + + var frontend = Environment.GetEnvironmentVariable("FRONTEND_ORIGIN") ?? "https://app.example.com"; + return Redirect($"{frontend}/{provider}/create"); } - // Linked to a different local account → fail explicitly. - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); - return Problem( - detail: "This external account is already linked to another user.", - statusCode: 409, - title: "Account already linked"); - } - - // Anonymous user: sign in as the linked account and go home. - var loginAction = await _accountService.CreateUserLoginSessionAsync(/* ....... */, new LoginContext - { - Ip = HttpContext.GetRemoteIP().ToString(), - UserAgent = HttpContext.GetUserAgent(), - }, cancellationToken); - - return loginAction.Match( - ok => + case "link": { - HttpContext.SetSessionKeyCookie(ok.Token, "." + cookieDomainToUse); + if (connection is not null) + { + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem( + detail: "This external account is already linked to another user.", + statusCode: 409, + title: "Account already linked"); + } + + var flowId = await SaveSnapshotAsync(store, provider, externalId, email, userName, tokens); + SetFlowCookie(flowId); await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); - return Redirect("/"); - }, - deactivated => Problem(AccountError.AccountDeactivated), - oauthOnly => Problem(AccountError.AccountOAuthOnly), - notActivated => Problem(AccountError.AccountNotActivated), - notFound => Problem(LoginError.InvalidCredentials) - ); + + var frontend = Environment.GetEnvironmentVariable("FRONTEND_ORIGIN") ?? "https://app.example.com"; + return Redirect($"{frontend}/{provider}/link"); + } + + default: + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + Response.Cookies.Delete(OpenShockAuthSchemes.OAuthFlowCookie, new CookieOptions { Path = "/" }); + return BadRequest(new { error = "unknown_flow", flow }); } - // CASE B: Not linked yet → create flow snapshot and send to frontend for link/create - var snapshot = new OAuthSnapshot( - Provider: provider, - ExternalId: externalId, - Email: email, - UserName: userName, - Tokens: tokens, - IssuedUtc: DateTimeOffset.UtcNow); - - var flowId = await store.SaveAsync(snapshot, OAuthFlow.Ttl); - - // Short-lived, non-HttpOnly cookie so the frontend can call /oauth/{provider}/data - Response.Cookies.Append( - OpenShockAuthSchemes.OAuthFlowCookie, - flowId, - new CookieOptions - { - Secure = HttpContext.Request.IsHttps, - HttpOnly = false, // readable by frontend JS for one fetch - SameSite = SameSiteMode.Lax, - Expires = DateTimeOffset.UtcNow.Add(OAuthFlow.Ttl), - Path = "/" - }); - - // Clean up the temp external principal - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); - - // Decide which UI route to send them to - var frontend = Environment.GetEnvironmentVariable("FRONTEND_ORIGIN") ?? "https://app.example.com"; - var nextPath = (!string.IsNullOrEmpty(currentUserId)) - ? $"/{provider}/link" - : $"/{provider}/create"; - - return Redirect(frontend + nextPath); + // --- local helpers --- + async Task SaveSnapshotAsync( + IOAuthFlowStore s, string prov, string extId, string? mail, string? name, + IDictionary tks) + { + var snapshot = new OAuthSnapshot( + Provider: prov, + ExternalId: extId, + Email: mail, + UserName: name, + Tokens: tks, + IssuedUtc: DateTimeOffset.UtcNow); + return await s.SaveAsync(snapshot, OAuthFlow.Ttl); + } + + void SetFlowCookie(string id) + { + Response.Cookies.Append( + OpenShockAuthSchemes.OAuthFlowCookie, + id, + new CookieOptions + { + Secure = HttpContext.Request.IsHttps, + HttpOnly = false, // frontend reads once for /oauth/{provider}/data + SameSite = SameSiteMode.Lax, + Expires = DateTimeOffset.UtcNow.Add(OAuthFlow.Ttl), + Path = "/" + }); + } } + } \ No newline at end of file diff --git a/API/Controller/OAuth/Login.cs b/API/Controller/OAuth/Login.cs index c9dc6901..853fff71 100644 --- a/API/Controller/OAuth/Login.cs +++ b/API/Controller/OAuth/Login.cs @@ -15,6 +15,6 @@ public async Task OAuthLogin([FromRoute] string provider, [FromQu if (!await schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.ProviderNotSupported); - return Challenge(new AuthenticationProperties { RedirectUri = $"/oauth/{provider}/complete", Parameters = { { "flow", "login" } } }, authenticationSchemes: [provider]); + return Challenge(new AuthenticationProperties { RedirectUri = $"/oauth/{provider}/complete", Items = { { "flow", "login" } } }, authenticationSchemes: [provider]); } } \ No newline at end of file From 8bd681692edf1d5e0967fcc868195bed45d4077b Mon Sep 17 00:00:00 2001 From: hhvrc Date: Wed, 3 Sep 2025 18:49:48 +0200 Subject: [PATCH 33/63] Yeah....... --- .../OAuth/{Login.cs => Authorize.cs} | 4 +- API/Controller/OAuth/Finalize.cs | 145 ++++++++++++++++++ API/Controller/OAuth/GetData.cs | 2 +- .../OAuth/{Complete.cs => HandOff.cs} | 4 +- Common/Errors/OAuthError.cs | 3 + 5 files changed, 153 insertions(+), 5 deletions(-) rename API/Controller/OAuth/{Login.cs => Authorize.cs} (72%) create mode 100644 API/Controller/OAuth/Finalize.cs rename API/Controller/OAuth/{Complete.cs => HandOff.cs} (98%) diff --git a/API/Controller/OAuth/Login.cs b/API/Controller/OAuth/Authorize.cs similarity index 72% rename from API/Controller/OAuth/Login.cs rename to API/Controller/OAuth/Authorize.cs index 853fff71..f20c5a55 100644 --- a/API/Controller/OAuth/Login.cs +++ b/API/Controller/OAuth/Authorize.cs @@ -9,8 +9,8 @@ namespace OpenShock.API.Controller.OAuth; public sealed partial class OAuthController { [EnableRateLimiting("auth")] - [HttpGet("{provider}/login")] - public async Task OAuthLogin([FromRoute] string provider, [FromQuery(Name = "return_to")] string returnTo, [FromServices] IAuthenticationSchemeProvider schemeProvider) + [HttpPost("{provider}/authorize")] + public async Task OAuthAuthorize([FromRoute] string provider, [FromQuery(Name = "return_to")] string returnTo, [FromServices] IAuthenticationSchemeProvider schemeProvider) { if (!await schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.ProviderNotSupported); diff --git a/API/Controller/OAuth/Finalize.cs b/API/Controller/OAuth/Finalize.cs new file mode 100644 index 00000000..11512a17 --- /dev/null +++ b/API/Controller/OAuth/Finalize.cs @@ -0,0 +1,145 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.API.Extensions; +using OpenShock.API.OAuth; +using OpenShock.API.OAuth.FlowStore; +using OpenShock.API.Services.Account; +using OpenShock.Common.Authentication; +using OpenShock.Common.Authentication.Services; +using OpenShock.Common.Errors; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Utils; +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.API.Controller.OAuth; + +public sealed partial class OAuthController +{ + [EnableRateLimiting("auth")] + [HttpPost("{provider}/finalize")] + public async Task OAuthFinalize( + [FromRoute] string provider, + [FromBody] OAuthFinalizeRequest body, + [FromServices] IAuthenticationSchemeProvider schemeProvider, + [FromServices] IOAuthFlowStore store, + [FromServices] IAccountService accountService, + [FromServices] IUserReferenceService userReferenceService, + [FromServices] IClientAuthService clientAuthService // used to read current user (if any) + ) + { + if (!await schemeProvider.IsSupportedOAuthScheme(provider)) + return Problem(OAuthError.ProviderNotSupported); + + if (!ModelState.IsValid) + return BadRequest(new { error = "bad_request", details = ModelState }); + + var action = body.action?.Trim().ToLowerInvariant(); + if (action is not ("create" or "link")) + return BadRequest(new { error = "unknown_action" }); + + // Load snapshot (one-time handoff) + var snapshot = await store.GetAsync(body.flowId); + if (snapshot is null) + { + // stale/expired/consumed flow; clear client cookie too + Response.Cookies.Delete(OpenShockAuthSchemes.OAuthFlowCookie, new CookieOptions { Path = "/" }); + return BadRequest(new { error = "expired" }); + } + + // Provider must match the route (defense-in-depth) + if (!string.Equals(snapshot.Provider, provider, StringComparison.OrdinalIgnoreCase)) + { + await store.DeleteAsync(body.flowId); + Response.Cookies.Delete(OpenShockAuthSchemes.OAuthFlowCookie, new CookieOptions { Path = "/" }); + return Conflict(new { error = "provider_mismatch" }); + } + + // From here on, ensure we always clean up the flow + await store.DeleteAsync(body.flowId); + Response.Cookies.Delete(OpenShockAuthSchemes.OAuthFlowCookie, new CookieOptions { Path = "/" }); + + // Does this external already exist? + var existing = await accountService.GetOAuthConnectionAsync(provider, snapshot.ExternalId); + + if (action == "create") + { + string userId; + + if (existing is not null) + { + // Already linked → log that user in + userId = existing.UserId; + } + else + { + // Create a new local user, then link the external + // TODO: replace with your actual APIs to create a user and link OAuth + // Examples (rename to your signatures): + userId = await accountService.CreateUserAsync( + preferredUserName: snapshot.UserName ?? $"oauth_{provider}_{snapshot.ExternalId}", + email: snapshot.Email); + + await accountService.AddOAuthConnectionAsync( + userId: userId, + provider: provider, + externalId: snapshot.ExternalId, + tokens: snapshot.Tokens); + } + + // Issue your application session now + // TODO: replace token issuance with your real session creation + cookie write + var ctx = new LoginContext + { + Ip = HttpContext.GetRemoteIP().ToString(), + UserAgent = HttpContext.GetUserAgent(), + }; + + var loginAction = await accountService.CreateUserLoginSessionAsync(userId, ctx, HttpContext.RequestAborted); + return await loginAction.MatchAsync( + ok: async ok => + { + // Choose your cookie domain policy as needed + HttpContext.SetSessionKeyCookie(ok.Token /*, "." + cookieDomainToUse */); + // Ensure the external temp principal is gone (if any) + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Ok(new { status = "ok" }); + }, + deactivated: _ => Task.FromResult(Problem(AccountError.AccountDeactivated)), + oauthOnly: _ => Task.FromResult(Problem(AccountError.AccountOAuthOnly)), + notActivated: _ => Task.FromResult(Problem(AccountError.AccountNotActivated)), + notFound: _ => Task.FromResult(Problem(LoginError.InvalidCredentials)) + ); + } + + // action == "link" + // Caller must already be authenticated with your site + if (!(userReferenceService.AuthReference?.Value.IsT0 ?? false)) + return Unauthorized(new { error = "not_authenticated" }); + + var currentUser = clientAuthService.CurrentClient; + if (currentUser is null) + return Unauthorized(new { error = "not_authenticated" }); + + if (existing is not null) + { + // Someone already owns this external identity + return Conflict(new { error = "already_linked" }); + } + + // Attach the external to the current user + await accountService.AddOAuthConnectionAsync( + userId: currentUser.Id, + provider: provider, + externalId: snapshot.ExternalId, + tokens: snapshot.Tokens); + + // Optional: refresh/extend current session if you need to + // await clientAuthService.RefreshAsync(...); + + // Ensure the external temp principal is gone (if any) + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + + return Ok(new { status = "ok" }); + } +} diff --git a/API/Controller/OAuth/GetData.cs b/API/Controller/OAuth/GetData.cs index aa7c5549..d59c540f 100644 --- a/API/Controller/OAuth/GetData.cs +++ b/API/Controller/OAuth/GetData.cs @@ -12,7 +12,7 @@ namespace OpenShock.API.Controller.OAuth; public sealed partial class OAuthController { - [EnableRateLimiting("auth")] + [EnableRateLimiting("auth")] // TODO: VERY IMPORTANT: DO CACHE: NO-STORE [HttpGet("{provider}/data")] public async Task OAuthGetData( [FromRoute] string provider, diff --git a/API/Controller/OAuth/Complete.cs b/API/Controller/OAuth/HandOff.cs similarity index 98% rename from API/Controller/OAuth/Complete.cs rename to API/Controller/OAuth/HandOff.cs index 1dd001bf..d7e996e6 100644 --- a/API/Controller/OAuth/Complete.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -15,8 +15,8 @@ namespace OpenShock.API.Controller.OAuth; public sealed partial class OAuthController { [EnableRateLimiting("auth")] - [HttpGet("{provider}/complete")] - public async Task OAuthComplete( + [HttpGet("{provider}/handoff")] + public async Task OAuthHandOff( [FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider, [FromServices] IAccountService accountService, diff --git a/Common/Errors/OAuthError.cs b/Common/Errors/OAuthError.cs index f648726d..c8d35924 100644 --- a/Common/Errors/OAuthError.cs +++ b/Common/Errors/OAuthError.cs @@ -5,6 +5,9 @@ namespace OpenShock.Common.Errors; public static class OAuthError { + public static OpenShockProblem FlowNotSupported => new OpenShockProblem( + "OAuth.Flow.NotSupported", "This OAuth flow is not supported", HttpStatusCode.Forbidden); + public static OpenShockProblem ProviderNotSupported => new OpenShockProblem( "OAuth.Provider.NotSupported", "This OAuth provider is not supported", HttpStatusCode.Forbidden); From 9cf72435ae349af1e78db20fc4d1dba6cd1200ae Mon Sep 17 00:00:00 2001 From: hhvrc Date: Thu, 4 Sep 2025 01:39:22 +0200 Subject: [PATCH 34/63] inbetween swapping back again... --- .../Authenticated/OAuthConnectionAdd.cs | 4 +- API/Controller/OAuth/Authorize.cs | 3 +- API/Controller/OAuth/Finalize.cs | 145 ------------------ API/Controller/OAuth/GetData.cs | 60 ++++---- API/Controller/OAuth/HandOff.cs | 83 ++-------- API/Controller/OAuth/_ApiController.cs | 1 + .../AuthenticationSchemeProviderExtensions.cs | 1 - .../OAuthFlowAuthenticationHandler.cs | 137 +++++++++++++++++ API/OAuth/FlowStore/CacheOAuthFlowStore.cs | 53 +++++-- API/OAuth/FlowStore/IOAuthFlowStore.cs | 6 +- API/OAuth/FlowStore/OAuthSnapshot.cs | 16 ++ API/OAuth/OAuthConstants.cs | 11 ++ API/OAuth/OAuthPublic.cs | 14 +- API/OAuth/OAuthSnapshot.cs | 9 -- API/Program.cs | 9 +- .../ApiTokenAuthentication.cs | 4 +- .../HubAuthentication.cs | 4 +- .../UserSessionAuthentication.cs | 6 +- Common/Authentication/OpenShockAuthSchemes.cs | 4 +- Common/Common.csproj | 1 + Common/Errors/OAuthError.cs | 21 ++- Common/OpenShockDb/OpenShockContext.cs | 7 +- Common/OpenShockServiceHelper.cs | 9 +- 23 files changed, 306 insertions(+), 302 deletions(-) delete mode 100644 API/Controller/OAuth/Finalize.cs create mode 100644 API/OAuth/AuthenticationHandler/OAuthFlowAuthenticationHandler.cs create mode 100644 API/OAuth/FlowStore/OAuthSnapshot.cs create mode 100644 API/OAuth/OAuthConstants.cs delete mode 100644 API/OAuth/OAuthSnapshot.cs diff --git a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs index 306965dc..cdd3b203 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; +using OpenShock.API.Controller.OAuth; using OpenShock.API.Extensions; +using OpenShock.API.OAuth; using OpenShock.Common.Errors; namespace OpenShock.API.Controller.Account.Authenticated; @@ -13,6 +15,6 @@ public async Task AddOAuthConnection([FromRoute] string provider, if (!await schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.ProviderNotSupported); - return Challenge(new AuthenticationProperties { RedirectUri = $"/oauth/{provider}/complete", Items = {{ "flow", "link" }} }, authenticationSchemes: [provider]); + return Challenge(new AuthenticationProperties { RedirectUri = $"/oauth/{provider}/complete", Items = {{ "flow", OAuthConstants.LinkFlow }} }, authenticationSchemes: [provider]); } } \ No newline at end of file diff --git a/API/Controller/OAuth/Authorize.cs b/API/Controller/OAuth/Authorize.cs index f20c5a55..e53fae8d 100644 --- a/API/Controller/OAuth/Authorize.cs +++ b/API/Controller/OAuth/Authorize.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Extensions; +using OpenShock.API.OAuth; using OpenShock.Common.Errors; namespace OpenShock.API.Controller.OAuth; @@ -15,6 +16,6 @@ public async Task OAuthAuthorize([FromRoute] string provider, [Fr if (!await schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.ProviderNotSupported); - return Challenge(new AuthenticationProperties { RedirectUri = $"/oauth/{provider}/complete", Items = { { "flow", "login" } } }, authenticationSchemes: [provider]); + return Challenge(new AuthenticationProperties { RedirectUri = $"/oauth/{provider}/complete", Items = { { "flow", OAuthConstants.LoginOrCreate } } }, authenticationSchemes: [provider]); } } \ No newline at end of file diff --git a/API/Controller/OAuth/Finalize.cs b/API/Controller/OAuth/Finalize.cs deleted file mode 100644 index 11512a17..00000000 --- a/API/Controller/OAuth/Finalize.cs +++ /dev/null @@ -1,145 +0,0 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.RateLimiting; -using OpenShock.API.Extensions; -using OpenShock.API.OAuth; -using OpenShock.API.OAuth.FlowStore; -using OpenShock.API.Services.Account; -using OpenShock.Common.Authentication; -using OpenShock.Common.Authentication.Services; -using OpenShock.Common.Errors; -using OpenShock.Common.OpenShockDb; -using OpenShock.Common.Utils; -using System.ComponentModel.DataAnnotations; - -namespace OpenShock.API.Controller.OAuth; - -public sealed partial class OAuthController -{ - [EnableRateLimiting("auth")] - [HttpPost("{provider}/finalize")] - public async Task OAuthFinalize( - [FromRoute] string provider, - [FromBody] OAuthFinalizeRequest body, - [FromServices] IAuthenticationSchemeProvider schemeProvider, - [FromServices] IOAuthFlowStore store, - [FromServices] IAccountService accountService, - [FromServices] IUserReferenceService userReferenceService, - [FromServices] IClientAuthService clientAuthService // used to read current user (if any) - ) - { - if (!await schemeProvider.IsSupportedOAuthScheme(provider)) - return Problem(OAuthError.ProviderNotSupported); - - if (!ModelState.IsValid) - return BadRequest(new { error = "bad_request", details = ModelState }); - - var action = body.action?.Trim().ToLowerInvariant(); - if (action is not ("create" or "link")) - return BadRequest(new { error = "unknown_action" }); - - // Load snapshot (one-time handoff) - var snapshot = await store.GetAsync(body.flowId); - if (snapshot is null) - { - // stale/expired/consumed flow; clear client cookie too - Response.Cookies.Delete(OpenShockAuthSchemes.OAuthFlowCookie, new CookieOptions { Path = "/" }); - return BadRequest(new { error = "expired" }); - } - - // Provider must match the route (defense-in-depth) - if (!string.Equals(snapshot.Provider, provider, StringComparison.OrdinalIgnoreCase)) - { - await store.DeleteAsync(body.flowId); - Response.Cookies.Delete(OpenShockAuthSchemes.OAuthFlowCookie, new CookieOptions { Path = "/" }); - return Conflict(new { error = "provider_mismatch" }); - } - - // From here on, ensure we always clean up the flow - await store.DeleteAsync(body.flowId); - Response.Cookies.Delete(OpenShockAuthSchemes.OAuthFlowCookie, new CookieOptions { Path = "/" }); - - // Does this external already exist? - var existing = await accountService.GetOAuthConnectionAsync(provider, snapshot.ExternalId); - - if (action == "create") - { - string userId; - - if (existing is not null) - { - // Already linked → log that user in - userId = existing.UserId; - } - else - { - // Create a new local user, then link the external - // TODO: replace with your actual APIs to create a user and link OAuth - // Examples (rename to your signatures): - userId = await accountService.CreateUserAsync( - preferredUserName: snapshot.UserName ?? $"oauth_{provider}_{snapshot.ExternalId}", - email: snapshot.Email); - - await accountService.AddOAuthConnectionAsync( - userId: userId, - provider: provider, - externalId: snapshot.ExternalId, - tokens: snapshot.Tokens); - } - - // Issue your application session now - // TODO: replace token issuance with your real session creation + cookie write - var ctx = new LoginContext - { - Ip = HttpContext.GetRemoteIP().ToString(), - UserAgent = HttpContext.GetUserAgent(), - }; - - var loginAction = await accountService.CreateUserLoginSessionAsync(userId, ctx, HttpContext.RequestAborted); - return await loginAction.MatchAsync( - ok: async ok => - { - // Choose your cookie domain policy as needed - HttpContext.SetSessionKeyCookie(ok.Token /*, "." + cookieDomainToUse */); - // Ensure the external temp principal is gone (if any) - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); - return Ok(new { status = "ok" }); - }, - deactivated: _ => Task.FromResult(Problem(AccountError.AccountDeactivated)), - oauthOnly: _ => Task.FromResult(Problem(AccountError.AccountOAuthOnly)), - notActivated: _ => Task.FromResult(Problem(AccountError.AccountNotActivated)), - notFound: _ => Task.FromResult(Problem(LoginError.InvalidCredentials)) - ); - } - - // action == "link" - // Caller must already be authenticated with your site - if (!(userReferenceService.AuthReference?.Value.IsT0 ?? false)) - return Unauthorized(new { error = "not_authenticated" }); - - var currentUser = clientAuthService.CurrentClient; - if (currentUser is null) - return Unauthorized(new { error = "not_authenticated" }); - - if (existing is not null) - { - // Someone already owns this external identity - return Conflict(new { error = "already_linked" }); - } - - // Attach the external to the current user - await accountService.AddOAuthConnectionAsync( - userId: currentUser.Id, - provider: provider, - externalId: snapshot.ExternalId, - tokens: snapshot.Tokens); - - // Optional: refresh/extend current session if you need to - // await clientAuthService.RefreshAsync(...); - - // Ensure the external temp principal is gone (if any) - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); - - return Ok(new { status = "ok" }); - } -} diff --git a/API/Controller/OAuth/GetData.cs b/API/Controller/OAuth/GetData.cs index d59c540f..413e2bb3 100644 --- a/API/Controller/OAuth/GetData.cs +++ b/API/Controller/OAuth/GetData.cs @@ -6,13 +6,13 @@ using OpenShock.API.OAuth.FlowStore; using OpenShock.Common.Authentication; using OpenShock.Common.Errors; -using Scalar.AspNetCore; namespace OpenShock.API.Controller.OAuth; public sealed partial class OAuthController { - [EnableRateLimiting("auth")] // TODO: VERY IMPORTANT: DO CACHE: NO-STORE + [ResponseCache(NoStore = true)] + [EnableRateLimiting("auth")] [HttpGet("{provider}/data")] public async Task OAuthGetData( [FromRoute] string provider, @@ -22,45 +22,45 @@ public async Task OAuthGetData( if (!await schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.ProviderNotSupported); - if (!Request.Cookies.TryGetValue(OpenShockAuthSchemes.OAuthFlowCookie, out var flowId) || - string.IsNullOrWhiteSpace(flowId)) - return NotFound(new { error = "no_flow" }); + // Temp external principal (set by OAuth handler with SignInScheme=OAuthFlowScheme, SaveTokens=true) + var auth = await HttpContext.AuthenticateAsync(OpenShockAuthSchemes.OAuthFlowScheme); + if (!auth.Succeeded || auth.Principal is null) + return Problem(OAuthError.FlowNotFound); - var snap = await store.GetAsync(flowId); + // Read identifiers from claims (no props.Items) + var flowIdClaim = auth.Principal.FindFirst("flow_id")?.Value; + var providerClaim = auth.Principal.FindFirst("provider")?.Value; + if (string.IsNullOrWhiteSpace(flowIdClaim) || string.IsNullOrWhiteSpace(providerClaim)) + { + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.FlowNotFound); + } + + // Load snapshot + var snap = await store.GetAsync(flowIdClaim); if (snap is null) { - // Clean up stale cookie to avoid client polling loops - Response.Cookies.Delete(OpenShockAuthSchemes.OAuthFlowCookie, new CookieOptions { Path = "/" }); - return NotFound(new { error = "expired" }); + // Stale/missing -> clear temp scheme (cookie+store entry) to stop loops + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.FlowNotFound); } // Defensive: ensure the snapshot belongs to this provider - if (!string.Equals(snap.Provider, provider, StringComparison.OrdinalIgnoreCase)) + if (snap.Provider != provider) { // Optional: you may also delete the cookie if you consider this a poisoned flow - Response.Cookies.Delete(OpenShockAuthSchemes.OAuthFlowCookie, new CookieOptions { Path = "/" }); - // Prefer NotFound to avoid leaking existence across providers - return NotFound(new { error = "provider_mismatch" }); - // Or: return Conflict(new { error = "provider_mismatch" }); + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.ProviderMismatch); } - var now = DateTimeOffset.UtcNow; - var expiresAt = snap.IssuedUtc.Add(OAuthFlow.Ttl); - var expiresIn = (int)Math.Max(0, (expiresAt - now).TotalSeconds); - - var dto = new OAuthPublic( - provider: snap.Provider, - externalId: snap.ExternalId, - email: snap.Email, - userName: snap.UserName, - flowId: flowId, - expiresInSeconds: expiresIn - ); - - // Don’t let proxies/browsers cache this - Response.Headers.CacheControl = "no-store"; - Response.Headers.Pragma = "no-cache"; + var dto = new OAuthPublic + { + Provider = snap.Provider, + Email = snap.Email, + DisplayName = snap.DisplayName, + ExpiresAt = snap.IssuedUtc.Add(OAuthConstants.StateLifetime).UtcDateTime + }; return Ok(dto); } diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index d7e996e6..75729b7f 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -2,13 +2,13 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Extensions; -using OpenShock.API.OAuth; using OpenShock.API.OAuth.FlowStore; using OpenShock.API.Services.Account; using OpenShock.Common.Authentication; using OpenShock.Common.Errors; -using Scalar.AspNetCore; using System.Security.Claims; +using Microsoft.AspNetCore.Http.HttpResults; +using OpenShock.API.OAuth; namespace OpenShock.API.Controller.OAuth; @@ -19,8 +19,7 @@ public sealed partial class OAuthController public async Task OAuthHandOff( [FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider, - [FromServices] IAccountService accountService, - [FromServices] IOAuthFlowStore store) + [FromServices] IAccountService accountService) { if (!await schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.ProviderNotSupported); @@ -28,35 +27,27 @@ public async Task OAuthHandOff( // Temp external principal (set by OAuth handler with SignInScheme=OAuthFlowScheme, SaveTokens=true) var auth = await HttpContext.AuthenticateAsync(OpenShockAuthSchemes.OAuthFlowScheme); if (!auth.Succeeded || auth.Principal is null) - return BadRequest("OAuth sign-in not found or expired."); + return Problem(OAuthError.FlowNotFound); - var props = auth.Properties; - if (props is null || !props.Items.TryGetValue("flow", out var flow) || string.IsNullOrWhiteSpace(flow)) + if (auth.Properties is null || !auth.Properties.Items.TryGetValue("flow", out var flow) || string.IsNullOrWhiteSpace(flow)) { await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); - Response.Cookies.Delete(OpenShockAuthSchemes.OAuthFlowCookie, new CookieOptions { Path = "/" }); - return BadRequest(new { error = "missing_flow" }); + return Problem(OAuthError.InternalError); } flow = flow.ToLowerInvariant(); - var ext = auth.Principal; - var externalId = - ext.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? - ext.FindFirst("sub")?.Value ?? - ext.FindFirst("id")?.Value; + var externalId = auth.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(externalId)) - return Problem("Missing external subject.", statusCode: 400); - - var email = ext.FindFirst(ClaimTypes.Email)?.Value; - var userName = ext.Identity?.Name; - var tokens = (props.GetTokens() ?? Enumerable.Empty()) - .ToDictionary(t => t.Name!, t => t.Value!); + { + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.FlowMissingData); + } var connection = await accountService.GetOAuthConnectionAsync(provider, externalId); switch (flow) { - case "login": + case OAuthConstants.LoginOrCreate: { if (connection is not null) { @@ -66,68 +57,26 @@ public async Task OAuthHandOff( return Redirect("/"); } - var flowId = await SaveSnapshotAsync(store, provider, externalId, email, userName, tokens); - SetFlowCookie(flowId); - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); - var frontend = Environment.GetEnvironmentVariable("FRONTEND_ORIGIN") ?? "https://app.example.com"; return Redirect($"{frontend}/{provider}/create"); } - case "link": + case OAuthConstants.LinkFlow: { if (connection is not null) { + // TODO: Check if the connection is connected to our account with same externalId (AlreadyLinked), different externalId (AlreadyExists), or to another account (LinkedToAnotherAccount) await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); - return Problem( - detail: "This external account is already linked to another user.", - statusCode: 409, - title: "Account already linked"); + return Problem(OAuthError.LinkedToAnotherAccount); } - var flowId = await SaveSnapshotAsync(store, provider, externalId, email, userName, tokens); - SetFlowCookie(flowId); - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); - var frontend = Environment.GetEnvironmentVariable("FRONTEND_ORIGIN") ?? "https://app.example.com"; return Redirect($"{frontend}/{provider}/link"); } default: await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); - Response.Cookies.Delete(OpenShockAuthSchemes.OAuthFlowCookie, new CookieOptions { Path = "/" }); - return BadRequest(new { error = "unknown_flow", flow }); - } - - // --- local helpers --- - async Task SaveSnapshotAsync( - IOAuthFlowStore s, string prov, string extId, string? mail, string? name, - IDictionary tks) - { - var snapshot = new OAuthSnapshot( - Provider: prov, - ExternalId: extId, - Email: mail, - UserName: name, - Tokens: tks, - IssuedUtc: DateTimeOffset.UtcNow); - return await s.SaveAsync(snapshot, OAuthFlow.Ttl); - } - - void SetFlowCookie(string id) - { - Response.Cookies.Append( - OpenShockAuthSchemes.OAuthFlowCookie, - id, - new CookieOptions - { - Secure = HttpContext.Request.IsHttps, - HttpOnly = false, // frontend reads once for /oauth/{provider}/data - SameSite = SameSiteMode.Lax, - Expires = DateTimeOffset.UtcNow.Add(OAuthFlow.Ttl), - Path = "/" - }); + return Problem(OAuthError.FlowNotSupported); } } - } \ No newline at end of file diff --git a/API/Controller/OAuth/_ApiController.cs b/API/Controller/OAuth/_ApiController.cs index 8ace3879..936d4348 100644 --- a/API/Controller/OAuth/_ApiController.cs +++ b/API/Controller/OAuth/_ApiController.cs @@ -1,4 +1,5 @@ using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Account; using OpenShock.Common; diff --git a/API/Extensions/AuthenticationSchemeProviderExtensions.cs b/API/Extensions/AuthenticationSchemeProviderExtensions.cs index a9715c4e..c10ae261 100644 --- a/API/Extensions/AuthenticationSchemeProviderExtensions.cs +++ b/API/Extensions/AuthenticationSchemeProviderExtensions.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Authentication; -using NRedisStack.Search; using OpenShock.Common.Authentication; namespace OpenShock.API.Extensions; diff --git a/API/OAuth/AuthenticationHandler/OAuthFlowAuthenticationHandler.cs b/API/OAuth/AuthenticationHandler/OAuthFlowAuthenticationHandler.cs new file mode 100644 index 00000000..fea303d9 --- /dev/null +++ b/API/OAuth/AuthenticationHandler/OAuthFlowAuthenticationHandler.cs @@ -0,0 +1,137 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using OpenShock.API.OAuth.FlowStore; + +namespace OpenShock.API.OAuth.AuthenticationHandler; + +public sealed class OAuthFlowAuthenticationHandler : AuthenticationHandler, IAuthenticationSignInHandler +{ + public const string CookieName = ".OpenShock.OAuthFlow"; + + private readonly IOAuthFlowStore _store; + + public OAuthFlowAuthenticationHandler( + IOAuthFlowStore store, + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) + { + _store = store; + } + + protected override async Task HandleAuthenticateAsync() + { + if (!Request.Cookies.TryGetValue(CookieName, out var flowId) || string.IsNullOrWhiteSpace(flowId)) + { + DeleteCookie(); + return AuthenticateResult.NoResult(); + } + + var snapshot = await _store.GetAsync(flowId); + if (snapshot is null) + { + // stale cookie: nuke it + await DeleteSession(flowId); + return AuthenticateResult.NoResult(); + } + + List claims = [ + new("flow_id", snapshot.FlowId), + new("provider", snapshot.Provider), + new(ClaimTypes.NameIdentifier, snapshot.ExternalId, ClaimValueTypes.String, snapshot.Provider), + ]; + if (!string.IsNullOrEmpty(snapshot.Email)) claims.Add(new Claim(ClaimTypes.Email, snapshot.Email, ClaimValueTypes.String, snapshot.Provider)); + if (!string.IsNullOrEmpty(snapshot.DisplayName)) claims.Add(new Claim(ClaimTypes.Name, snapshot.DisplayName, ClaimValueTypes.String, snapshot.Provider)); + + var ident = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(ident); + + var props = new AuthenticationProperties + { + IssuedUtc = snapshot.IssuedUtc, + ExpiresUtc = snapshot.IssuedUtc.Add(OAuthConstants.StateLifetime), + IsPersistent = false, + }; + + var ticket = new AuthenticationTicket(principal, props, Scheme.Name); + + return AuthenticateResult.Success(ticket); + } + + public async Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties) + { + var issuedUtc = properties?.IssuedUtc ?? DateTimeOffset.UtcNow; + + var idn = user.Identities.Single(); + var provider = idn.Claims.FirstOrDefault()?.Issuer + ?? idn?.AuthenticationType + ?? "external"; + + var externalId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? throw new InvalidOperationException("Missing external subject (NameIdentifier)."); + + var email = user.FindFirst(ClaimTypes.Email)?.Value; + var displayName = user.Identity?.Name; + + // Persist minimal snapshot (tokens, if any, handled by your store overloads) + var flowId = await _store.SaveAsync(provider, externalId, email, displayName, issuedUtc); + + // Hand off to browser via short-lived HttpOnly cookie + SetCookie(flowId, issuedUtc.Add(OAuthConstants.StateLifetime)); + } + + // ===== sign-out (remove from redis + clear cookie) ===== + public async Task SignOutAsync(AuthenticationProperties? properties) + { + if (Request.Cookies.TryGetValue(CookieName, out var flowId) && !string.IsNullOrWhiteSpace(flowId)) + { + await DeleteSession(flowId); + } + else + { + DeleteCookie(); + } + } + + // not really used for this temp scheme; return harmless codes + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + } + + protected override Task HandleForbiddenAsync(AuthenticationProperties properties) + { + Response.StatusCode = StatusCodes.Status403Forbidden; + return Task.CompletedTask; + } + + private void SetCookie(string flowId, DateTimeOffset expires) + { + Response.Cookies.Append( + CookieName, + flowId, + new CookieOptions + { + Path = "/", + Secure = true, + SameSite = SameSiteMode.Lax, // TODO: This should probably be way more secure? + HttpOnly = true, + IsEssential = true, + Expires = expires + } + ); + } + + private void DeleteCookie() + { + Response.Cookies.Delete(CookieName, new CookieOptions { Path = "/" }); + } + private async Task DeleteSession(string flowId) + { + await _store.DeleteAsync(flowId); + DeleteCookie(); + } +} diff --git a/API/OAuth/FlowStore/CacheOAuthFlowStore.cs b/API/OAuth/FlowStore/CacheOAuthFlowStore.cs index b846310d..011e69dd 100644 --- a/API/OAuth/FlowStore/CacheOAuthFlowStore.cs +++ b/API/OAuth/FlowStore/CacheOAuthFlowStore.cs @@ -1,29 +1,50 @@ -using Microsoft.Extensions.Caching.Distributed; -using System.Security.Cryptography; -using System.Text.Json; +using OpenShock.API.Controller.OAuth; +using OpenShock.Common.Authentication; +using OpenShock.Common.Utils; +using Redis.OM.Contracts; +using Redis.OM.Searching; namespace OpenShock.API.OAuth.FlowStore; -public sealed class CacheOAuthFlowStore(IDistributedCache cache) : IOAuthFlowStore +public sealed class CacheOAuthFlowStore : IOAuthFlowStore { - private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); - private static string Key(string id) => $"oauth:flow:{id}"; + private readonly IRedisCollection _cache; - public async Task SaveAsync(OAuthSnapshot snap, TimeSpan ttl, CancellationToken ct = default) + public CacheOAuthFlowStore(IRedisConnectionProvider redisConnectionProvider) { - var id = Convert.ToBase64String(RandomNumberGenerator.GetBytes(18)) - .TrimEnd('=').Replace('+', '-').Replace('/', '_'); // url-safe - var json = JsonSerializer.Serialize(snap, JsonOpts); - await cache.SetStringAsync(Key(id), json, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl }, ct); + _cache = redisConnectionProvider.RedisCollection(); + } + + public async Task SaveAsync(string provider, string externalId, string? email, string? displayName, DateTimeOffset issuedUtc) + { + var id = CryptoUtils.RandomString(32); + + var snap = new OAuthSnapshot + { + FlowId = id, + Provider = provider, + ExternalId = externalId, + DisplayName = displayName, + Email = email, + IssuedUtc = issuedUtc + }; + + await _cache.InsertAsync(snap, OAuthConstants.StateLifetime); + return id; } - public async Task GetAsync(string flowId, CancellationToken ct = default) + public async Task GetAsync(string flowId) { - var json = await cache.GetStringAsync(Key(flowId), ct); - return json is null ? null : JsonSerializer.Deserialize(json, JsonOpts); + return await _cache.FindByIdAsync(flowId); } - public Task DeleteAsync(string flowId, CancellationToken ct = default) - => cache.RemoveAsync(Key(flowId), ct); + public async Task DeleteAsync(string flowId) + { + var snapshot = await _cache.FindByIdAsync(flowId); + if (snapshot is not null) + { + await _cache.DeleteAsync(snapshot); + } + } } \ No newline at end of file diff --git a/API/OAuth/FlowStore/IOAuthFlowStore.cs b/API/OAuth/FlowStore/IOAuthFlowStore.cs index c205f33c..c6b6a87b 100644 --- a/API/OAuth/FlowStore/IOAuthFlowStore.cs +++ b/API/OAuth/FlowStore/IOAuthFlowStore.cs @@ -2,7 +2,7 @@ public interface IOAuthFlowStore { - Task SaveAsync(OAuthSnapshot snap, TimeSpan ttl, CancellationToken ct = default); - Task GetAsync(string flowId, CancellationToken ct = default); - Task DeleteAsync(string flowId, CancellationToken ct = default); + Task SaveAsync(string provider, string externalId, string? email, string? displayName, DateTimeOffset issuedUtc); + Task GetAsync(string flowId); + Task DeleteAsync(string flowId); } \ No newline at end of file diff --git a/API/OAuth/FlowStore/OAuthSnapshot.cs b/API/OAuth/FlowStore/OAuthSnapshot.cs new file mode 100644 index 00000000..3b3111dc --- /dev/null +++ b/API/OAuth/FlowStore/OAuthSnapshot.cs @@ -0,0 +1,16 @@ +using Redis.OM.Modeling; + +namespace OpenShock.API.OAuth.FlowStore; + +[Document(StorageType = StorageType.Json, IndexName = IndexName)] +public sealed class OAuthSnapshot +{ + public const string IndexName = "oauth-snapshot"; + + [RedisField] public required string FlowId { get; init; } + public required string Provider { get; init; } + public required string ExternalId { get; init; } + public required string? Email { get; init; } + public required string? DisplayName { get; init; } + public required DateTimeOffset IssuedUtc { get; init; } +} \ No newline at end of file diff --git a/API/OAuth/OAuthConstants.cs b/API/OAuth/OAuthConstants.cs new file mode 100644 index 00000000..37af311a --- /dev/null +++ b/API/OAuth/OAuthConstants.cs @@ -0,0 +1,11 @@ +namespace OpenShock.API.OAuth; + +public static class OAuthConstants +{ + public const string LoginOrCreate = "login-or-create"; + public const string LinkFlow = "link"; + + public static readonly TimeSpan StateLifetime = TimeSpan.FromMinutes(10); + + public const string StateCachePrefix = "oauth:state:"; +} \ No newline at end of file diff --git a/API/OAuth/OAuthPublic.cs b/API/OAuth/OAuthPublic.cs index 9722ab1b..c1f5190f 100644 --- a/API/OAuth/OAuthPublic.cs +++ b/API/OAuth/OAuthPublic.cs @@ -1,10 +1,10 @@ namespace OpenShock.API.OAuth; // what we return to frontend at /oauth/discord/data -public sealed record OAuthPublic( - string provider, - string externalId, - string? email, - string? userName, - string flowId, // opaque id the frontend will POST back to finalize - int expiresInSeconds); \ No newline at end of file +public sealed class OAuthPublic +{ + public required string Provider { get; init; } + public required string? Email { get; init; } + public required string? DisplayName { get; init; } + public required DateTime ExpiresAt { get; init; } +} \ No newline at end of file diff --git a/API/OAuth/OAuthSnapshot.cs b/API/OAuth/OAuthSnapshot.cs deleted file mode 100644 index 948ecb8b..00000000 --- a/API/OAuth/OAuthSnapshot.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace OpenShock.API.OAuth; - -public sealed record OAuthSnapshot( - string Provider, - string ExternalId, - string? Email, - string? UserName, - IDictionary Tokens, - DateTimeOffset IssuedUtc); \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 74854483..cea38e89 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.Connections; using Microsoft.Extensions.Options; +using OpenShock.API.OAuth.AuthenticationHandler; using OpenShock.API.OAuth.FlowStore; using OpenShock.API.Options.OAuth; using OpenShock.API.Realtime; @@ -42,10 +43,16 @@ builder.Services.AddOpenShockServices(auth => auth .AddCookie(OpenShockAuthSchemes.OAuthFlowScheme, o => { - o.Cookie.Name = OpenShockAuthSchemes.OAuthFlowCookie; + o.Cookie.Name = OpenShockAuthSchemes.OAuthFlowCookieName; o.ExpireTimeSpan = TimeSpan.FromMinutes(10); o.SlidingExpiration = false; }) + /* + .AddScheme(OpenShockAuthSchemes.OAuthFlowScheme, o => + { + + }) + */ .AddDiscord(OpenShockAuthSchemes.DiscordScheme, o => { o.SignInScheme = OpenShockAuthSchemes.OAuthFlowScheme; diff --git a/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs b/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs index 9ac2bfd0..0d4d8a95 100644 --- a/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs +++ b/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs @@ -66,7 +66,7 @@ protected override async Task HandleAuthenticateAsync() List claims = new List(3 + tokenDto.Permissions.Count) { - new(ClaimTypes.AuthenticationMethod, OpenShockAuthSchemes.ApiToken), + new(ClaimTypes.AuthenticationMethod, Scheme.Name), new(ClaimTypes.NameIdentifier, tokenDto.User.Id.ToString()), new(OpenShockAuthClaims.ApiTokenId, tokenDto.Id.ToString()) }; @@ -78,8 +78,6 @@ protected override async Task HandleAuthenticateAsync() var ident = new ClaimsIdentity(claims, nameof(ApiTokenAuthentication)); - Context.User = new ClaimsPrincipal(ident); - var ticket = new AuthenticationTicket(new ClaimsPrincipal(ident), Scheme.Name); return AuthenticateResult.Success(ticket); diff --git a/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs b/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs index 65f4557c..19801918 100644 --- a/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs +++ b/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs @@ -60,11 +60,13 @@ protected override async Task HandleAuthenticateAsync() _authService.CurrentClient = device; Claim[] claims = [ - new(ClaimTypes.AuthenticationMethod, OpenShockAuthSchemes.HubToken), + new(ClaimTypes.AuthenticationMethod, Scheme.Name), new Claim(ClaimTypes.NameIdentifier, device.OwnerId.ToString()), new Claim(OpenShockAuthClaims.HubId, _authService.CurrentClient.Id.ToString()), ]; + var ident = new ClaimsIdentity(claims, nameof(HubAuthentication)); + var ticket = new AuthenticationTicket(new ClaimsPrincipal(ident), Scheme.Name); return AuthenticateResult.Success(ticket); diff --git a/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs b/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs index 83918108..511f5b7d 100644 --- a/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs +++ b/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs @@ -81,16 +81,14 @@ protected override async Task HandleAuthenticateAsync() _userReferenceService.AuthReference = session; List claims = [ - new(ClaimTypes.AuthenticationMethod, OpenShockAuthSchemes.UserSessionCookie), + new(ClaimTypes.AuthenticationMethod, Scheme.Name), new(ClaimTypes.NameIdentifier, retrievedUser.Id.ToString()), ]; claims.AddRange(retrievedUser.Roles.Select(r => new Claim(ClaimTypes.Role, r.ToString()))); var ident = new ClaimsIdentity(claims, nameof(UserSessionAuthentication)); - - Context.User = new ClaimsPrincipal(ident); - + var ticket = new AuthenticationTicket(new ClaimsPrincipal(ident), Scheme.Name); return AuthenticateResult.Success(ticket); diff --git a/Common/Authentication/OpenShockAuthSchemes.cs b/Common/Authentication/OpenShockAuthSchemes.cs index 43e7b981..50f6d014 100644 --- a/Common/Authentication/OpenShockAuthSchemes.cs +++ b/Common/Authentication/OpenShockAuthSchemes.cs @@ -7,8 +7,8 @@ public static class OpenShockAuthSchemes public const string HubToken = "HubToken"; public const string OAuthFlowScheme = "OAuthFlowCookie"; - public const string OAuthFlowCookie = ".OpenShock.OAuthFlow"; - public const string DiscordScheme = "discord"; + public const string OAuthFlowCookieName = ".OpenShock.OAuthFlow"; + public const string DiscordScheme = "oauth-discord"; public static readonly string[] OAuth2Schemes = [DiscordScheme]; public const string UserSessionApiTokenCombo = $"{UserSessionCookie},{ApiToken}"; diff --git a/Common/Common.csproj b/Common/Common.csproj index 0c57f624..a5cc32c4 100644 --- a/Common/Common.csproj +++ b/Common/Common.csproj @@ -13,6 +13,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Common/Errors/OAuthError.cs b/Common/Errors/OAuthError.cs index c8d35924..597b1c12 100644 --- a/Common/Errors/OAuthError.cs +++ b/Common/Errors/OAuthError.cs @@ -5,12 +5,25 @@ namespace OpenShock.Common.Errors; public static class OAuthError { - public static OpenShockProblem FlowNotSupported => new OpenShockProblem( - "OAuth.Flow.NotSupported", "This OAuth flow is not supported", HttpStatusCode.Forbidden); - public static OpenShockProblem ProviderNotSupported => new OpenShockProblem( "OAuth.Provider.NotSupported", "This OAuth provider is not supported", HttpStatusCode.Forbidden); + public static OpenShockProblem ProviderMismatch => new OpenShockProblem( + "OAuth.Provider.Mismatch", "????????????????", HttpStatusCode.BadRequest); + + public static OpenShockProblem FlowNotSupported => new OpenShockProblem( + "OAuth.Flow.NotSupported", "This OAuth flow is not supported", HttpStatusCode.Forbidden); + public static OpenShockProblem FlowNotFound => new OpenShockProblem( + "OAuth.Flow.NotFound", "This OAuth flow is expired or invalid", HttpStatusCode.BadRequest); + + public static OpenShockProblem FlowMissingData => new OpenShockProblem( + "OAuth.Flow.MissingData", "The OAuth provider supplied less data that expected", HttpStatusCode.InternalServerError); public static OpenShockProblem AlreadyExists => new OpenShockProblem( - "OAuth.Connections.AlreadyExists", "There is already an OAuth connection of this type in your account", HttpStatusCode.Conflict); + "OAuth.Connection.AlreadyExists", "There is already an OAuth connection of this type in your account", HttpStatusCode.Conflict); + + public static OpenShockProblem LinkedToAnotherAccount => new OpenShockProblem( + "OAuth.Connection.LinkedToStranger", "This external account is already linked to another user", HttpStatusCode.Conflict); + + public static OpenShockProblem InternalError => new OpenShockProblem( + "OAuth.InternalError", "Encountered an unexpected error while processing your OAuth flow", HttpStatusCode.InternalServerError); } \ No newline at end of file diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index f210b1ba..16d95a55 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using OpenShock.Common.Constants; using OpenShock.Common.Extensions; using OpenShock.Common.Models; @@ -46,7 +47,7 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) /// /// Main OpenShock DB Context /// -public class OpenShockContext : DbContext +public class OpenShockContext : DbContext, IDataProtectionKeyContext { public OpenShockContext() { @@ -125,6 +126,8 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet UserNameBlacklists { get; set; } public DbSet EmailProviderBlacklists { get; set; } + + public DbSet DataProtectionKeys { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index dc8c9a58..cb41118f 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using Asp.Versioning; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -128,6 +129,7 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se services.AddScoped, ClientAuthService>(); services.AddScoped(); + services.AddDataProtection().PersistKeysToDbContext(); services.AddAuthenticationCore(); var authBuilder = new AuthenticationBuilder(services) .AddScheme( @@ -137,11 +139,8 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se .AddScheme( OpenShockAuthSchemes.HubToken, _ => { }); - if (configureAuth is not null) - { - configureAuth(authBuilder); - } - + configureAuth?.Invoke(authBuilder); + services.AddAuthorization(options => { options.AddPolicy(OpenShockAuthPolicies.RankAdmin, policy => policy.RequireRole("Admin", "System")); From 0d8928a41d493924a512c32f97a0a3811a8fa825 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Thu, 4 Sep 2025 01:50:59 +0200 Subject: [PATCH 35/63] Idk where this is going --- API/Controller/OAuth/GetData.cs | 36 ++--- API/Controller/OAuth/HandOff.cs | 2 - .../Response/OAuthDataResponse.cs} | 4 +- .../OAuthFlowAuthenticationHandler.cs | 137 ------------------ API/OAuth/FlowStore/CacheOAuthFlowStore.cs | 50 ------- API/OAuth/FlowStore/IOAuthFlowStore.cs | 8 - API/OAuth/FlowStore/OAuthSnapshot.cs | 16 -- API/Program.cs | 12 +- Common/OpenShockMiddlewareHelper.cs | 1 - 9 files changed, 15 insertions(+), 251 deletions(-) rename API/{OAuth/OAuthPublic.cs => Models/Response/OAuthDataResponse.cs} (76%) delete mode 100644 API/OAuth/AuthenticationHandler/OAuthFlowAuthenticationHandler.cs delete mode 100644 API/OAuth/FlowStore/CacheOAuthFlowStore.cs delete mode 100644 API/OAuth/FlowStore/IOAuthFlowStore.cs delete mode 100644 API/OAuth/FlowStore/OAuthSnapshot.cs diff --git a/API/Controller/OAuth/GetData.cs b/API/Controller/OAuth/GetData.cs index 413e2bb3..0ea87a78 100644 --- a/API/Controller/OAuth/GetData.cs +++ b/API/Controller/OAuth/GetData.cs @@ -1,9 +1,9 @@ -using Microsoft.AspNetCore.Authentication; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Extensions; -using OpenShock.API.OAuth; -using OpenShock.API.OAuth.FlowStore; +using OpenShock.API.Models.Response; using OpenShock.Common.Authentication; using OpenShock.Common.Errors; @@ -14,10 +14,7 @@ public sealed partial class OAuthController [ResponseCache(NoStore = true)] [EnableRateLimiting("auth")] [HttpGet("{provider}/data")] - public async Task OAuthGetData( - [FromRoute] string provider, - [FromServices] IAuthenticationSchemeProvider schemeProvider, - [FromServices] IOAuthFlowStore store) + public async Task OAuthGetData([FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider) { if (!await schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.ProviderNotSupported); @@ -37,31 +34,20 @@ public async Task OAuthGetData( return Problem(OAuthError.FlowNotFound); } - // Load snapshot - var snap = await store.GetAsync(flowIdClaim); - if (snap is null) - { - // Stale/missing -> clear temp scheme (cookie+store entry) to stop loops - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); - return Problem(OAuthError.FlowNotFound); - } - // Defensive: ensure the snapshot belongs to this provider - if (snap.Provider != provider) + if (providerClaim != provider) { // Optional: you may also delete the cookie if you consider this a poisoned flow await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); return Problem(OAuthError.ProviderMismatch); } - var dto = new OAuthPublic + return Ok(new OAuthDataResponse { - Provider = snap.Provider, - Email = snap.Email, - DisplayName = snap.DisplayName, - ExpiresAt = snap.IssuedUtc.Add(OAuthConstants.StateLifetime).UtcDateTime - }; - - return Ok(dto); + Provider = providerClaim, + Email = auth.Principal.FindFirst(ClaimTypes.Email)?.Value, + DisplayName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value, + ExpiresAt = auth.Ticket.Properties.ExpiresUtc!.Value.UtcDateTime + }); } } diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index 75729b7f..2f58bb1d 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -2,12 +2,10 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Extensions; -using OpenShock.API.OAuth.FlowStore; using OpenShock.API.Services.Account; using OpenShock.Common.Authentication; using OpenShock.Common.Errors; using System.Security.Claims; -using Microsoft.AspNetCore.Http.HttpResults; using OpenShock.API.OAuth; namespace OpenShock.API.Controller.OAuth; diff --git a/API/OAuth/OAuthPublic.cs b/API/Models/Response/OAuthDataResponse.cs similarity index 76% rename from API/OAuth/OAuthPublic.cs rename to API/Models/Response/OAuthDataResponse.cs index c1f5190f..7f4ade5b 100644 --- a/API/OAuth/OAuthPublic.cs +++ b/API/Models/Response/OAuthDataResponse.cs @@ -1,7 +1,7 @@ -namespace OpenShock.API.OAuth; +namespace OpenShock.API.Models.Response; // what we return to frontend at /oauth/discord/data -public sealed class OAuthPublic +public sealed class OAuthDataResponse { public required string Provider { get; init; } public required string? Email { get; init; } diff --git a/API/OAuth/AuthenticationHandler/OAuthFlowAuthenticationHandler.cs b/API/OAuth/AuthenticationHandler/OAuthFlowAuthenticationHandler.cs deleted file mode 100644 index fea303d9..00000000 --- a/API/OAuth/AuthenticationHandler/OAuthFlowAuthenticationHandler.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System.Security.Claims; -using System.Text.Encodings.Web; -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Options; -using OpenShock.API.OAuth.FlowStore; - -namespace OpenShock.API.OAuth.AuthenticationHandler; - -public sealed class OAuthFlowAuthenticationHandler : AuthenticationHandler, IAuthenticationSignInHandler -{ - public const string CookieName = ".OpenShock.OAuthFlow"; - - private readonly IOAuthFlowStore _store; - - public OAuthFlowAuthenticationHandler( - IOAuthFlowStore store, - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder) - : base(options, logger, encoder) - { - _store = store; - } - - protected override async Task HandleAuthenticateAsync() - { - if (!Request.Cookies.TryGetValue(CookieName, out var flowId) || string.IsNullOrWhiteSpace(flowId)) - { - DeleteCookie(); - return AuthenticateResult.NoResult(); - } - - var snapshot = await _store.GetAsync(flowId); - if (snapshot is null) - { - // stale cookie: nuke it - await DeleteSession(flowId); - return AuthenticateResult.NoResult(); - } - - List claims = [ - new("flow_id", snapshot.FlowId), - new("provider", snapshot.Provider), - new(ClaimTypes.NameIdentifier, snapshot.ExternalId, ClaimValueTypes.String, snapshot.Provider), - ]; - if (!string.IsNullOrEmpty(snapshot.Email)) claims.Add(new Claim(ClaimTypes.Email, snapshot.Email, ClaimValueTypes.String, snapshot.Provider)); - if (!string.IsNullOrEmpty(snapshot.DisplayName)) claims.Add(new Claim(ClaimTypes.Name, snapshot.DisplayName, ClaimValueTypes.String, snapshot.Provider)); - - var ident = new ClaimsIdentity(claims, Scheme.Name); - var principal = new ClaimsPrincipal(ident); - - var props = new AuthenticationProperties - { - IssuedUtc = snapshot.IssuedUtc, - ExpiresUtc = snapshot.IssuedUtc.Add(OAuthConstants.StateLifetime), - IsPersistent = false, - }; - - var ticket = new AuthenticationTicket(principal, props, Scheme.Name); - - return AuthenticateResult.Success(ticket); - } - - public async Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties) - { - var issuedUtc = properties?.IssuedUtc ?? DateTimeOffset.UtcNow; - - var idn = user.Identities.Single(); - var provider = idn.Claims.FirstOrDefault()?.Issuer - ?? idn?.AuthenticationType - ?? "external"; - - var externalId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? throw new InvalidOperationException("Missing external subject (NameIdentifier)."); - - var email = user.FindFirst(ClaimTypes.Email)?.Value; - var displayName = user.Identity?.Name; - - // Persist minimal snapshot (tokens, if any, handled by your store overloads) - var flowId = await _store.SaveAsync(provider, externalId, email, displayName, issuedUtc); - - // Hand off to browser via short-lived HttpOnly cookie - SetCookie(flowId, issuedUtc.Add(OAuthConstants.StateLifetime)); - } - - // ===== sign-out (remove from redis + clear cookie) ===== - public async Task SignOutAsync(AuthenticationProperties? properties) - { - if (Request.Cookies.TryGetValue(CookieName, out var flowId) && !string.IsNullOrWhiteSpace(flowId)) - { - await DeleteSession(flowId); - } - else - { - DeleteCookie(); - } - } - - // not really used for this temp scheme; return harmless codes - protected override Task HandleChallengeAsync(AuthenticationProperties properties) - { - Response.StatusCode = StatusCodes.Status401Unauthorized; - return Task.CompletedTask; - } - - protected override Task HandleForbiddenAsync(AuthenticationProperties properties) - { - Response.StatusCode = StatusCodes.Status403Forbidden; - return Task.CompletedTask; - } - - private void SetCookie(string flowId, DateTimeOffset expires) - { - Response.Cookies.Append( - CookieName, - flowId, - new CookieOptions - { - Path = "/", - Secure = true, - SameSite = SameSiteMode.Lax, // TODO: This should probably be way more secure? - HttpOnly = true, - IsEssential = true, - Expires = expires - } - ); - } - - private void DeleteCookie() - { - Response.Cookies.Delete(CookieName, new CookieOptions { Path = "/" }); - } - private async Task DeleteSession(string flowId) - { - await _store.DeleteAsync(flowId); - DeleteCookie(); - } -} diff --git a/API/OAuth/FlowStore/CacheOAuthFlowStore.cs b/API/OAuth/FlowStore/CacheOAuthFlowStore.cs deleted file mode 100644 index 011e69dd..00000000 --- a/API/OAuth/FlowStore/CacheOAuthFlowStore.cs +++ /dev/null @@ -1,50 +0,0 @@ -using OpenShock.API.Controller.OAuth; -using OpenShock.Common.Authentication; -using OpenShock.Common.Utils; -using Redis.OM.Contracts; -using Redis.OM.Searching; - -namespace OpenShock.API.OAuth.FlowStore; - -public sealed class CacheOAuthFlowStore : IOAuthFlowStore -{ - private readonly IRedisCollection _cache; - - public CacheOAuthFlowStore(IRedisConnectionProvider redisConnectionProvider) - { - _cache = redisConnectionProvider.RedisCollection(); - } - - public async Task SaveAsync(string provider, string externalId, string? email, string? displayName, DateTimeOffset issuedUtc) - { - var id = CryptoUtils.RandomString(32); - - var snap = new OAuthSnapshot - { - FlowId = id, - Provider = provider, - ExternalId = externalId, - DisplayName = displayName, - Email = email, - IssuedUtc = issuedUtc - }; - - await _cache.InsertAsync(snap, OAuthConstants.StateLifetime); - - return id; - } - - public async Task GetAsync(string flowId) - { - return await _cache.FindByIdAsync(flowId); - } - - public async Task DeleteAsync(string flowId) - { - var snapshot = await _cache.FindByIdAsync(flowId); - if (snapshot is not null) - { - await _cache.DeleteAsync(snapshot); - } - } -} \ No newline at end of file diff --git a/API/OAuth/FlowStore/IOAuthFlowStore.cs b/API/OAuth/FlowStore/IOAuthFlowStore.cs deleted file mode 100644 index c6b6a87b..00000000 --- a/API/OAuth/FlowStore/IOAuthFlowStore.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace OpenShock.API.OAuth.FlowStore; - -public interface IOAuthFlowStore -{ - Task SaveAsync(string provider, string externalId, string? email, string? displayName, DateTimeOffset issuedUtc); - Task GetAsync(string flowId); - Task DeleteAsync(string flowId); -} \ No newline at end of file diff --git a/API/OAuth/FlowStore/OAuthSnapshot.cs b/API/OAuth/FlowStore/OAuthSnapshot.cs deleted file mode 100644 index 3b3111dc..00000000 --- a/API/OAuth/FlowStore/OAuthSnapshot.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Redis.OM.Modeling; - -namespace OpenShock.API.OAuth.FlowStore; - -[Document(StorageType = StorageType.Json, IndexName = IndexName)] -public sealed class OAuthSnapshot -{ - public const string IndexName = "oauth-snapshot"; - - [RedisField] public required string FlowId { get; init; } - public required string Provider { get; init; } - public required string ExternalId { get; init; } - public required string? Email { get; init; } - public required string? DisplayName { get; init; } - public required DateTimeOffset IssuedUtc { get; init; } -} \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index cea38e89..838a3c0d 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,8 +1,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.Connections; using Microsoft.Extensions.Options; -using OpenShock.API.OAuth.AuthenticationHandler; -using OpenShock.API.OAuth.FlowStore; +using OpenShock.API.OAuth; using OpenShock.API.Options.OAuth; using OpenShock.API.Realtime; using OpenShock.API.Services; @@ -44,15 +43,9 @@ .AddCookie(OpenShockAuthSchemes.OAuthFlowScheme, o => { o.Cookie.Name = OpenShockAuthSchemes.OAuthFlowCookieName; - o.ExpireTimeSpan = TimeSpan.FromMinutes(10); + o.ExpireTimeSpan = OAuthConstants.StateLifetime; o.SlidingExpiration = false; }) - /* - .AddScheme(OpenShockAuthSchemes.OAuthFlowScheme, o => - { - - }) - */ .AddDiscord(OpenShockAuthSchemes.DiscordScheme, o => { o.SignInScheme = OpenShockAuthSchemes.OAuthFlowScheme; @@ -82,7 +75,6 @@ options.PayloadSerializerOptions.Converters.Add(new SemVersionJsonConverter()); }); -builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Common/OpenShockMiddlewareHelper.cs b/Common/OpenShockMiddlewareHelper.cs index 43c905f7..23148fef 100644 --- a/Common/OpenShockMiddlewareHelper.cs +++ b/Common/OpenShockMiddlewareHelper.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Options; From a37b988d2b0e53c0a8044fffc31336da531899fa Mon Sep 17 00:00:00 2001 From: hhvrc Date: Thu, 4 Sep 2025 02:13:52 +0200 Subject: [PATCH 36/63] Absolute cinema. --- .../Authenticated/OAuthConnectionAdd.cs | 2 +- API/Controller/OAuth/Authorize.cs | 6 +- Common/Authentication/OpenShockAuthSchemes.cs | 2 +- ...20250903235304_AddOAuthSupport.Designer.cs | 1464 +++++++++++++++++ .../20250903235304_AddOAuthSupport.cs | 92 ++ .../OpenShockContextModelSnapshot.cs | 69 +- 6 files changed, 1628 insertions(+), 7 deletions(-) create mode 100644 Common/Migrations/20250903235304_AddOAuthSupport.Designer.cs create mode 100644 Common/Migrations/20250903235304_AddOAuthSupport.cs diff --git a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs index cdd3b203..e3248109 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs @@ -15,6 +15,6 @@ public async Task AddOAuthConnection([FromRoute] string provider, if (!await schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.ProviderNotSupported); - return Challenge(new AuthenticationProperties { RedirectUri = $"/oauth/{provider}/complete", Items = {{ "flow", OAuthConstants.LinkFlow }} }, authenticationSchemes: [provider]); + return Challenge(new AuthenticationProperties { RedirectUri = $"/1/oauth/{provider}/handoff", Items = {{ "flow", OAuthConstants.LinkFlow }} }, authenticationSchemes: [provider]); } } \ No newline at end of file diff --git a/API/Controller/OAuth/Authorize.cs b/API/Controller/OAuth/Authorize.cs index e53fae8d..f536f391 100644 --- a/API/Controller/OAuth/Authorize.cs +++ b/API/Controller/OAuth/Authorize.cs @@ -10,12 +10,12 @@ namespace OpenShock.API.Controller.OAuth; public sealed partial class OAuthController { [EnableRateLimiting("auth")] - [HttpPost("{provider}/authorize")] - public async Task OAuthAuthorize([FromRoute] string provider, [FromQuery(Name = "return_to")] string returnTo, [FromServices] IAuthenticationSchemeProvider schemeProvider) + [HttpGet("{provider}/authorize")] + public async Task OAuthAuthorize([FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider) { if (!await schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.ProviderNotSupported); - return Challenge(new AuthenticationProperties { RedirectUri = $"/oauth/{provider}/complete", Items = { { "flow", OAuthConstants.LoginOrCreate } } }, authenticationSchemes: [provider]); + return Challenge(new AuthenticationProperties { RedirectUri = $"/1/oauth/{provider}/handoff", Items = { { "flow", OAuthConstants.LoginOrCreate } } }, authenticationSchemes: [provider]); } } \ No newline at end of file diff --git a/Common/Authentication/OpenShockAuthSchemes.cs b/Common/Authentication/OpenShockAuthSchemes.cs index 50f6d014..27ebe9b4 100644 --- a/Common/Authentication/OpenShockAuthSchemes.cs +++ b/Common/Authentication/OpenShockAuthSchemes.cs @@ -8,7 +8,7 @@ public static class OpenShockAuthSchemes public const string OAuthFlowScheme = "OAuthFlowCookie"; public const string OAuthFlowCookieName = ".OpenShock.OAuthFlow"; - public const string DiscordScheme = "oauth-discord"; + public const string DiscordScheme = "discord"; public static readonly string[] OAuth2Schemes = [DiscordScheme]; public const string UserSessionApiTokenCombo = $"{UserSessionCookie},{ApiToken}"; diff --git a/Common/Migrations/20250903235304_AddOAuthSupport.Designer.cs b/Common/Migrations/20250903235304_AddOAuthSupport.Designer.cs new file mode 100644 index 00000000..64b05549 --- /dev/null +++ b/Common/Migrations/20250903235304_AddOAuthSupport.Designer.cs @@ -0,0 +1,1464 @@ +// +using System; +using System.Collections.Generic; +using System.Net; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + [DbContext(typeof(MigrationOpenShockContext))] + [Migration("20250903235304_AddOAuthSupport")] + partial class AddOAuthSupport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "match_type_enum", new[] { "exact", "contains" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "pbkdf2", "bcrypt_enhanced" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "caiXianlin", "petTrainer", "petrainer998DR" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b => + { + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("ApiTokenCount") + .HasColumnType("integer") + .HasColumnName("api_token_count"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deactivated_at"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeviceCount") + .HasColumnType("integer") + .HasColumnName("device_count"); + + b.Property("Email") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("email"); + + b.Property("EmailChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("email_change_request_count"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("name"); + + b.Property("NameChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("name_change_request_count"); + + b.Property("PasswordHashType") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("password_hash_type"); + + b.Property("PasswordResetCount") + .HasColumnType("integer") + .HasColumnName("password_reset_count"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("ShockerControlLogCount") + .HasColumnType("integer") + .HasColumnName("shocker_control_log_count"); + + b.Property("ShockerCount") + .HasColumnType("integer") + .HasColumnName("shocker_count"); + + b.Property("ShockerPublicShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_public_share_count"); + + b.Property("ShockerUserShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_user_share_count"); + + b.ToTable((string)null); + + b.ToView("admin_users_view", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByIp") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("created_by_ip"); + + b.Property("LastUsed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used") + .HasDefaultValueSql("'-infinity'::timestamp without time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.PrimitiveCollection>("Permissions") + .IsRequired() + .HasColumnType("permission_type[]") + .HasColumnName("permissions"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("valid_until"); + + b.HasKey("Id") + .HasName("api_tokens_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("ValidUntil"); + + b.ToTable("api_tokens", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AffectedCount") + .HasColumnType("integer") + .HasColumnName("affected_count"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("IpCountry") + .HasColumnType("text") + .HasColumnName("ip_country"); + + b.Property("SubmittedCount") + .HasColumnType("integer") + .HasColumnName("submitted_count"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("api_token_reports_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("api_token_reports", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name") + .UseCollation("C"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Type") + .HasColumnType("configuration_value_type") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Name") + .HasName("configuration_pkey"); + + b.ToTable("configuration", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("token") + .UseCollation("C"); + + b.HasKey("Id") + .HasName("devices_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("devices", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("UpdateId") + .HasColumnType("integer") + .HasColumnName("update_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Message") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("message"); + + b.Property("Status") + .HasColumnType("ota_update_status") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.HasKey("DeviceId", "UpdateId") + .HasName("device_ota_updates_pkey"); + + b.HasIndex(new[] { "CreatedAt" }, "device_ota_updates_created_at_idx"); + + b.ToTable("device_ota_updates", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("WebhookId") + .HasColumnType("bigint") + .HasColumnName("webhook_id"); + + b.Property("WebhookToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("webhook_token"); + + b.HasKey("Name") + .HasName("discord_webhooks_pkey"); + + b.ToTable("discord_webhooks", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailProviderBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Domain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("domain") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("email_provider_blacklist_pkey"); + + b.HasIndex("Domain") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Domain"), new[] { "ndcoll" }); + + b.ToTable("email_provider_blacklist", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.HasKey("Id") + .HasName("public_shares_pkey"); + + b.HasIndex("OwnerId"); + + b.ToTable("public_shares", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.Property("PublicShareId") + .HasColumnType("uuid") + .HasColumnName("public_share_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("Cooldown") + .HasColumnType("integer") + .HasColumnName("cooldown"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("PublicShareId", "ShockerId") + .HasName("public_share_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("public_share_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("Model") + .HasColumnType("shocker_model_type") + .HasColumnName("model"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("RfId") + .HasColumnType("integer") + .HasColumnName("rf_id"); + + b.HasKey("Id") + .HasName("shockers_pkey"); + + b.HasIndex("DeviceId"); + + b.ToTable("shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ControlledByUserId") + .HasColumnType("uuid") + .HasColumnName("controlled_by_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CustomName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("custom_name"); + + b.Property("Duration") + .HasColumnType("bigint") + .HasColumnName("duration"); + + b.Property("Intensity") + .HasColumnType("smallint") + .HasColumnName("intensity"); + + b.Property("LiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("live_control"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("Type") + .HasColumnType("control_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("shocker_control_logs_pkey"); + + b.HasIndex("ControlledByUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_control_logs", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.HasKey("Id") + .HasName("shocker_share_codes_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_share_codes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("name") + .UseCollation("ndcoll"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("password_hash") + .UseCollation("C"); + + b.PrimitiveCollection>("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.HasKey("Id") + .HasName("users_pkey"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Name"), new[] { "ndcoll" }); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EmailSendAttempts") + .HasColumnType("integer") + .HasColumnName("email_send_attempts"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.HasKey("UserId") + .HasName("user_activation_requests_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("user_activation_requests", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.Property("DeactivatedUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeleteLater") + .HasColumnType("boolean") + .HasColumnName("delete_later"); + + b.Property("UserModerationId") + .HasColumnType("uuid") + .HasColumnName("user_moderation_id"); + + b.HasKey("DeactivatedUserId") + .HasName("user_deactivations_pkey"); + + b.HasIndex("DeactivatedByUserId"); + + b.ToTable("user_deactivations", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("NewEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_new"); + + b.Property("OldEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_old"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_email_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UsedAt"); + + b.HasIndex("UserId"); + + b.ToTable("user_email_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("MatchType") + .HasColumnType("match_type_enum") + .HasColumnName("match_type"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("user_name_blacklist_pkey"); + + b.HasIndex("Value") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Value"), new[] { "ndcoll" }); + + b.ToTable("user_name_blacklist", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OldName") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("old_name"); + + b.HasKey("Id", "UserId") + .HasName("user_name_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("OldName"); + + b.HasIndex("UserId"); + + b.ToTable("user_name_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key") + .UseCollation("C"); + + b.Property("ExternalId") + .HasColumnType("text") + .HasColumnName("external_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("ProviderKey", "ExternalId") + .HasName("user_oauth_connections_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_oauth_connections", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_password_resets_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_password_resets", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.Property("SharedWithUserId") + .HasColumnType("uuid") + .HasColumnName("shared_with_user_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("SharedWithUserId", "ShockerId") + .HasName("user_shares_pkey"); + + b.HasIndex("SharedWithUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_shares", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("RecipientUserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_share_invites_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("RecipientUserId"); + + b.ToTable("user_share_invites", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.Property("InviteId") + .HasColumnType("uuid") + .HasColumnName("invite_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("InviteId", "ShockerId") + .HasName("user_share_invite_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_share_invite_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_tokens_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ReportedByUser") + .WithMany("ReportedApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_token_reports_reported_by_user_id"); + + b.Navigation("ReportedByUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("Devices") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_devices_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("OtaUpdates") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_device_ota_updates_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OwnedPublicShares") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_shares_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.PublicShare", "PublicShare") + .WithMany("ShockerMappings") + .HasForeignKey("PublicShareId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_public_share_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("PublicShareMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_shocker_id"); + + b.Navigation("PublicShare"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("Shockers") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shockers_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ControlledByUser") + .WithMany("ShockerControlLogs") + .HasForeignKey("ControlledByUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_shocker_control_logs_controlled_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerControlLogs") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_control_logs_shocker_id"); + + b.Navigation("ControlledByUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerShareCodes") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_share_codes_shocker_id"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithOne("UserActivationRequest") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserActivationRequest", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_activation_requests_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedByUser") + .WithMany() + .HasForeignKey("DeactivatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedUser") + .WithOne("UserDeactivation") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserDeactivation", "DeactivatedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_user_id"); + + b.Navigation("DeactivatedByUser"); + + b.Navigation("DeactivatedUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("EmailChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_email_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("NameChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_name_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_oauth_connections_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("PasswordResets") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_password_resets_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "SharedWithUser") + .WithMany("IncomingUserShares") + .HasForeignKey("SharedWithUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shared_with_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShares") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shocker_id"); + + b.Navigation("SharedWithUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OutgoingUserShareInvites") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invites_owner_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "RecipientUser") + .WithMany("IncomingUserShareInvites") + .HasForeignKey("RecipientUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_share_invites_recipient_user_id"); + + b.Navigation("Owner"); + + b.Navigation("RecipientUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.UserShareInvite", "Invite") + .WithMany("ShockerMappings") + .HasForeignKey("InviteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_invite_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShareInviteShockerMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_shocker_id"); + + b.Navigation("Invite"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Navigation("OtaUpdates"); + + b.Navigation("Shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Navigation("ShockerMappings"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Navigation("PublicShareMappings"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("ShockerShareCodes"); + + b.Navigation("UserShareInviteShockerMappings"); + + b.Navigation("UserShares"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Navigation("ApiTokens"); + + b.Navigation("Devices"); + + b.Navigation("EmailChanges"); + + b.Navigation("IncomingUserShareInvites"); + + b.Navigation("IncomingUserShares"); + + b.Navigation("NameChanges"); + + b.Navigation("OAuthConnections"); + + b.Navigation("OutgoingUserShareInvites"); + + b.Navigation("OwnedPublicShares"); + + b.Navigation("PasswordResets"); + + b.Navigation("ReportedApiTokens"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("UserActivationRequest"); + + b.Navigation("UserDeactivation"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Navigation("ShockerMappings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Common/Migrations/20250903235304_AddOAuthSupport.cs b/Common/Migrations/20250903235304_AddOAuthSupport.cs new file mode 100644 index 00000000..632165bc --- /dev/null +++ b/Common/Migrations/20250903235304_AddOAuthSupport.cs @@ -0,0 +1,92 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + /// + public partial class AddOAuthSupport : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "password_hash", + table: "users", + type: "character varying(100)", + maxLength: 100, + nullable: true, + collation: "C", + oldClrType: typeof(string), + oldType: "character varying(100)", + oldMaxLength: 100, + oldCollation: "C"); + + migrationBuilder.CreateTable( + name: "DataProtectionKeys", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + FriendlyName = table.Column(type: "text", nullable: true), + Xml = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DataProtectionKeys", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "user_oauth_connections", + columns: table => new + { + provider_key = table.Column(type: "text", nullable: false, collation: "C"), + external_id = table.Column(type: "text", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + display_name = table.Column(type: "text", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("user_oauth_connections_pkey", x => new { x.provider_key, x.external_id }); + table.ForeignKey( + name: "fk_user_oauth_connections_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_user_oauth_connections_user_id", + table: "user_oauth_connections", + column: "user_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DataProtectionKeys"); + + migrationBuilder.DropTable( + name: "user_oauth_connections"); + + migrationBuilder.AlterColumn( + name: "password_hash", + table: "users", + type: "character varying(100)", + maxLength: 100, + nullable: false, + defaultValue: "", + collation: "C", + oldClrType: typeof(string), + oldType: "character varying(100)", + oldMaxLength: 100, + oldNullable: true, + oldCollation: "C"); + } + } +} diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index c91a9061..645c590e 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -21,7 +21,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") - .HasAnnotation("ProductVersion", "9.0.7") + .HasAnnotation("ProductVersion", "9.0.8") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); @@ -34,6 +34,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "caiXianlin", "petTrainer", "petrainer998DR" }); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b => { b.Property("ActivatedAt") @@ -680,7 +699,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .UseCollation("ndcoll"); b.Property("PasswordHash") - .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)") .HasColumnName("password_hash") @@ -891,6 +909,39 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("user_name_changes", (string)null); }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key") + .UseCollation("C"); + + b.Property("ExternalId") + .HasColumnType("text") + .HasColumnName("external_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("ProviderKey", "ExternalId") + .HasName("user_oauth_connections_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_oauth_connections", (string)null); + }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => { b.Property("Id") @@ -1258,6 +1309,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_oauth_connections_user_id"); + + b.Navigation("User"); + }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => { b.HasOne("OpenShock.Common.OpenShockDb.User", "User") @@ -1371,6 +1434,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("NameChanges"); + b.Navigation("OAuthConnections"); + b.Navigation("OutgoingUserShareInvites"); b.Navigation("OwnedPublicShares"); From 3dad263e358632df976533067a6a14a7553869d4 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Thu, 4 Sep 2025 02:16:31 +0200 Subject: [PATCH 37/63] What now? --- API/Controller/Account/Authenticated/OAuthConnectionAdd.cs | 2 +- API/Controller/OAuth/Authorize.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs index e3248109..72182401 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs @@ -9,7 +9,7 @@ namespace OpenShock.API.Controller.Account.Authenticated; public sealed partial class AuthenticatedAccountController { - [HttpGet("connections/{provider}/link")] + [HttpPost("connections/{provider}/link")] public async Task AddOAuthConnection([FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider) { if (!await schemeProvider.IsSupportedOAuthScheme(provider)) diff --git a/API/Controller/OAuth/Authorize.cs b/API/Controller/OAuth/Authorize.cs index f536f391..817316cd 100644 --- a/API/Controller/OAuth/Authorize.cs +++ b/API/Controller/OAuth/Authorize.cs @@ -10,7 +10,7 @@ namespace OpenShock.API.Controller.OAuth; public sealed partial class OAuthController { [EnableRateLimiting("auth")] - [HttpGet("{provider}/authorize")] + [HttpPost("{provider}/authorize")] public async Task OAuthAuthorize([FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider) { if (!await schemeProvider.IsSupportedOAuthScheme(provider)) From 21db899dc7c60d310aa96c010b8977ebeb31b76c Mon Sep 17 00:00:00 2001 From: hhvrc Date: Thu, 4 Sep 2025 02:24:19 +0200 Subject: [PATCH 38/63] Use proper frontend endpoints --- API/Controller/OAuth/HandOff.cs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index 2f58bb1d..16aa0d3d 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -6,7 +6,9 @@ using OpenShock.Common.Authentication; using OpenShock.Common.Errors; using System.Security.Claims; +using Microsoft.Extensions.Options; using OpenShock.API.OAuth; +using OpenShock.Common.Options; namespace OpenShock.API.Controller.OAuth; @@ -17,7 +19,8 @@ public sealed partial class OAuthController public async Task OAuthHandOff( [FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider, - [FromServices] IAccountService accountService) + [FromServices] IAccountService accountService, + [FromServices] IOptions frontendOptions) { if (!await schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.ProviderNotSupported); @@ -54,9 +57,12 @@ public async Task OAuthHandOff( await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); return Redirect("/"); } - - var frontend = Environment.GetEnvironmentVariable("FRONTEND_ORIGIN") ?? "https://app.example.com"; - return Redirect($"{frontend}/{provider}/create"); + + var frontendUrl = new UriBuilder(frontendOptions.Value.BaseUrl) + { + Path = $"oauth/{provider}/create" + }; + return Redirect(frontendUrl.Uri.ToString()); } case OAuthConstants.LinkFlow: @@ -68,8 +74,11 @@ public async Task OAuthHandOff( return Problem(OAuthError.LinkedToAnotherAccount); } - var frontend = Environment.GetEnvironmentVariable("FRONTEND_ORIGIN") ?? "https://app.example.com"; - return Redirect($"{frontend}/{provider}/link"); + var frontendUrl = new UriBuilder(frontendOptions.Value.BaseUrl) + { + Path = $"oauth/{provider}/link" + }; + return Redirect(frontendUrl.Uri.ToString()); } default: From 2ca0b388b566013dd1b213b3d0d3c42b8dbb3583 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Thu, 4 Sep 2025 02:29:25 +0200 Subject: [PATCH 39/63] More cleanup --- .../Account/Authenticated/OAuthConnectionAdd.cs | 5 ++--- API/Controller/OAuth/Authorize.cs | 4 ++-- API/Controller/OAuth/HandOff.cs | 6 +++--- API/OAuth/OAuthConstants.cs | 11 ----------- API/Program.cs | 3 +-- Common/Constants/AuthConstants.cs | 3 +++ Common/OpenShockMiddlewareHelper.cs | 1 + 7 files changed, 12 insertions(+), 21 deletions(-) delete mode 100644 API/OAuth/OAuthConstants.cs diff --git a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs index 72182401..e8787ff7 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs @@ -1,8 +1,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; -using OpenShock.API.Controller.OAuth; using OpenShock.API.Extensions; -using OpenShock.API.OAuth; +using OpenShock.Common.Constants; using OpenShock.Common.Errors; namespace OpenShock.API.Controller.Account.Authenticated; @@ -15,6 +14,6 @@ public async Task AddOAuthConnection([FromRoute] string provider, if (!await schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.ProviderNotSupported); - return Challenge(new AuthenticationProperties { RedirectUri = $"/1/oauth/{provider}/handoff", Items = {{ "flow", OAuthConstants.LinkFlow }} }, authenticationSchemes: [provider]); + return Challenge(new AuthenticationProperties { RedirectUri = $"/1/oauth/{provider}/handoff", Items = {{ "flow", AuthConstants.OAuthLinkFlow }} }, authenticationSchemes: [provider]); } } \ No newline at end of file diff --git a/API/Controller/OAuth/Authorize.cs b/API/Controller/OAuth/Authorize.cs index 817316cd..88ae0ee6 100644 --- a/API/Controller/OAuth/Authorize.cs +++ b/API/Controller/OAuth/Authorize.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Extensions; -using OpenShock.API.OAuth; +using OpenShock.Common.Constants; using OpenShock.Common.Errors; namespace OpenShock.API.Controller.OAuth; @@ -16,6 +16,6 @@ public async Task OAuthAuthorize([FromRoute] string provider, [Fr if (!await schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.ProviderNotSupported); - return Challenge(new AuthenticationProperties { RedirectUri = $"/1/oauth/{provider}/handoff", Items = { { "flow", OAuthConstants.LoginOrCreate } } }, authenticationSchemes: [provider]); + return Challenge(new AuthenticationProperties { RedirectUri = $"/1/oauth/{provider}/handoff", Items = { { "flow", AuthConstants.OAuthLoginOrCreateFlow } } }, authenticationSchemes: [provider]); } } \ No newline at end of file diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index 16aa0d3d..d13777f1 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -7,7 +7,7 @@ using OpenShock.Common.Errors; using System.Security.Claims; using Microsoft.Extensions.Options; -using OpenShock.API.OAuth; +using OpenShock.Common.Constants; using OpenShock.Common.Options; namespace OpenShock.API.Controller.OAuth; @@ -48,7 +48,7 @@ public async Task OAuthHandOff( switch (flow) { - case OAuthConstants.LoginOrCreate: + case AuthConstants.OAuthLoginOrCreateFlow: { if (connection is not null) { @@ -65,7 +65,7 @@ public async Task OAuthHandOff( return Redirect(frontendUrl.Uri.ToString()); } - case OAuthConstants.LinkFlow: + case AuthConstants.OAuthLinkFlow: { if (connection is not null) { diff --git a/API/OAuth/OAuthConstants.cs b/API/OAuth/OAuthConstants.cs deleted file mode 100644 index 37af311a..00000000 --- a/API/OAuth/OAuthConstants.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace OpenShock.API.OAuth; - -public static class OAuthConstants -{ - public const string LoginOrCreate = "login-or-create"; - public const string LinkFlow = "link"; - - public static readonly TimeSpan StateLifetime = TimeSpan.FromMinutes(10); - - public const string StateCachePrefix = "oauth:state:"; -} \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 838a3c0d..5528cd60 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.Connections; using Microsoft.Extensions.Options; -using OpenShock.API.OAuth; using OpenShock.API.Options.OAuth; using OpenShock.API.Realtime; using OpenShock.API.Services; @@ -43,7 +42,7 @@ .AddCookie(OpenShockAuthSchemes.OAuthFlowScheme, o => { o.Cookie.Name = OpenShockAuthSchemes.OAuthFlowCookieName; - o.ExpireTimeSpan = OAuthConstants.StateLifetime; + o.ExpireTimeSpan = TimeSpan.FromMinutes(10); o.SlidingExpiration = false; }) .AddDiscord(OpenShockAuthSchemes.DiscordScheme, o => diff --git a/Common/Constants/AuthConstants.cs b/Common/Constants/AuthConstants.cs index 3f1153d5..5d0e2651 100644 --- a/Common/Constants/AuthConstants.cs +++ b/Common/Constants/AuthConstants.cs @@ -7,6 +7,9 @@ public static class AuthConstants public const string ApiTokenHeaderName = "OpenShockToken"; public const string HubTokenHeaderName = "DeviceToken"; + public const string OAuthLoginOrCreateFlow = "login-or-create"; + public const string OAuthLinkFlow = "link"; + public const int GeneratedTokenLength = 32; public const int ApiTokenLength = 64; } diff --git a/Common/OpenShockMiddlewareHelper.cs b/Common/OpenShockMiddlewareHelper.cs index 23148fef..43c905f7 100644 --- a/Common/OpenShockMiddlewareHelper.cs +++ b/Common/OpenShockMiddlewareHelper.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Options; From 0e2ba324763dcfe8c054b9dfe37aff9bafd9437d Mon Sep 17 00:00:00 2001 From: hhvrc Date: Thu, 4 Sep 2025 09:11:18 +0200 Subject: [PATCH 40/63] Code quality improvements --- .../Authenticated/OAuthConnectionAdd.cs | 28 +++++++++++++++-- .../Authenticated/OAuthConnectionRemove.cs | 5 +++- .../Authenticated/OAuthConnectionsList.cs | 4 ++- API/Controller/OAuth/Authorize.cs | 29 +++++++++++++++--- API/Controller/OAuth/GetData.cs | 22 +++++++++++--- API/Controller/OAuth/HandOff.cs | 30 +++++++++++++++---- API/Controller/OAuth/ListProviders.cs | 10 +++++-- API/Controller/OAuth/_ApiController.cs | 7 +++-- Common/Errors/OAuthError.cs | 2 +- 9 files changed, 113 insertions(+), 24 deletions(-) diff --git a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs index e8787ff7..f5411efb 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs @@ -3,17 +3,41 @@ using OpenShock.API.Extensions; using OpenShock.Common.Constants; using OpenShock.Common.Errors; +using OpenShock.Common.Problems; +using System.Net.Mime; namespace OpenShock.API.Controller.Account.Authenticated; public sealed partial class AuthenticatedAccountController { - [HttpPost("connections/{provider}/link")] + /// + /// Start linking an OAuth provider to the current account. + /// + /// + /// Initiates the OAuth flow (link mode) for a given provider. + /// On success this returns a 302 Found to the provider's authorization page. + /// After consent, the OAuth middleware will call the internal callback and finally + /// redirect to /1/oauth/{provider}/handoff. + /// + /// Provider key (e.g. discord). + /// + /// Redirect to the provider authorization page. + /// Unsupported or misconfigured provider. + [HttpGet("connections/{provider}/link")] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] public async Task AddOAuthConnection([FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider) { if (!await schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.ProviderNotSupported); - return Challenge(new AuthenticationProperties { RedirectUri = $"/1/oauth/{provider}/handoff", Items = {{ "flow", AuthConstants.OAuthLinkFlow }} }, authenticationSchemes: [provider]); + // Kick off provider challenge in "link" mode. + // Redirect URI is our handoff endpoint which decides next UI step. + var props = new AuthenticationProperties { + RedirectUri = $"/1/oauth/{provider}/handoff", + Items = { { "flow", AuthConstants.OAuthLinkFlow } } + }; + + return Challenge(props, provider); } } \ No newline at end of file diff --git a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs index a9b2c5bd..d2faae6f 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs @@ -5,8 +5,11 @@ namespace OpenShock.API.Controller.Account.Authenticated; public sealed partial class AuthenticatedAccountController { /// - /// Delete an OAuth connection by provider + /// Remove an existing OAuth connection for the current user. /// + /// Provider key (e.g. discord). + /// Connection removed. + /// No connection found for this provider. [HttpDelete("connections/{provider}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] diff --git a/API/Controller/Account/Authenticated/OAuthConnectionsList.cs b/API/Controller/Account/Authenticated/OAuthConnectionsList.cs index 67094e29..a10f9b0c 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionsList.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionsList.cs @@ -6,8 +6,10 @@ namespace OpenShock.API.Controller.Account.Authenticated; public sealed partial class AuthenticatedAccountController { /// - /// List OAuth connections + /// List OAuth connections linked to the current user. /// + /// Array of connections with provider key, external id, display name and link time. + /// Returns the list of connections. [HttpGet("connections")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task ListOAuthConnections() diff --git a/API/Controller/OAuth/Authorize.cs b/API/Controller/OAuth/Authorize.cs index 88ae0ee6..069bfb6c 100644 --- a/API/Controller/OAuth/Authorize.cs +++ b/API/Controller/OAuth/Authorize.cs @@ -4,18 +4,39 @@ using OpenShock.API.Extensions; using OpenShock.Common.Constants; using OpenShock.Common.Errors; +using OpenShock.Common.Problems; +using System.Net.Mime; namespace OpenShock.API.Controller.OAuth; public sealed partial class OAuthController { + /// + /// Start OAuth authorization for a given provider (login-or-create flow). + /// + /// + /// Initiates an OAuth challenge in "login-or-create" mode. + /// Returns 302 redirect to the provider authorization page. + /// + /// Provider key (e.g. discord). + /// Redirect to the provider authorization page. + /// Unsupported or misconfigured provider. [EnableRateLimiting("auth")] - [HttpPost("{provider}/authorize")] - public async Task OAuthAuthorize([FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider) + [HttpGet("{provider}/authorize")] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] + public async Task OAuthAuthorize([FromRoute] string provider) { - if (!await schemeProvider.IsSupportedOAuthScheme(provider)) + if (!await _schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.ProviderNotSupported); - return Challenge(new AuthenticationProperties { RedirectUri = $"/1/oauth/{provider}/handoff", Items = { { "flow", AuthConstants.OAuthLoginOrCreateFlow } } }, authenticationSchemes: [provider]); + // Kick off provider challenge in "login-or-create" mode. + var props = new AuthenticationProperties + { + RedirectUri = $"/1/oauth/{provider}/handoff", + Items = { { "flow", AuthConstants.OAuthLoginOrCreateFlow } } + }; + + return Challenge(props, provider); } } \ No newline at end of file diff --git a/API/Controller/OAuth/GetData.cs b/API/Controller/OAuth/GetData.cs index 0ea87a78..598a8fa8 100644 --- a/API/Controller/OAuth/GetData.cs +++ b/API/Controller/OAuth/GetData.cs @@ -1,22 +1,36 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Extensions; using OpenShock.API.Models.Response; using OpenShock.Common.Authentication; using OpenShock.Common.Errors; +using OpenShock.Common.Problems; +using System.Net.Mime; +using System.Security.Claims; namespace OpenShock.API.Controller.OAuth; public sealed partial class OAuthController { + /// + /// Retrieve short-lived OAuth handoff information for the current flow. + /// + /// + /// Returns identity details from the external provider (e.g., email, display name) along with the flow expiry. + /// This endpoint is authenticated via the temporary OAuth flow cookie and is only accessible to the user who initiated the flow. + /// + /// Provider key (e.g. discord). + /// Handoff data returned. + /// Flow not found or provider mismatch. [ResponseCache(NoStore = true)] [EnableRateLimiting("auth")] [HttpGet("{provider}/data")] - public async Task OAuthGetData([FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider) + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] + public async Task OAuthGetData([FromRoute] string provider) { - if (!await schemeProvider.IsSupportedOAuthScheme(provider)) + if (!await _schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.ProviderNotSupported); // Temp external principal (set by OAuth handler with SignInScheme=OAuthFlowScheme, SaveTokens=true) diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index d13777f1..17a924c1 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -1,35 +1,52 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Options; using OpenShock.API.Extensions; using OpenShock.API.Services.Account; using OpenShock.Common.Authentication; -using OpenShock.Common.Errors; -using System.Security.Claims; -using Microsoft.Extensions.Options; using OpenShock.Common.Constants; +using OpenShock.Common.Errors; using OpenShock.Common.Options; +using OpenShock.Common.Problems; +using System.Net.Mime; +using System.Security.Claims; namespace OpenShock.API.Controller.OAuth; public sealed partial class OAuthController { + /// + /// Handoff after provider callback. Decides next step (create, link, or direct sign-in). + /// + /// + /// This endpoint is reached after the OAuth middleware processed the provider callback. + /// It reads the temp OAuth flow principal and its flow (create/link). + /// If an existing connection is found, signs in and redirects home; otherwise redirects the frontend to continue the flow. + /// + /// Provider key (e.g. discord). + /// Account service used to check existing connections. + /// Frontend base URL for redirects. + /// Redirect to the frontend (create/link) or home on direct sign-in. + /// Flow missing or not supported. [EnableRateLimiting("auth")] [HttpGet("{provider}/handoff")] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] public async Task OAuthHandOff( [FromRoute] string provider, - [FromServices] IAuthenticationSchemeProvider schemeProvider, [FromServices] IAccountService accountService, [FromServices] IOptions frontendOptions) { - if (!await schemeProvider.IsSupportedOAuthScheme(provider)) + if (!await _schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.ProviderNotSupported); - // Temp external principal (set by OAuth handler with SignInScheme=OAuthFlowScheme, SaveTokens=true) + // Temp external principal (set by OAuth SignInScheme). var auth = await HttpContext.AuthenticateAsync(OpenShockAuthSchemes.OAuthFlowScheme); if (!auth.Succeeded || auth.Principal is null) return Problem(OAuthError.FlowNotFound); + // Flow is stored in AuthenticationProperties by the authorize step. if (auth.Properties is null || !auth.Properties.Items.TryGetValue("flow", out var flow) || string.IsNullOrWhiteSpace(flow)) { await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); @@ -37,6 +54,7 @@ public async Task OAuthHandOff( } flow = flow.ToLowerInvariant(); + // External subject is required to resolve/link. var externalId = auth.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(externalId)) { diff --git a/API/Controller/OAuth/ListProviders.cs b/API/Controller/OAuth/ListProviders.cs index 9fef8c98..bdffb49a 100644 --- a/API/Controller/OAuth/ListProviders.cs +++ b/API/Controller/OAuth/ListProviders.cs @@ -7,11 +7,15 @@ namespace OpenShock.API.Controller.OAuth; public sealed partial class OAuthController { /// - /// Returns a list of supported SSO provider keys + /// Get the list of supported OAuth providers. /// + /// + /// Returns the set of provider keys that are configured and available for use. + /// + /// Returns provider keys (e.g., discord). [HttpGet("providers")] - public async Task ListOAuthProviders([FromServices] IAuthenticationSchemeProvider schemeProvider) + public async Task ListOAuthProviders() { - return await schemeProvider.GetAllOAuthSchemesAsync(); + return await _schemeProvider.GetAllOAuthSchemesAsync(); } } diff --git a/API/Controller/OAuth/_ApiController.cs b/API/Controller/OAuth/_ApiController.cs index 936d4348..5c83982f 100644 --- a/API/Controller/OAuth/_ApiController.cs +++ b/API/Controller/OAuth/_ApiController.cs @@ -1,4 +1,5 @@ using Asp.Versioning; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Account; @@ -7,7 +8,7 @@ namespace OpenShock.API.Controller.OAuth; /// -/// OAuth management +/// OAuth management endpoints (provider listing, authorize, data handoff). /// [ApiController] [Tags("OAuth")] @@ -16,11 +17,13 @@ namespace OpenShock.API.Controller.OAuth; public sealed partial class OAuthController : OpenShockControllerBase { private readonly IAccountService _accountService; + private readonly IAuthenticationSchemeProvider _schemeProvider; private readonly ILogger _logger; - public OAuthController(IAccountService accountService, ILogger logger) + public OAuthController(IAccountService accountService, IAuthenticationSchemeProvider schemeProvider, ILogger logger) { _accountService = accountService; + _schemeProvider = schemeProvider; _logger = logger; } } \ No newline at end of file diff --git a/Common/Errors/OAuthError.cs b/Common/Errors/OAuthError.cs index 597b1c12..b14b07d7 100644 --- a/Common/Errors/OAuthError.cs +++ b/Common/Errors/OAuthError.cs @@ -6,7 +6,7 @@ namespace OpenShock.Common.Errors; public static class OAuthError { public static OpenShockProblem ProviderNotSupported => new OpenShockProblem( - "OAuth.Provider.NotSupported", "This OAuth provider is not supported", HttpStatusCode.Forbidden); + "OAuth.Provider.NotSupported", "This OAuth provider is not supported", HttpStatusCode.BadRequest); public static OpenShockProblem ProviderMismatch => new OpenShockProblem( "OAuth.Provider.Mismatch", "????????????????", HttpStatusCode.BadRequest); From 0fd6e3d7336bbfda211df27e84966610a0455d89 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Thu, 4 Sep 2025 09:52:05 +0200 Subject: [PATCH 41/63] More docs and clean up stuff --- .../Authenticated/OAuthConnectionAdd.cs | 2 +- .../Authenticated/OAuthConnectionRemove.cs | 6 +- .../Authenticated/OAuthConnectionsList.cs | 5 +- API/Controller/Account/Signup.cs | 2 +- API/Controller/Account/SignupV2.cs | 2 +- API/Controller/OAuth/Authorize.cs | 2 +- API/Controller/OAuth/Finalize.cs | 216 ++++++++++++++++++ API/Controller/OAuth/GetData.cs | 2 +- API/Controller/OAuth/HandOff.cs | 15 +- API/Models/Requests/OAuthFinalizeRequest.cs | 19 ++ API/Models/Response/OAuthFinalizeResponse.cs | 16 ++ API/Program.cs | 2 + API/Services/Account/AccountService.cs | 112 +++++---- API/Services/Account/IAccountService.cs | 20 +- .../IOAuthConnectionService.cs | 15 ++ .../OAuthConnection/OAuthConnectionService.cs | 71 ++++++ Common/Errors/OAuthError.cs | 77 ++++--- Common/Errors/SignupError.cs | 5 +- 18 files changed, 492 insertions(+), 97 deletions(-) create mode 100644 API/Controller/OAuth/Finalize.cs create mode 100644 API/Models/Requests/OAuthFinalizeRequest.cs create mode 100644 API/Models/Response/OAuthFinalizeResponse.cs create mode 100644 API/Services/OAuthConnection/IOAuthConnectionService.cs create mode 100644 API/Services/OAuthConnection/OAuthConnectionService.cs diff --git a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs index f5411efb..23d7afec 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs @@ -29,7 +29,7 @@ public sealed partial class AuthenticatedAccountController public async Task AddOAuthConnection([FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider) { if (!await schemeProvider.IsSupportedOAuthScheme(provider)) - return Problem(OAuthError.ProviderNotSupported); + return Problem(OAuthError.UnsupportedProvider); // Kick off provider challenge in "link" mode. // Redirect URI is our handoff endpoint which decides next UI step. diff --git a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs index d2faae6f..18100bee 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using OpenShock.API.Services.OAuthConnection; namespace OpenShock.API.Controller.Account.Authenticated; @@ -8,14 +9,15 @@ public sealed partial class AuthenticatedAccountController /// Remove an existing OAuth connection for the current user. /// /// Provider key (e.g. discord). + /// /// Connection removed. /// No connection found for this provider. [HttpDelete("connections/{provider}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task RemoveOAuthConnection([FromRoute] string provider) + public async Task RemoveOAuthConnection([FromRoute] string provider, [FromServices] IOAuthConnectionService connectionService) { - var deleted = await _accountService.TryRemoveOAuthConnectionAsync(CurrentUser.Id, provider); + var deleted = await connectionService.TryRemoveConnectionAsync(CurrentUser.Id, provider); if (!deleted) return NotFound(); diff --git a/API/Controller/Account/Authenticated/OAuthConnectionsList.cs b/API/Controller/Account/Authenticated/OAuthConnectionsList.cs index a10f9b0c..3cdc1b65 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionsList.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionsList.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Response; +using OpenShock.API.Services.OAuthConnection; namespace OpenShock.API.Controller.Account.Authenticated; @@ -12,9 +13,9 @@ public sealed partial class AuthenticatedAccountController /// Returns the list of connections. [HttpGet("connections")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task ListOAuthConnections() + public async Task ListOAuthConnections([FromServices] IOAuthConnectionService connectionService) { - var connections = await _accountService.GetOAuthConnectionsAsync(CurrentUser.Id); + var connections = await connectionService.GetConnectionsAsync(CurrentUser.Id); return connections .Select(c => new OAuthConnectionResponse diff --git a/API/Controller/Account/Signup.cs b/API/Controller/Account/Signup.cs index 5e04868e..94e2f12e 100644 --- a/API/Controller/Account/Signup.cs +++ b/API/Controller/Account/Signup.cs @@ -27,7 +27,7 @@ public async Task SignUp([FromBody] SignUp body) var creationAction = await _accountService.CreateAccountWithoutActivationFlowLegacyAsync(body.Email, body.Username, body.Password); return creationAction.Match( ok => LegacyEmptyOk("Successfully signed up"), - alreadyExists => Problem(SignupError.EmailAlreadyExists) + alreadyExists => Problem(SignupError.UsernameOrEmailExists) ); } } \ No newline at end of file diff --git a/API/Controller/Account/SignupV2.cs b/API/Controller/Account/SignupV2.cs index 6e5ae089..f5dd0b26 100644 --- a/API/Controller/Account/SignupV2.cs +++ b/API/Controller/Account/SignupV2.cs @@ -45,7 +45,7 @@ public async Task SignUpV2( var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(body.Email, body.Username, body.Password); return creationAction.Match( _ => Ok(), - _ => Problem(SignupError.EmailAlreadyExists) + _ => Problem(SignupError.UsernameOrEmailExists) ); } } \ No newline at end of file diff --git a/API/Controller/OAuth/Authorize.cs b/API/Controller/OAuth/Authorize.cs index 069bfb6c..f30d2d57 100644 --- a/API/Controller/OAuth/Authorize.cs +++ b/API/Controller/OAuth/Authorize.cs @@ -28,7 +28,7 @@ public sealed partial class OAuthController public async Task OAuthAuthorize([FromRoute] string provider) { if (!await _schemeProvider.IsSupportedOAuthScheme(provider)) - return Problem(OAuthError.ProviderNotSupported); + return Problem(OAuthError.UnsupportedProvider); // Kick off provider challenge in "login-or-create" mode. var props = new AuthenticationProperties diff --git a/API/Controller/OAuth/Finalize.cs b/API/Controller/OAuth/Finalize.cs new file mode 100644 index 00000000..27b34dc4 --- /dev/null +++ b/API/Controller/OAuth/Finalize.cs @@ -0,0 +1,216 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.API.Extensions; +using OpenShock.API.Models.Requests; +using OpenShock.API.Models.Response; +using OpenShock.API.Services.Account; +using OpenShock.API.Services.OAuthConnection; +using OpenShock.Common.Authentication; +using OpenShock.Common.Authentication.Services; +using OpenShock.Common.Constants; +using OpenShock.Common.Errors; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Problems; +using OpenShock.Common.Utils; +using System.Net.Mime; +using System.Security.Claims; + +namespace OpenShock.API.Controller.OAuth; + +public sealed partial class OAuthController +{ + /// + /// Finalize an OAuth flow by either creating a new local account or linking to the current account. + /// + /// + /// Authenticates via the temporary OAuth flow cookie (set during the provider callback). + /// - create: creates a local account, then links the external identity.
+ /// - link: requires a logged-in local user; links the external identity to that user.
+ /// No access/refresh tokens are returned. + ///
+ /// Provider key (e.g. discord). + /// Finalize request. + /// + /// + /// Finalization succeeded. + /// Flow not found, bad action, username invalid, or provider mismatch. + /// Link requested but user not authenticated. + /// External already linked to another account, or duplicate link attempt. + [EnableRateLimiting("auth")] + [HttpPost("{provider}/finalize")] + [ProducesResponseType(typeof(OAuthFinalizeResponse), StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status401Unauthorized, MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status409Conflict, MediaTypeNames.Application.Json)] + public async Task OAuthFinalize( + [FromRoute] string provider, + [FromBody] OAuthFinalizeRequest body, + [FromServices] IAccountService accountService, + [FromServices] IOAuthConnectionService connectionService) + { + if (!await _schemeProvider.IsSupportedOAuthScheme(provider)) + return Problem(OAuthError.UnsupportedProvider); + + var action = body.Action?.Trim().ToLowerInvariant(); + if (action is not (AuthConstants.OAuthLoginOrCreateFlow or AuthConstants.OAuthLinkFlow)) + return Problem(OAuthError.UnsupportedFlow); + + // Authenticate via the short-lived OAuth flow cookie (temp scheme) + var auth = await HttpContext.AuthenticateAsync(OpenShockAuthSchemes.OAuthFlowScheme); + if (!auth.Succeeded || auth.Principal is null) + return Problem(OAuthError.FlowNotFound); + + // Flow must belong to the same provider we’re finalizing + var providerClaim = auth.Principal.FindFirst("provider")?.Value; + if (!string.Equals(providerClaim, provider, StringComparison.OrdinalIgnoreCase)) + { + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.ProviderMismatch); + } + + // External identity basics from claims (added by your handler) + var externalId = auth.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(externalId)) + { + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.FlowMissingData); + } + + var email = auth.Principal.FindFirst(ClaimTypes.Email)?.Value; + var displayName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value; + + // If the external is already linked, don’t allow relinking in either flow. + var existing = await connectionService.GetByProviderExternalIdAsync(provider, externalId); + + if (action == AuthConstants.OAuthLinkFlow) + { + // Linking requires an authenticated session + var userRef = HttpContext.RequestServices.GetRequiredService(); + if (userRef.AuthReference is null || !userRef.AuthReference.Value.IsT0) + { + // Not a logged-in session (could be API token or anonymous) + return Problem(OAuthError.NotAuthenticatedForLink); + } + + var currentUser = HttpContext.RequestServices + .GetRequiredService>() + .CurrentClient; + + if (existing is not null) + { + // Already linked to someone, block. + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.ExternalAlreadyLinked); + } + + var ok = await connectionService.TryAddConnectionAsync( + userId: currentUser.Id, + provider: provider, + providerAccountId: externalId, + providerAccountName: displayName ?? email); + + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + + if (!ok) return Problem(OAuthError.ExternalAlreadyLinked); + + return Ok(new OAuthFinalizeResponse + { + Provider = provider, + ExternalId = externalId + }); + } + + if (action is not AuthConstants.OAuthLoginOrCreateFlow) + { + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.UnsupportedFlow); + } + + if (existing is not null) + { + // External already mapped; treat as conflict (or you could return 200 if you consider this a no-op login). + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + return Problem(OAuthError.ConnectionAlreadyExists); + } + + // We must create a local account. Your AccountService requires a password, so: + var desiredUsername = body.Username?.Trim(); + if (string.IsNullOrEmpty(desiredUsername)) + { + // Generate a reasonable username from displayName/email/externalId + desiredUsername = GenerateUsername(displayName, email, externalId, provider); + } + + // Ensure username is available; if not, try a few suffixes + desiredUsername = await EnsureAvailableUsernameAsync(desiredUsername, accountService); + + var password = string.IsNullOrEmpty(body.Password) + ? CryptoUtils.RandomString(32) // strong random (since OAuth-only users won't use it) + : body.Password; + + var created = await accountService.CreateOAuthOnlyAccountAsync( + email: email!, + username: body.Username!, + provider: provider, + providerAccountId: externalId, + providerAccountName: displayName ?? email + ); + + + if (created.IsT1) + { + return Problem(SignupError.UsernameOrEmailExists); + } + + await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + + var newUser = created.AsT0.Value; + + return Ok(new OAuthFinalizeResponse + { + Provider = provider, + ExternalId = externalId, + Username = newUser.Name + }); + + // ------- local helpers -------- + + static string GenerateUsername(string? name, string? mail, string externalId, string providerKey) + { + if (!string.IsNullOrWhiteSpace(name)) + return Slugify(name); + + if (!string.IsNullOrWhiteSpace(mail)) + { + var at = mail.IndexOf('@'); + if (at > 0) return Slugify(mail[..at]); + } + + return $"{providerKey}_{externalId}".ToLowerInvariant(); + } + + static string Slugify(string s) + { + var slug = new string(s.Trim() + .ToLowerInvariant() + .Select(ch => char.IsLetterOrDigit(ch) ? ch : '_') + .ToArray()); + slug = System.Text.RegularExpressions.Regex.Replace(slug, "_{2,}", "_").Trim('_'); + return string.IsNullOrEmpty(slug) ? "user" : slug; + } + + static async Task EnsureAvailableUsernameAsync(string baseName, IAccountService account) + { + var candidate = baseName; + for (var i = 0; i < 10; i++) + { + var check = await account.CheckUsernameAvailabilityAsync(candidate); + if (check.IsT0) return candidate; // Success + candidate = $"{baseName}_{CryptoUtils.RandomString(4).ToLowerInvariant()}"; + } + // last resort: include a timestamp suffix + return $"{baseName}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"; + } + } +} diff --git a/API/Controller/OAuth/GetData.cs b/API/Controller/OAuth/GetData.cs index 598a8fa8..475cd42a 100644 --- a/API/Controller/OAuth/GetData.cs +++ b/API/Controller/OAuth/GetData.cs @@ -31,7 +31,7 @@ public sealed partial class OAuthController public async Task OAuthGetData([FromRoute] string provider) { if (!await _schemeProvider.IsSupportedOAuthScheme(provider)) - return Problem(OAuthError.ProviderNotSupported); + return Problem(OAuthError.UnsupportedProvider); // Temp external principal (set by OAuth handler with SignInScheme=OAuthFlowScheme, SaveTokens=true) var auth = await HttpContext.AuthenticateAsync(OpenShockAuthSchemes.OAuthFlowScheme); diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index 17a924c1..db4867a7 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; using OpenShock.API.Extensions; using OpenShock.API.Services.Account; +using OpenShock.API.Services.OAuthConnection; using OpenShock.Common.Authentication; using OpenShock.Common.Constants; using OpenShock.Common.Errors; @@ -25,8 +26,9 @@ public sealed partial class OAuthController /// If an existing connection is found, signs in and redirects home; otherwise redirects the frontend to continue the flow. /// /// Provider key (e.g. discord). - /// Account service used to check existing connections. - /// Frontend base URL for redirects. + /// + /// + /// /// Redirect to the frontend (create/link) or home on direct sign-in. /// Flow missing or not supported. [EnableRateLimiting("auth")] @@ -36,10 +38,11 @@ public sealed partial class OAuthController public async Task OAuthHandOff( [FromRoute] string provider, [FromServices] IAccountService accountService, + [FromServices] IOAuthConnectionService connectionService, [FromServices] IOptions frontendOptions) { if (!await _schemeProvider.IsSupportedOAuthScheme(provider)) - return Problem(OAuthError.ProviderNotSupported); + return Problem(OAuthError.UnsupportedProvider); // Temp external principal (set by OAuth SignInScheme). var auth = await HttpContext.AuthenticateAsync(OpenShockAuthSchemes.OAuthFlowScheme); @@ -62,7 +65,7 @@ public async Task OAuthHandOff( return Problem(OAuthError.FlowMissingData); } - var connection = await accountService.GetOAuthConnectionAsync(provider, externalId); + var connection = await connectionService.GetByProviderExternalIdAsync(provider, externalId); switch (flow) { @@ -89,7 +92,7 @@ public async Task OAuthHandOff( { // TODO: Check if the connection is connected to our account with same externalId (AlreadyLinked), different externalId (AlreadyExists), or to another account (LinkedToAnotherAccount) await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); - return Problem(OAuthError.LinkedToAnotherAccount); + return Problem(OAuthError.ExternalAlreadyLinked); } var frontendUrl = new UriBuilder(frontendOptions.Value.BaseUrl) @@ -101,7 +104,7 @@ public async Task OAuthHandOff( default: await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); - return Problem(OAuthError.FlowNotSupported); + return Problem(OAuthError.UnsupportedFlow); } } } \ No newline at end of file diff --git a/API/Models/Requests/OAuthFinalizeRequest.cs b/API/Models/Requests/OAuthFinalizeRequest.cs new file mode 100644 index 00000000..a3da8b2a --- /dev/null +++ b/API/Models/Requests/OAuthFinalizeRequest.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.API.Models.Requests; + +public sealed class OAuthFinalizeRequest +{ + /// Action to perform: "create" or "link". + [Required] + public required string Action { get; init; } + + /// Desired username (create only). If omitted, a name will be generated from the external profile. + public string? Username { get; init; } + + /// + /// New account password (create only). If omitted, a strong random password will be generated. + /// Your current AccountService requires a password. + /// + public string? Password { get; init; } +} \ No newline at end of file diff --git a/API/Models/Response/OAuthFinalizeResponse.cs b/API/Models/Response/OAuthFinalizeResponse.cs new file mode 100644 index 00000000..25f57840 --- /dev/null +++ b/API/Models/Response/OAuthFinalizeResponse.cs @@ -0,0 +1,16 @@ +namespace OpenShock.API.Models.Response; + +public sealed class OAuthFinalizeResponse +{ + /// "ok" on success; otherwise not returned (problem details emitted). + public string Status { get; init; } = "ok"; + + /// The provider key that was processed. + public required string Provider { get; init; } + + /// The external account id that was linked. + public required string ExternalId { get; init; } + + /// When action=create, the username of the newly created account. + public string? Username { get; init; } +} \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 5528cd60..397bcd6c 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -6,6 +6,7 @@ using OpenShock.API.Services; using OpenShock.API.Services.Account; using OpenShock.API.Services.Email; +using OpenShock.API.Services.OAuthConnection; using OpenShock.API.Services.UserService; using OpenShock.Common; using OpenShock.Common.Authentication; @@ -79,6 +80,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 190f8db6..2ec98d47 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -123,6 +123,70 @@ await _emailService.VerifyEmail(new Contact(email, username), return new Success(user); } + public async Task, AccountWithEmailOrUsernameExists>> CreateOAuthOnlyAccountAsync( + string email, + string username, + string provider, + string providerAccountId, + string? providerAccountName) + { + email = email.ToLowerInvariant(); + provider = provider.ToLowerInvariant(); + + // Reuse your existing guards + if (await IsUserNameBlacklisted(username) || await IsEmailProviderBlacklisted(email)) + return new AccountWithEmailOrUsernameExists(); + + // Fast uniqueness check (optimistic; race handled by unique constraints below) + var exists = await _db.Users.AnyAsync(u => u.Email == email || u.Name == username); + if (exists) return new AccountWithEmailOrUsernameExists(); + + await using var tx = await _db.Database.BeginTransactionAsync(); + + try + { + var user = new User + { + Id = Guid.CreateVersion7(), + Name = username, + Email = email, + PasswordHash = null, // OAuth-only account + ActivatedAt = DateTime.UtcNow // no activation flow + }; + + _db.Users.Add(user); + await _db.SaveChangesAsync(); + + // Link external identity + _db.UserOAuthConnections.Add(new UserOAuthConnection + { + UserId = user.Id, + ProviderKey = provider, + ExternalId = providerAccountId, + DisplayName = providerAccountName + }); + + await _db.SaveChangesAsync(); + + await tx.CommitAsync(); + + // Ensure ActivatedAt <= CreatedAt (optional monotonic tidy-up) + if (user.CreatedAt > user.ActivatedAt) + { + user.ActivatedAt = user.CreatedAt; + await _db.SaveChangesAsync(); + } + + return new Success(user); + } + catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" }) + { + // Unique constraint hit: either username/email already exists, or (provider, externalId) is already linked. + await tx.RollbackAsync(); + return new AccountWithEmailOrUsernameExists(); + } + } + public async Task TryActivateAccountAsync(string secret, CancellationToken cancellationToken = default) { var hash = HashingUtils.HashToken(secret); @@ -446,54 +510,6 @@ public async Task TryVerifyEmailAsync(string token, CancellationToken canc return nChanges > 0; } - public async Task GetOAuthConnectionsAsync(Guid userId) - { - return await _db.UserOAuthConnections - .AsNoTracking() - .Where(c => c.UserId == userId) - .ToArrayAsync(); - } - - public async Task GetOAuthConnectionAsync(string provider, string providerAccountId) - { - return await _db.UserOAuthConnections.FirstOrDefaultAsync(c => c.ProviderKey == provider && c.ExternalId == providerAccountId); - } - - public async Task HasOAuthConnectionAsync(Guid userId, string provider) - { - return await _db.UserOAuthConnections.AnyAsync(c => c.UserId == userId && c.ProviderKey == provider); - } - - public async Task TryAddOAuthConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName) - { - try - { - _db.UserOAuthConnections.Add(new UserOAuthConnection - { - UserId = userId, - ProviderKey = provider, - ExternalId = providerAccountId, - DisplayName = providerAccountName - }); - await _db.SaveChangesAsync(); - } - catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" }) // Unique constaint violation - { - return false; - } - - return true; - } - - public async Task TryRemoveOAuthConnectionAsync(Guid userId, string provider) - { - var nDeleted = await _db.UserOAuthConnections - .Where(c => c.UserId == userId && c.ProviderKey == provider) - .ExecuteDeleteAsync(); - - return nDeleted > 0; - } - private async Task CheckPassword(string password, User user) { if (string.IsNullOrEmpty(user.PasswordHash)) diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 65652313..3c9b66f1 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -28,6 +28,19 @@ public interface IAccountService /// public Task, AccountWithEmailOrUsernameExists>> CreateAccountWithActivationFlowAsync(string email, string username, string password); + /// + /// Creates an OAuth-only (passwordless) account and links the external identity in a single transaction. + /// The new user is activated immediately (no activation flow). Returns a conflict-style result if the + /// username/email is taken or the external identity is already linked. + /// + /// Email to set on the user. + /// Desired unique username. + /// e.g. "discord" + /// external subject/id from provider + /// display name from provider + /// Success with the created user, or AccountWithEmailOrUsernameExists when taken/blocked. + Task, AccountWithEmailOrUsernameExists>> CreateOAuthOnlyAccountAsync(string email, string username, string provider, string providerAccountId, string? providerAccountName); + /// /// /// @@ -110,13 +123,6 @@ public interface IAccountService /// /// Task TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default); - - Task GetOAuthConnectionsAsync(Guid userId); - Task GetOAuthConnectionAsync(string provider, string providerAccountId); - Task HasOAuthConnectionAsync(Guid userId, string provider); - Task TryAddOAuthConnectionAsync(Guid userId, string provider, string providerAccountId, - string? providerAccountName); - Task TryRemoveOAuthConnectionAsync(Guid userId, string provider); } public sealed record CreateUserLoginSessionSuccess(User User, string Token); diff --git a/API/Services/OAuthConnection/IOAuthConnectionService.cs b/API/Services/OAuthConnection/IOAuthConnectionService.cs new file mode 100644 index 00000000..2706a479 --- /dev/null +++ b/API/Services/OAuthConnection/IOAuthConnectionService.cs @@ -0,0 +1,15 @@ +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.Services.OAuthConnection; + +/// +/// Manages external OAuth connections for users. +/// +public interface IOAuthConnectionService +{ + Task GetConnectionsAsync(Guid userId); + Task GetByProviderExternalIdAsync(string provider, string providerAccountId); + Task HasConnectionAsync(Guid userId, string provider); + Task TryAddConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName); + Task TryRemoveConnectionAsync(Guid userId, string provider); +} \ No newline at end of file diff --git a/API/Services/OAuthConnection/OAuthConnectionService.cs b/API/Services/OAuthConnection/OAuthConnectionService.cs new file mode 100644 index 00000000..572e88f7 --- /dev/null +++ b/API/Services/OAuthConnection/OAuthConnectionService.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using OpenShock.API.Services.OAuthConnection; +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.Services.Account; + +public sealed class OAuthConnectionService : IOAuthConnectionService +{ + private readonly OpenShockContext _db; + private readonly ILogger _logger; + + public OAuthConnectionService(OpenShockContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task GetConnectionsAsync(Guid userId) + { + return await _db.UserOAuthConnections + .AsNoTracking() + .Where(c => c.UserId == userId) + .ToArrayAsync(); + } + + public async Task GetByProviderExternalIdAsync(string provider, string providerAccountId) + { + var p = provider.ToLowerInvariant(); + return await _db.UserOAuthConnections + .FirstOrDefaultAsync(c => c.ProviderKey == p && c.ExternalId == providerAccountId); + } + + public async Task HasConnectionAsync(Guid userId, string provider) + { + var p = provider.ToLowerInvariant(); + return await _db.UserOAuthConnections.AnyAsync(c => c.UserId == userId && c.ProviderKey == p); + } + + public async Task TryAddConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName) + { + try + { + _db.UserOAuthConnections.Add(new UserOAuthConnection + { + UserId = userId, + ProviderKey = provider.ToLowerInvariant(), + ExternalId = providerAccountId, + DisplayName = providerAccountName + }); + await _db.SaveChangesAsync(); + return true; + } + catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" }) + { + // Unique constraint violation (duplicate link) + _logger.LogDebug(ex, "Duplicate OAuth link for {Provider}:{ExternalId}", provider, providerAccountId); + return false; + } + } + + public async Task TryRemoveConnectionAsync(Guid userId, string provider) + { + var p = provider.ToLowerInvariant(); + var nDeleted = await _db.UserOAuthConnections + .Where(c => c.UserId == userId && c.ProviderKey == p) + .ExecuteDeleteAsync(); + + return nDeleted > 0; + } +} diff --git a/Common/Errors/OAuthError.cs b/Common/Errors/OAuthError.cs index b14b07d7..a48f9b3f 100644 --- a/Common/Errors/OAuthError.cs +++ b/Common/Errors/OAuthError.cs @@ -1,29 +1,54 @@ -using System.Net; -using OpenShock.Common.Problems; - -namespace OpenShock.Common.Errors; +using OpenShock.Common.Problems; +using System.Net; public static class OAuthError { - public static OpenShockProblem ProviderNotSupported => new OpenShockProblem( - "OAuth.Provider.NotSupported", "This OAuth provider is not supported", HttpStatusCode.BadRequest); - public static OpenShockProblem ProviderMismatch => new OpenShockProblem( - "OAuth.Provider.Mismatch", "????????????????", HttpStatusCode.BadRequest); - - public static OpenShockProblem FlowNotSupported => new OpenShockProblem( - "OAuth.Flow.NotSupported", "This OAuth flow is not supported", HttpStatusCode.Forbidden); - public static OpenShockProblem FlowNotFound => new OpenShockProblem( - "OAuth.Flow.NotFound", "This OAuth flow is expired or invalid", HttpStatusCode.BadRequest); - - public static OpenShockProblem FlowMissingData => new OpenShockProblem( - "OAuth.Flow.MissingData", "The OAuth provider supplied less data that expected", HttpStatusCode.InternalServerError); - - public static OpenShockProblem AlreadyExists => new OpenShockProblem( - "OAuth.Connection.AlreadyExists", "There is already an OAuth connection of this type in your account", HttpStatusCode.Conflict); - - public static OpenShockProblem LinkedToAnotherAccount => new OpenShockProblem( - "OAuth.Connection.LinkedToStranger", "This external account is already linked to another user", HttpStatusCode.Conflict); - - public static OpenShockProblem InternalError => new OpenShockProblem( - "OAuth.InternalError", "Encountered an unexpected error while processing your OAuth flow", HttpStatusCode.InternalServerError); -} \ No newline at end of file + // Provider-related + public static OpenShockProblem UnsupportedProvider => new( + "OAuth.Provider.Unsupported", + "The requested OAuth provider is not supported", + HttpStatusCode.BadRequest); + + public static OpenShockProblem ProviderMismatch => new( + "OAuth.Provider.Mismatch", + "The current OAuth flow does not match the requested provider", + HttpStatusCode.BadRequest); + + // Flow-related + public static OpenShockProblem UnsupportedFlow => new( + "OAuth.Flow.Unsupported", + "This OAuth flow type is not recognized or allowed", + HttpStatusCode.Forbidden); + + public static OpenShockProblem FlowNotFound => new( + "OAuth.Flow.NotFound", + "The OAuth flow was not found, has expired, or is invalid", + HttpStatusCode.BadRequest); + + public static OpenShockProblem FlowMissingData => new( + "OAuth.Flow.MissingData", + "The OAuth provider did not supply the expected identity data", + HttpStatusCode.BadGateway); // 502 makes sense if external didn't return what we expect + + // Connection-related + public static OpenShockProblem ConnectionAlreadyExists => new( + "OAuth.Connection.AlreadyExists", + "Your account already has an OAuth connection for this provider", + HttpStatusCode.Conflict); + + public static OpenShockProblem ExternalAlreadyLinked => new( + "OAuth.Connection.AlreadyLinked", + "This external account is already linked to another user", + HttpStatusCode.Conflict); + + public static OpenShockProblem NotAuthenticatedForLink => new( + "OAuth.Link.NotAuthenticated", + "You must be signed in to link an external account", + HttpStatusCode.Unauthorized); + + // Misc / generic + public static OpenShockProblem InternalError => new( + "OAuth.InternalError", + "An unexpected error occurred while processing the OAuth flow", + HttpStatusCode.InternalServerError); +} diff --git a/Common/Errors/SignupError.cs b/Common/Errors/SignupError.cs index 8e8841b1..66535001 100644 --- a/Common/Errors/SignupError.cs +++ b/Common/Errors/SignupError.cs @@ -5,5 +5,8 @@ namespace OpenShock.Common.Errors; public static class SignupError { - public static OpenShockProblem EmailAlreadyExists => new("Signup.EmailOrUsernameAlreadyExists", "Email or username already exists", HttpStatusCode.Conflict); + public static OpenShockProblem UsernameOrEmailExists => new( + "Signup.UsernameOrEmailExists", + "The chosen username or email is already in use", + HttpStatusCode.Conflict); } \ No newline at end of file From 7e9a5fad349b4234d5cbed6de5060fb30144eb11 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Thu, 4 Sep 2025 10:42:08 +0200 Subject: [PATCH 42/63] Revert other changes done by Codex --- API/appsettings.json | 8 -------- README.md | 8 -------- 2 files changed, 16 deletions(-) diff --git a/API/appsettings.json b/API/appsettings.json index b17e3152..e9263458 100644 --- a/API/appsettings.json +++ b/API/appsettings.json @@ -35,13 +35,5 @@ "FromLogContext", "WithOpenShockEnricher" ] - }, - "OpenShock": { - "OAuth2": { - "Discord": { - "ClientId": "", - "ClientSecret": "" - } - } } } diff --git a/README.md b/README.md index 9c76956b..ed4c0969 100644 --- a/README.md +++ b/README.md @@ -31,14 +31,6 @@ Preferred way is a .env file. Refer to the [Npgsql Connection String](https://www.npgsql.org/doc/connection-string-parameters.html) documentation page for details about `OPENSHOCK__DB_CONN`. Refer to [StackExchange.Redis Configuration](https://stackexchange.github.io/StackExchange.Redis/Configuration.html) documentation page for details about `OPENSHOCK__REDIS__CONN`. -### Discord OAuth - -| Variable | Required | Default value | Allowed / Example value | -|--------------------------------------------|----------|---------------|------------------------------------------------------| -| `OPENSHOCK__OAUTH2__DISCORD__CLIENTID` | x | | | -| `OPENSHOCK__OAUTH2__DISCORD__CLIENTSECRET` | x | | | -| `OPENSHOCK__OAUTH2__DISCORD__REDIRECTURI` | x | | `https://my-openshock-instance.net/discord/callback` | - ## Turnstile When Turnstile enable is set to `true`, the following environment variable is required: From 4b768ab424d604b2fe0ee955cc77259780c32613 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Thu, 4 Sep 2025 23:38:43 +0200 Subject: [PATCH 43/63] Move stuff to API csproj --- .../Authenticated/OAuthConnectionAdd.cs | 11 ++------ API/Controller/OAuth/Authorize.cs | 17 ++++++------ API/Controller/OAuth/Finalize.cs | 27 ++++++++++--------- API/Controller/OAuth/GetData.cs | 16 +++++------ API/Controller/OAuth/HandOff.cs | 25 ++++++++++------- API/Controller/OAuth/_ApiController.cs | 1 - .../AuthenticationSchemeProviderExtensions.cs | 5 ++-- API/Models/Requests/OAuthFinalizeRequest.cs | 5 ++-- API/OAuth/OAuthConstants.cs | 15 +++++++++++ {Common/Errors => API/OAuth}/OAuthError.cs | 5 ++++ API/OAuth/OAuthUtil.cs | 18 +++++++++++++ API/Program.cs | 14 +++++----- Common/Authentication/OpenShockAuthSchemes.cs | 5 ---- 13 files changed, 98 insertions(+), 66 deletions(-) create mode 100644 API/OAuth/OAuthConstants.cs rename {Common/Errors => API/OAuth}/OAuthError.cs (91%) create mode 100644 API/OAuth/OAuthUtil.cs diff --git a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs index 23d7afec..694ac9b7 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs @@ -2,9 +2,9 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Extensions; using OpenShock.Common.Constants; -using OpenShock.Common.Errors; using OpenShock.Common.Problems; using System.Net.Mime; +using OpenShock.API.Utils; namespace OpenShock.API.Controller.Account.Authenticated; @@ -31,13 +31,6 @@ public async Task AddOAuthConnection([FromRoute] string provider, if (!await schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.UnsupportedProvider); - // Kick off provider challenge in "link" mode. - // Redirect URI is our handoff endpoint which decides next UI step. - var props = new AuthenticationProperties { - RedirectUri = $"/1/oauth/{provider}/handoff", - Items = { { "flow", AuthConstants.OAuthLinkFlow } } - }; - - return Challenge(props, provider); + return OAuthUtil.StartOAuth(provider, AuthConstants.OAuthLinkFlow); } } \ No newline at end of file diff --git a/API/Controller/OAuth/Authorize.cs b/API/Controller/OAuth/Authorize.cs index f30d2d57..aa8921c9 100644 --- a/API/Controller/OAuth/Authorize.cs +++ b/API/Controller/OAuth/Authorize.cs @@ -1,11 +1,11 @@ -using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Extensions; using OpenShock.Common.Constants; -using OpenShock.Common.Errors; using OpenShock.Common.Problems; using System.Net.Mime; +using OpenShock.API.Utils; +using OpenShock.Common.Authentication; namespace OpenShock.API.Controller.OAuth; @@ -30,13 +30,12 @@ public async Task OAuthAuthorize([FromRoute] string provider) if (!await _schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.UnsupportedProvider); - // Kick off provider challenge in "login-or-create" mode. - var props = new AuthenticationProperties + if (User.Identities.Any(ident => string.Equals(ident.AuthenticationType, OpenShockAuthSchemes.UserSessionCookie, + StringComparison.InvariantCultureIgnoreCase))) { - RedirectUri = $"/1/oauth/{provider}/handoff", - Items = { { "flow", AuthConstants.OAuthLoginOrCreateFlow } } - }; - - return Challenge(props, provider); + return Problem(OAuthError.AnonymousOnlyEndpoint); + } + + return OAuthUtil.StartOAuth(provider, AuthConstants.OAuthLoginOrCreateFlow); } } \ No newline at end of file diff --git a/API/Controller/OAuth/Finalize.cs b/API/Controller/OAuth/Finalize.cs index 27b34dc4..6d41247d 100644 --- a/API/Controller/OAuth/Finalize.cs +++ b/API/Controller/OAuth/Finalize.cs @@ -15,6 +15,7 @@ using OpenShock.Common.Utils; using System.Net.Mime; using System.Security.Claims; +using OpenShock.API.Constants; namespace OpenShock.API.Controller.OAuth; @@ -57,7 +58,7 @@ public async Task OAuthFinalize( return Problem(OAuthError.UnsupportedFlow); // Authenticate via the short-lived OAuth flow cookie (temp scheme) - var auth = await HttpContext.AuthenticateAsync(OpenShockAuthSchemes.OAuthFlowScheme); + var auth = await HttpContext.AuthenticateAsync(OAuthConstants.FlowScheme); if (!auth.Succeeded || auth.Principal is null) return Problem(OAuthError.FlowNotFound); @@ -65,20 +66,20 @@ public async Task OAuthFinalize( var providerClaim = auth.Principal.FindFirst("provider")?.Value; if (!string.Equals(providerClaim, provider, StringComparison.OrdinalIgnoreCase)) { - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return Problem(OAuthError.ProviderMismatch); } // External identity basics from claims (added by your handler) var externalId = auth.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(externalId)) + var email = body.Email ?? auth.Principal.FindFirst(ClaimTypes.Email)?.Value; + var displayName = body.Username ?? auth.Principal.FindFirst(ClaimTypes.Name)?.Value; + if (string.IsNullOrEmpty(externalId) || string.IsNullOrEmpty(email) || string.IsNullOrEmpty(displayName)) { - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return Problem(OAuthError.FlowMissingData); } - var email = auth.Principal.FindFirst(ClaimTypes.Email)?.Value; - var displayName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value; // If the external is already linked, don’t allow relinking in either flow. var existing = await connectionService.GetByProviderExternalIdAsync(provider, externalId); @@ -100,7 +101,7 @@ public async Task OAuthFinalize( if (existing is not null) { // Already linked to someone, block. - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return Problem(OAuthError.ExternalAlreadyLinked); } @@ -110,7 +111,7 @@ public async Task OAuthFinalize( providerAccountId: externalId, providerAccountName: displayName ?? email); - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); if (!ok) return Problem(OAuthError.ExternalAlreadyLinked); @@ -123,14 +124,14 @@ public async Task OAuthFinalize( if (action is not AuthConstants.OAuthLoginOrCreateFlow) { - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return Problem(OAuthError.UnsupportedFlow); } if (existing is not null) { // External already mapped; treat as conflict (or you could return 200 if you consider this a no-op login). - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return Problem(OAuthError.ConnectionAlreadyExists); } @@ -150,11 +151,11 @@ public async Task OAuthFinalize( : body.Password; var created = await accountService.CreateOAuthOnlyAccountAsync( - email: email!, + email: email, username: body.Username!, provider: provider, providerAccountId: externalId, - providerAccountName: displayName ?? email + providerAccountName: displayName ); @@ -163,7 +164,7 @@ public async Task OAuthFinalize( return Problem(SignupError.UsernameOrEmailExists); } - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); var newUser = created.AsT0.Value; diff --git a/API/Controller/OAuth/GetData.cs b/API/Controller/OAuth/GetData.cs index 475cd42a..4163fd3f 100644 --- a/API/Controller/OAuth/GetData.cs +++ b/API/Controller/OAuth/GetData.cs @@ -8,6 +8,7 @@ using OpenShock.Common.Problems; using System.Net.Mime; using System.Security.Claims; +using OpenShock.API.Constants; namespace OpenShock.API.Controller.OAuth; @@ -34,25 +35,22 @@ public async Task OAuthGetData([FromRoute] string provider) return Problem(OAuthError.UnsupportedProvider); // Temp external principal (set by OAuth handler with SignInScheme=OAuthFlowScheme, SaveTokens=true) - var auth = await HttpContext.AuthenticateAsync(OpenShockAuthSchemes.OAuthFlowScheme); + var auth = await HttpContext.AuthenticateAsync(OAuthConstants.FlowScheme); if (!auth.Succeeded || auth.Principal is null) return Problem(OAuthError.FlowNotFound); // Read identifiers from claims (no props.Items) - var flowIdClaim = auth.Principal.FindFirst("flow_id")?.Value; - var providerClaim = auth.Principal.FindFirst("provider")?.Value; + var providerClaim = auth.Principal.Identity?.AuthenticationType; - if (string.IsNullOrWhiteSpace(flowIdClaim) || string.IsNullOrWhiteSpace(providerClaim)) + if (string.IsNullOrWhiteSpace(providerClaim)) { - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return Problem(OAuthError.FlowNotFound); } - // Defensive: ensure the snapshot belongs to this provider - if (providerClaim != provider) + if (!string.Equals(providerClaim, provider, StringComparison.InvariantCultureIgnoreCase)) { - // Optional: you may also delete the cookie if you consider this a poisoned flow - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return Problem(OAuthError.ProviderMismatch); } diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index db4867a7..1f19b4a1 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -12,6 +12,7 @@ using OpenShock.Common.Problems; using System.Net.Mime; using System.Security.Claims; +using OpenShock.API.Constants; namespace OpenShock.API.Controller.OAuth; @@ -26,7 +27,6 @@ public sealed partial class OAuthController /// If an existing connection is found, signs in and redirects home; otherwise redirects the frontend to continue the flow. /// /// Provider key (e.g. discord). - /// /// /// /// Redirect to the frontend (create/link) or home on direct sign-in. @@ -37,7 +37,6 @@ public sealed partial class OAuthController [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] public async Task OAuthHandOff( [FromRoute] string provider, - [FromServices] IAccountService accountService, [FromServices] IOAuthConnectionService connectionService, [FromServices] IOptions frontendOptions) { @@ -45,14 +44,14 @@ public async Task OAuthHandOff( return Problem(OAuthError.UnsupportedProvider); // Temp external principal (set by OAuth SignInScheme). - var auth = await HttpContext.AuthenticateAsync(OpenShockAuthSchemes.OAuthFlowScheme); + var auth = await HttpContext.AuthenticateAsync(OAuthConstants.FlowScheme); if (!auth.Succeeded || auth.Principal is null) return Problem(OAuthError.FlowNotFound); // Flow is stored in AuthenticationProperties by the authorize step. if (auth.Properties is null || !auth.Properties.Items.TryGetValue("flow", out var flow) || string.IsNullOrWhiteSpace(flow)) { - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return Problem(OAuthError.InternalError); } flow = flow.ToLowerInvariant(); @@ -61,7 +60,7 @@ public async Task OAuthHandOff( var externalId = auth.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(externalId)) { - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return Problem(OAuthError.FlowMissingData); } @@ -73,12 +72,14 @@ public async Task OAuthHandOff( { if (connection is not null) { - // Already linked -> sign in and go home. - // TODO: issue your UserSessionCookie/session here for connection.UserId - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + // Log In + + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return Redirect("/"); } + // Create + var frontendUrl = new UriBuilder(frontendOptions.Value.BaseUrl) { Path = $"oauth/{provider}/create" @@ -90,10 +91,14 @@ public async Task OAuthHandOff( { if (connection is not null) { + // Connection already exists, FAILURE + // TODO: Check if the connection is connected to our account with same externalId (AlreadyLinked), different externalId (AlreadyExists), or to another account (LinkedToAnotherAccount) - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return Problem(OAuthError.ExternalAlreadyLinked); } + + // Link connection to account var frontendUrl = new UriBuilder(frontendOptions.Value.BaseUrl) { @@ -103,7 +108,7 @@ public async Task OAuthHandOff( } default: - await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme); + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return Problem(OAuthError.UnsupportedFlow); } } diff --git a/API/Controller/OAuth/_ApiController.cs b/API/Controller/OAuth/_ApiController.cs index 5c83982f..40fc941d 100644 --- a/API/Controller/OAuth/_ApiController.cs +++ b/API/Controller/OAuth/_ApiController.cs @@ -1,6 +1,5 @@ using Asp.Versioning; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Account; using OpenShock.Common; diff --git a/API/Extensions/AuthenticationSchemeProviderExtensions.cs b/API/Extensions/AuthenticationSchemeProviderExtensions.cs index c10ae261..b0cc6c60 100644 --- a/API/Extensions/AuthenticationSchemeProviderExtensions.cs +++ b/API/Extensions/AuthenticationSchemeProviderExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authentication; +using OpenShock.API.Constants; using OpenShock.Common.Authentication; namespace OpenShock.API.Extensions; @@ -11,12 +12,12 @@ public static async Task GetAllOAuthSchemesAsync(this IAuthenticationS return schemes .Select(scheme => scheme.Name) - .Where(scheme => OpenShockAuthSchemes.OAuth2Schemes.Contains(scheme)) + .Where(scheme => OAuthConstants.OAuth2Schemes.Contains(scheme)) .ToArray(); } public static async Task IsSupportedOAuthScheme(this IAuthenticationSchemeProvider provider, string scheme) { - if (!OpenShockAuthSchemes.OAuth2Schemes.Contains(scheme)) + if (!OAuthConstants.OAuth2Schemes.Contains(scheme)) return false; var schemes = await provider.GetAllSchemesAsync(); diff --git a/API/Models/Requests/OAuthFinalizeRequest.cs b/API/Models/Requests/OAuthFinalizeRequest.cs index a3da8b2a..1110a188 100644 --- a/API/Models/Requests/OAuthFinalizeRequest.cs +++ b/API/Models/Requests/OAuthFinalizeRequest.cs @@ -10,10 +10,11 @@ public sealed class OAuthFinalizeRequest /// Desired username (create only). If omitted, a name will be generated from the external profile. public string? Username { get; init; } + + public string? Email { get; init; } /// - /// New account password (create only). If omitted, a strong random password will be generated. - /// Your current AccountService requires a password. + /// New account password (create only). /// public string? Password { get; init; } } \ No newline at end of file diff --git a/API/OAuth/OAuthConstants.cs b/API/OAuth/OAuthConstants.cs new file mode 100644 index 00000000..23183297 --- /dev/null +++ b/API/OAuth/OAuthConstants.cs @@ -0,0 +1,15 @@ +namespace OpenShock.API.Constants; + +public static class OAuthConstants +{ + public const string FlowScheme = "OAuthFlowCookie"; + public const string FlowCookieName = ".OpenShock.OAuthFlow"; + + public const string DiscordScheme = "discord"; + public static readonly string[] OAuth2Schemes = [DiscordScheme]; + + public const string ItemKeyFlowType = "openshock.oauth.flow_type"; + + public const string ClaimEmailVerified = "openshock.oauth.email_verified"; + public const string ClaimDisplayName = "openshock.oauth.display_name"; +} \ No newline at end of file diff --git a/Common/Errors/OAuthError.cs b/API/OAuth/OAuthError.cs similarity index 91% rename from Common/Errors/OAuthError.cs rename to API/OAuth/OAuthError.cs index a48f9b3f..05eaecc7 100644 --- a/Common/Errors/OAuthError.cs +++ b/API/OAuth/OAuthError.cs @@ -20,6 +20,11 @@ public static class OAuthError "This OAuth flow type is not recognized or allowed", HttpStatusCode.Forbidden); + public static OpenShockProblem AnonymousOnlyEndpoint => new( + "OAuth.Flow.AnonymousOnlyEndpoint", + "You must be signed out to call this endpoint", + HttpStatusCode.Unauthorized); + public static OpenShockProblem FlowNotFound => new( "OAuth.Flow.NotFound", "The OAuth flow was not found, has expired, or is invalid", diff --git a/API/OAuth/OAuthUtil.cs b/API/OAuth/OAuthUtil.cs new file mode 100644 index 00000000..bdd7530d --- /dev/null +++ b/API/OAuth/OAuthUtil.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using OpenShock.API.Constants; +using OpenShock.Common.Authentication; + +namespace OpenShock.API.Utils; + +public static class OAuthUtil +{ + public static ChallengeResult StartOAuth(string provider, string flow) + { + return new ChallengeResult(provider, new AuthenticationProperties + { + RedirectUri = $"/1/oauth/{provider}/handoff", + Items = { { OAuthConstants.ItemKeyFlowType, flow } } + }); + } +} \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 397bcd6c..74a90b31 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.Connections; using Microsoft.Extensions.Options; +using OpenShock.API.Constants; using OpenShock.API.Options.OAuth; using OpenShock.API.Realtime; using OpenShock.API.Services; @@ -40,15 +41,15 @@ builder.Services.AddOpenShockMemDB(redisConfig); builder.Services.AddOpenShockDB(databaseConfig); builder.Services.AddOpenShockServices(auth => auth - .AddCookie(OpenShockAuthSchemes.OAuthFlowScheme, o => + .AddCookie(OAuthConstants.FlowScheme, o => { - o.Cookie.Name = OpenShockAuthSchemes.OAuthFlowCookieName; + o.Cookie.Name = OAuthConstants.FlowCookieName; o.ExpireTimeSpan = TimeSpan.FromMinutes(10); o.SlidingExpiration = false; }) - .AddDiscord(OpenShockAuthSchemes.DiscordScheme, o => + .AddDiscord(OAuthConstants.DiscordScheme, o => { - o.SignInScheme = OpenShockAuthSchemes.OAuthFlowScheme; + o.SignInScheme = OAuthConstants.FlowScheme; var options = builder.Configuration.GetRequiredSection(DiscordOAuthOptions.SectionName).Get()!; @@ -59,9 +60,10 @@ foreach (var scope in options.Scopes) o.Scope.Add(scope); o.Prompt = "none"; - o.SaveTokens = true; + o.SaveTokens = false; - o.ClaimActions.MapJsonKey("email-verified", "verified"); + o.ClaimActions.MapJsonKey(OAuthConstants.ClaimEmailVerified, "verified"); + o.ClaimActions.MapJsonKey(OAuthConstants.ClaimDisplayName, "global_name"); o.Validate(); }) diff --git a/Common/Authentication/OpenShockAuthSchemes.cs b/Common/Authentication/OpenShockAuthSchemes.cs index 27ebe9b4..7d3c8792 100644 --- a/Common/Authentication/OpenShockAuthSchemes.cs +++ b/Common/Authentication/OpenShockAuthSchemes.cs @@ -6,10 +6,5 @@ public static class OpenShockAuthSchemes public const string ApiToken = "ApiToken"; public const string HubToken = "HubToken"; - public const string OAuthFlowScheme = "OAuthFlowCookie"; - public const string OAuthFlowCookieName = ".OpenShock.OAuthFlow"; - public const string DiscordScheme = "discord"; - public static readonly string[] OAuth2Schemes = [DiscordScheme]; - public const string UserSessionApiTokenCombo = $"{UserSessionCookie},{ApiToken}"; } \ No newline at end of file From 3c813eaae80c18e6d99fc0d6bad7d681666b6408 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Thu, 4 Sep 2025 23:42:07 +0200 Subject: [PATCH 44/63] Revert changes in ApiTokenAuthentication.cs --- .../AuthenticationHandlers/ApiTokenAuthentication.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs b/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs index 48d6f498..f98fda19 100644 --- a/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs +++ b/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs @@ -66,7 +66,7 @@ protected override async Task HandleAuthenticateAsync() var claims = new List(3 + tokenDto.Permissions.Count) { - new(ClaimTypes.AuthenticationMethod, Scheme.Name), + new(ClaimTypes.AuthenticationMethod, OpenShockAuthSchemes.ApiToken), new(ClaimTypes.NameIdentifier, tokenDto.User.Id.ToString()), new(OpenShockAuthClaims.ApiTokenId, tokenDto.Id.ToString()) }; From c1c9fda7138537a25ad37bf8cdcac91732fd9943 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Fri, 5 Sep 2025 01:02:08 +0200 Subject: [PATCH 45/63] More cleanup --- .../Authenticated/OAuthConnectionAdd.cs | 6 +- API/Controller/OAuth/Authorize.cs | 8 +- API/Controller/OAuth/Finalize.cs | 173 ++++++------------ API/Controller/OAuth/GetData.cs | 7 +- API/Controller/OAuth/HandOff.cs | 35 ++-- API/Controller/OAuth/ListProviders.cs | 2 +- API/Controller/OAuth/_ApiController.cs | 48 +++++ API/Models/Requests/OAuthFinalizeRequest.cs | 10 +- .../AuthenticationSchemeProviderExtensions.cs | 4 +- API/OAuth/OAuthConstants.cs | 4 +- API/OAuth/OAuthError.cs | 15 +- API/OAuth/OAuthFlow.cs | 7 + API/OAuth/OAuthUtil.cs | 8 +- API/OAuth/ValidatedFlowContext.cs | 6 + API/Program.cs | 2 +- Common/Constants/AuthConstants.cs | 3 - 16 files changed, 164 insertions(+), 174 deletions(-) rename API/{Extensions => OAuth}/AuthenticationSchemeProviderExtensions.cs (88%) create mode 100644 API/OAuth/OAuthFlow.cs create mode 100644 API/OAuth/ValidatedFlowContext.cs diff --git a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs index 694ac9b7..f8525d7f 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs @@ -1,10 +1,8 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; -using OpenShock.API.Extensions; -using OpenShock.Common.Constants; using OpenShock.Common.Problems; using System.Net.Mime; -using OpenShock.API.Utils; +using OpenShock.API.OAuth; namespace OpenShock.API.Controller.Account.Authenticated; @@ -31,6 +29,6 @@ public async Task AddOAuthConnection([FromRoute] string provider, if (!await schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.UnsupportedProvider); - return OAuthUtil.StartOAuth(provider, AuthConstants.OAuthLinkFlow); + return OAuthUtil.StartOAuth(provider, OAuthFlow.Link); } } \ No newline at end of file diff --git a/API/Controller/OAuth/Authorize.cs b/API/Controller/OAuth/Authorize.cs index aa8921c9..ee2e1b84 100644 --- a/API/Controller/OAuth/Authorize.cs +++ b/API/Controller/OAuth/Authorize.cs @@ -1,10 +1,8 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using OpenShock.API.Extensions; -using OpenShock.Common.Constants; using OpenShock.Common.Problems; using System.Net.Mime; -using OpenShock.API.Utils; +using OpenShock.API.OAuth; using OpenShock.Common.Authentication; namespace OpenShock.API.Controller.OAuth; @@ -35,7 +33,7 @@ public async Task OAuthAuthorize([FromRoute] string provider) { return Problem(OAuthError.AnonymousOnlyEndpoint); } - - return OAuthUtil.StartOAuth(provider, AuthConstants.OAuthLoginOrCreateFlow); + + return OAuthUtil.StartOAuth(provider, OAuthFlow.LoginOrCreate); } } \ No newline at end of file diff --git a/API/Controller/OAuth/Finalize.cs b/API/Controller/OAuth/Finalize.cs index 6d41247d..b421b949 100644 --- a/API/Controller/OAuth/Finalize.cs +++ b/API/Controller/OAuth/Finalize.cs @@ -1,21 +1,18 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using OpenShock.API.Extensions; using OpenShock.API.Models.Requests; using OpenShock.API.Models.Response; using OpenShock.API.Services.Account; using OpenShock.API.Services.OAuthConnection; -using OpenShock.Common.Authentication; using OpenShock.Common.Authentication.Services; -using OpenShock.Common.Constants; using OpenShock.Common.Errors; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Problems; using OpenShock.Common.Utils; using System.Net.Mime; using System.Security.Claims; -using OpenShock.API.Constants; +using OpenShock.API.OAuth; namespace OpenShock.API.Controller.OAuth; @@ -50,24 +47,10 @@ public async Task OAuthFinalize( [FromServices] IAccountService accountService, [FromServices] IOAuthConnectionService connectionService) { - if (!await _schemeProvider.IsSupportedOAuthScheme(provider)) - return Problem(OAuthError.UnsupportedProvider); - - var action = body.Action?.Trim().ToLowerInvariant(); - if (action is not (AuthConstants.OAuthLoginOrCreateFlow or AuthConstants.OAuthLinkFlow)) - return Problem(OAuthError.UnsupportedFlow); - - // Authenticate via the short-lived OAuth flow cookie (temp scheme) - var auth = await HttpContext.AuthenticateAsync(OAuthConstants.FlowScheme); - if (!auth.Succeeded || auth.Principal is null) - return Problem(OAuthError.FlowNotFound); - - // Flow must belong to the same provider we’re finalizing - var providerClaim = auth.Principal.FindFirst("provider")?.Value; - if (!string.Equals(providerClaim, provider, StringComparison.OrdinalIgnoreCase)) + var result = await ValidateOAuthFlowAsync(provider); + if (!result.TryPickT0(out var auth, out var response)) { - await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.ProviderMismatch); + return response; } // External identity basics from claims (added by your handler) @@ -80,54 +63,68 @@ public async Task OAuthFinalize( return Problem(OAuthError.FlowMissingData); } - - // If the external is already linked, don’t allow relinking in either flow. - var existing = await connectionService.GetByProviderExternalIdAsync(provider, externalId); - - if (action == AuthConstants.OAuthLinkFlow) + return body.Action switch { - // Linking requires an authenticated session - var userRef = HttpContext.RequestServices.GetRequiredService(); - if (userRef.AuthReference is null || !userRef.AuthReference.Value.IsT0) - { - // Not a logged-in session (could be API token or anonymous) - return Problem(OAuthError.NotAuthenticatedForLink); - } - - var currentUser = HttpContext.RequestServices - .GetRequiredService>() - .CurrentClient; - - if (existing is not null) - { - // Already linked to someone, block. - await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.ExternalAlreadyLinked); - } - - var ok = await connectionService.TryAddConnectionAsync( - userId: currentUser.Id, - provider: provider, - providerAccountId: externalId, - providerAccountName: displayName ?? email); + OAuthFlow.Link => await HandleLink(provider, externalId, displayName, connectionService), + OAuthFlow.LoginOrCreate => await HandleLoginOrCreate(provider, externalId, email, displayName, accountService, connectionService), + _ => await HandleBadFlow() + }; + async Task HandleBadFlow() + { await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - - if (!ok) return Problem(OAuthError.ExternalAlreadyLinked); - - return Ok(new OAuthFinalizeResponse - { - Provider = provider, - ExternalId = externalId - }); + return Problem(OAuthError.UnsupportedFlow); } + } - if (action is not AuthConstants.OAuthLoginOrCreateFlow) + [NonAction] + private async Task HandleLink(string provider, string externalId, string displayName, IOAuthConnectionService connectionService) + { + // If the external is already linked, don’t allow relinking in either flow. + var existing = await connectionService.GetByProviderExternalIdAsync(provider, externalId); + if (existing is not null) { + // Already linked to someone, block. await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.UnsupportedFlow); + return Problem(OAuthError.ExternalAlreadyLinked); + } + + // Linking requires an authenticated session + var userRef = HttpContext.RequestServices.GetRequiredService(); + if (userRef.AuthReference is null || !userRef.AuthReference.Value.IsT0) + { + // Not a logged-in session (could be API token or anonymous) + return Problem(OAuthError.NotAuthenticatedForLink); } + var currentUser = HttpContext.RequestServices + .GetRequiredService>() + .CurrentClient; + + + var ok = await connectionService.TryAddConnectionAsync( + userId: currentUser.Id, + provider: provider, + providerAccountId: externalId, + providerAccountName: displayName + ); + + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + + if (!ok) return Problem(OAuthError.ExternalAlreadyLinked); + + return Ok(new OAuthFinalizeResponse + { + Provider = provider, + ExternalId = externalId + }); + } + + [NonAction] + private async Task HandleLoginOrCreate(string provider, string externalId, string email, string displayName, IAccountService accountService, IOAuthConnectionService connectionService) + { + // If the external is already linked, don’t allow relinking in either flow. + var existing = await connectionService.GetByProviderExternalIdAsync(provider, externalId); if (existing is not null) { // External already mapped; treat as conflict (or you could return 200 if you consider this a no-op login). @@ -136,23 +133,12 @@ public async Task OAuthFinalize( } // We must create a local account. Your AccountService requires a password, so: - var desiredUsername = body.Username?.Trim(); - if (string.IsNullOrEmpty(desiredUsername)) - { - // Generate a reasonable username from displayName/email/externalId - desiredUsername = GenerateUsername(displayName, email, externalId, provider); - } - - // Ensure username is available; if not, try a few suffixes - desiredUsername = await EnsureAvailableUsernameAsync(desiredUsername, accountService); - - var password = string.IsNullOrEmpty(body.Password) - ? CryptoUtils.RandomString(32) // strong random (since OAuth-only users won't use it) - : body.Password; + displayName = displayName.Trim(); + // TODO: Check if username valid, if invalid respond with bad request, dont clear cookie tho, so that frontend can try again var created = await accountService.CreateOAuthOnlyAccountAsync( email: email, - username: body.Username!, + username: displayName, provider: provider, providerAccountId: externalId, providerAccountName: displayName @@ -174,44 +160,5 @@ public async Task OAuthFinalize( ExternalId = externalId, Username = newUser.Name }); - - // ------- local helpers -------- - - static string GenerateUsername(string? name, string? mail, string externalId, string providerKey) - { - if (!string.IsNullOrWhiteSpace(name)) - return Slugify(name); - - if (!string.IsNullOrWhiteSpace(mail)) - { - var at = mail.IndexOf('@'); - if (at > 0) return Slugify(mail[..at]); - } - - return $"{providerKey}_{externalId}".ToLowerInvariant(); - } - - static string Slugify(string s) - { - var slug = new string(s.Trim() - .ToLowerInvariant() - .Select(ch => char.IsLetterOrDigit(ch) ? ch : '_') - .ToArray()); - slug = System.Text.RegularExpressions.Regex.Replace(slug, "_{2,}", "_").Trim('_'); - return string.IsNullOrEmpty(slug) ? "user" : slug; - } - - static async Task EnsureAvailableUsernameAsync(string baseName, IAccountService account) - { - var candidate = baseName; - for (var i = 0; i < 10; i++) - { - var check = await account.CheckUsernameAvailabilityAsync(candidate); - if (check.IsT0) return candidate; // Success - candidate = $"{baseName}_{CryptoUtils.RandomString(4).ToLowerInvariant()}"; - } - // last resort: include a timestamp suffix - return $"{baseName}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"; - } } } diff --git a/API/Controller/OAuth/GetData.cs b/API/Controller/OAuth/GetData.cs index 4163fd3f..33c895ed 100644 --- a/API/Controller/OAuth/GetData.cs +++ b/API/Controller/OAuth/GetData.cs @@ -1,14 +1,13 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using OpenShock.API.Extensions; using OpenShock.API.Models.Response; using OpenShock.Common.Authentication; using OpenShock.Common.Errors; using OpenShock.Common.Problems; using System.Net.Mime; using System.Security.Claims; -using OpenShock.API.Constants; +using OpenShock.API.OAuth; namespace OpenShock.API.Controller.OAuth; @@ -37,7 +36,7 @@ public async Task OAuthGetData([FromRoute] string provider) // Temp external principal (set by OAuth handler with SignInScheme=OAuthFlowScheme, SaveTokens=true) var auth = await HttpContext.AuthenticateAsync(OAuthConstants.FlowScheme); if (!auth.Succeeded || auth.Principal is null) - return Problem(OAuthError.FlowNotFound); + return Problem(OAuthError.FlowStateNotFound); // Read identifiers from claims (no props.Items) var providerClaim = auth.Principal.Identity?.AuthenticationType; @@ -45,7 +44,7 @@ public async Task OAuthGetData([FromRoute] string provider) if (string.IsNullOrWhiteSpace(providerClaim)) { await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.FlowNotFound); + return Problem(OAuthError.FlowStateNotFound); } if (!string.Equals(providerClaim, provider, StringComparison.InvariantCultureIgnoreCase)) diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index 1f19b4a1..a03fa534 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -2,17 +2,12 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; -using OpenShock.API.Extensions; -using OpenShock.API.Services.Account; using OpenShock.API.Services.OAuthConnection; -using OpenShock.Common.Authentication; -using OpenShock.Common.Constants; -using OpenShock.Common.Errors; using OpenShock.Common.Options; using OpenShock.Common.Problems; using System.Net.Mime; using System.Security.Claims; -using OpenShock.API.Constants; +using OpenShock.API.OAuth; namespace OpenShock.API.Controller.OAuth; @@ -40,22 +35,12 @@ public async Task OAuthHandOff( [FromServices] IOAuthConnectionService connectionService, [FromServices] IOptions frontendOptions) { - if (!await _schemeProvider.IsSupportedOAuthScheme(provider)) - return Problem(OAuthError.UnsupportedProvider); - - // Temp external principal (set by OAuth SignInScheme). - var auth = await HttpContext.AuthenticateAsync(OAuthConstants.FlowScheme); - if (!auth.Succeeded || auth.Principal is null) - return Problem(OAuthError.FlowNotFound); - - // Flow is stored in AuthenticationProperties by the authorize step. - if (auth.Properties is null || !auth.Properties.Items.TryGetValue("flow", out var flow) || string.IsNullOrWhiteSpace(flow)) + var result = await ValidateOAuthFlowAsync(provider); + if (!result.TryPickT0(out var auth, out var response)) { - await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.InternalError); + return response; } - flow = flow.ToLowerInvariant(); - + // External subject is required to resolve/link. var externalId = auth.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(externalId)) @@ -66,14 +51,16 @@ public async Task OAuthHandOff( var connection = await connectionService.GetByProviderExternalIdAsync(provider, externalId); - switch (flow) + switch (auth.Flow) { - case AuthConstants.OAuthLoginOrCreateFlow: + case OAuthFlow.LoginOrCreate: { + // TODO: Fail if currently logged in + if (connection is not null) { // Log In - + // TODO: Initialize authentication session await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return Redirect("/"); } @@ -87,7 +74,7 @@ public async Task OAuthHandOff( return Redirect(frontendUrl.Uri.ToString()); } - case AuthConstants.OAuthLinkFlow: + case OAuthFlow.Link: { if (connection is not null) { diff --git a/API/Controller/OAuth/ListProviders.cs b/API/Controller/OAuth/ListProviders.cs index bdffb49a..6f2dba72 100644 --- a/API/Controller/OAuth/ListProviders.cs +++ b/API/Controller/OAuth/ListProviders.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; -using OpenShock.API.Extensions; +using OpenShock.API.OAuth; namespace OpenShock.API.Controller.OAuth; diff --git a/API/Controller/OAuth/_ApiController.cs b/API/Controller/OAuth/_ApiController.cs index 40fc941d..d2aaae38 100644 --- a/API/Controller/OAuth/_ApiController.cs +++ b/API/Controller/OAuth/_ApiController.cs @@ -1,6 +1,8 @@ using Asp.Versioning; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; +using OneOf; +using OpenShock.API.OAuth; using OpenShock.API.Services.Account; using OpenShock.Common; @@ -25,4 +27,50 @@ public OAuthController(IAccountService accountService, IAuthenticationSchemeProv _schemeProvider = schemeProvider; _logger = logger; } + + /// + /// Validates: provider exists, temp cookie auth present, scheme matches, flow parsable. + /// On success returns ValidatedFlowContext; on failure returns IActionResult with proper problem details. + /// + private async Task> ValidateOAuthFlowAsync(string expectedProvider, OAuthFlow? expectedFlow = null) + { + // 1) provider supported? + if (!await _schemeProvider.IsSupportedOAuthScheme(expectedProvider)) + return Problem(OAuthError.UnsupportedProvider); + + // 2) authenticate temp cookie + var auth = await HttpContext.AuthenticateAsync(OAuthConstants.FlowScheme); + if (!auth.Succeeded || auth.Principal is null || auth.Ticket is null) + return Problem(OAuthError.FlowStateNotFound); + + // 3) scheme/provider check — prefer the ticket's scheme over a magic Item + var actualScheme = auth.Properties.Items[".AuthScheme"]; + if (actualScheme is null || !string.Equals(actualScheme, expectedProvider, StringComparison.OrdinalIgnoreCase)) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return Problem(OAuthError.ProviderMismatch); + } + + // 4) parse flow from properties + if (auth.Properties is null || + !auth.Properties.Items.TryGetValue(OAuthConstants.ItemKeyFlowType, out var flowStr) || + !Enum.TryParse(flowStr, true, out var flow)) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return Problem(OAuthError.UnsupportedFlow); + } + + if (expectedFlow is not null && flow != expectedFlow) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return Problem(OAuthError.FlowMismatch); + } + + return new ValidatedFlowContext( + Provider: expectedProvider, + Flow: flow, + Principal: auth.Principal, + Properties: auth.Properties + ); + } } \ No newline at end of file diff --git a/API/Models/Requests/OAuthFinalizeRequest.cs b/API/Models/Requests/OAuthFinalizeRequest.cs index 1110a188..6bd8e934 100644 --- a/API/Models/Requests/OAuthFinalizeRequest.cs +++ b/API/Models/Requests/OAuthFinalizeRequest.cs @@ -1,20 +1,20 @@ using System.ComponentModel.DataAnnotations; +using OpenShock.API.OAuth; namespace OpenShock.API.Models.Requests; public sealed class OAuthFinalizeRequest { /// Action to perform: "create" or "link". - [Required] - public required string Action { get; init; } + public required OAuthFlow Action { get; init; } /// Desired username (create only). If omitted, a name will be generated from the external profile. - public string? Username { get; init; } + public required string? Username { get; init; } - public string? Email { get; init; } + public required string? Email { get; init; } /// /// New account password (create only). /// - public string? Password { get; init; } + public required string? Password { get; init; } } \ No newline at end of file diff --git a/API/Extensions/AuthenticationSchemeProviderExtensions.cs b/API/OAuth/AuthenticationSchemeProviderExtensions.cs similarity index 88% rename from API/Extensions/AuthenticationSchemeProviderExtensions.cs rename to API/OAuth/AuthenticationSchemeProviderExtensions.cs index b0cc6c60..5da2d4d9 100644 --- a/API/Extensions/AuthenticationSchemeProviderExtensions.cs +++ b/API/OAuth/AuthenticationSchemeProviderExtensions.cs @@ -1,8 +1,6 @@ using Microsoft.AspNetCore.Authentication; -using OpenShock.API.Constants; -using OpenShock.Common.Authentication; -namespace OpenShock.API.Extensions; +namespace OpenShock.API.OAuth; public static class AuthenticationSchemeProviderExtensions { diff --git a/API/OAuth/OAuthConstants.cs b/API/OAuth/OAuthConstants.cs index 23183297..4bae08a4 100644 --- a/API/OAuth/OAuthConstants.cs +++ b/API/OAuth/OAuthConstants.cs @@ -1,4 +1,4 @@ -namespace OpenShock.API.Constants; +namespace OpenShock.API.OAuth; public static class OAuthConstants { @@ -8,7 +8,7 @@ public static class OAuthConstants public const string DiscordScheme = "discord"; public static readonly string[] OAuth2Schemes = [DiscordScheme]; - public const string ItemKeyFlowType = "openshock.oauth.flow_type"; + public const string ItemKeyFlowType = ".FlowType"; public const string ClaimEmailVerified = "openshock.oauth.email_verified"; public const string ClaimDisplayName = "openshock.oauth.display_name"; diff --git a/API/OAuth/OAuthError.cs b/API/OAuth/OAuthError.cs index 05eaecc7..584aff7f 100644 --- a/API/OAuth/OAuthError.cs +++ b/API/OAuth/OAuthError.cs @@ -1,5 +1,7 @@ -using OpenShock.Common.Problems; -using System.Net; +using System.Net; +using OpenShock.Common.Problems; + +namespace OpenShock.API.OAuth; public static class OAuthError { @@ -19,13 +21,18 @@ public static class OAuthError "OAuth.Flow.Unsupported", "This OAuth flow type is not recognized or allowed", HttpStatusCode.Forbidden); + + public static OpenShockProblem FlowMismatch => new( + "OAuth.Flow.Mismatch", + "This OAuth flow differs from the flow the oauth flow started with", + HttpStatusCode.Forbidden); public static OpenShockProblem AnonymousOnlyEndpoint => new( "OAuth.Flow.AnonymousOnlyEndpoint", "You must be signed out to call this endpoint", HttpStatusCode.Unauthorized); - public static OpenShockProblem FlowNotFound => new( + public static OpenShockProblem FlowStateNotFound => new( "OAuth.Flow.NotFound", "The OAuth flow was not found, has expired, or is invalid", HttpStatusCode.BadRequest); @@ -56,4 +63,4 @@ public static class OAuthError "OAuth.InternalError", "An unexpected error occurred while processing the OAuth flow", HttpStatusCode.InternalServerError); -} +} \ No newline at end of file diff --git a/API/OAuth/OAuthFlow.cs b/API/OAuth/OAuthFlow.cs new file mode 100644 index 00000000..2caf75c4 --- /dev/null +++ b/API/OAuth/OAuthFlow.cs @@ -0,0 +1,7 @@ +namespace OpenShock.API.OAuth; + +public enum OAuthFlow +{ + LoginOrCreate, + Link +} \ No newline at end of file diff --git a/API/OAuth/OAuthUtil.cs b/API/OAuth/OAuthUtil.cs index bdd7530d..5ae53549 100644 --- a/API/OAuth/OAuthUtil.cs +++ b/API/OAuth/OAuthUtil.cs @@ -1,18 +1,16 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; -using OpenShock.API.Constants; -using OpenShock.Common.Authentication; -namespace OpenShock.API.Utils; +namespace OpenShock.API.OAuth; public static class OAuthUtil { - public static ChallengeResult StartOAuth(string provider, string flow) + public static ChallengeResult StartOAuth(string provider, OAuthFlow flow) { return new ChallengeResult(provider, new AuthenticationProperties { RedirectUri = $"/1/oauth/{provider}/handoff", - Items = { { OAuthConstants.ItemKeyFlowType, flow } } + Items = { { OAuthConstants.ItemKeyFlowType, flow.ToString() } } }); } } \ No newline at end of file diff --git a/API/OAuth/ValidatedFlowContext.cs b/API/OAuth/ValidatedFlowContext.cs new file mode 100644 index 00000000..27f9e752 --- /dev/null +++ b/API/OAuth/ValidatedFlowContext.cs @@ -0,0 +1,6 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; + +namespace OpenShock.API.OAuth; + +public sealed record ValidatedFlowContext(string Provider, OAuthFlow Flow, ClaimsPrincipal Principal, AuthenticationProperties Properties); \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 74a90b31..e88d58e3 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.Connections; using Microsoft.Extensions.Options; -using OpenShock.API.Constants; +using OpenShock.API.OAuth; using OpenShock.API.Options.OAuth; using OpenShock.API.Realtime; using OpenShock.API.Services; diff --git a/Common/Constants/AuthConstants.cs b/Common/Constants/AuthConstants.cs index 5d0e2651..3f1153d5 100644 --- a/Common/Constants/AuthConstants.cs +++ b/Common/Constants/AuthConstants.cs @@ -7,9 +7,6 @@ public static class AuthConstants public const string ApiTokenHeaderName = "OpenShockToken"; public const string HubTokenHeaderName = "DeviceToken"; - public const string OAuthLoginOrCreateFlow = "login-or-create"; - public const string OAuthLinkFlow = "link"; - public const int GeneratedTokenLength = 32; public const int ApiTokenLength = 64; } From 80c70bc44e9285fcb43ce8222edd1e1537682900 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Mon, 8 Sep 2025 12:10:39 +0200 Subject: [PATCH 46/63] More work on oauth session initiarion --- API/Controller/Account/Login.cs | 29 +++++---- API/Controller/Account/LoginV2.cs | 42 ++++++------- API/Controller/OAuth/Finalize.cs | 9 +-- API/Controller/OAuth/HandOff.cs | 60 +++++++++++++----- API/Controller/OAuth/_ApiController.cs | 12 +++- API/OAuth/ValidatedFlowContext.cs | 2 +- API/Services/Account/AccountService.cs | 18 +++--- API/Services/Account/IAccountService.cs | 7 +-- Common/Errors/LoginError.cs | 2 +- Common/OpenShockControllerBase.cs | 81 +++++++++++++++++++++++-- 10 files changed, 184 insertions(+), 78 deletions(-) diff --git a/API/Controller/Account/Login.cs b/API/Controller/Account/Login.cs index f6238070..5432968e 100644 --- a/API/Controller/Account/Login.cs +++ b/API/Controller/Account/Login.cs @@ -10,6 +10,7 @@ using OpenShock.Common.Utils; using System.Net.Mime; using Microsoft.AspNetCore.RateLimiting; +using OpenShock.Common.Services.Session; namespace OpenShock.API.Controller.Account; @@ -28,28 +29,26 @@ public sealed partial class AccountController [MapToApiVersion("1")] public async Task Login( [FromBody] Login body, + [FromServices] ISessionService sessionService, [FromServices] IOptions options, CancellationToken cancellationToken) { var cookieDomainToUse = options.Value.CookieDomain.Split(',').FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase)); if (cookieDomainToUse is null) return Problem(LoginError.InvalidDomain); - var loginAction = await _accountService.CreateUserLoginSessionAsync(body.Email, body.Password, new LoginContext + var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.Email, body.Password, cancellationToken); + if (!getAccountResult.TryPickT0(out var account, out var errors)) { - Ip = HttpContext.GetRemoteIP().ToString(), - UserAgent = HttpContext.GetUserAgent(), - }, cancellationToken); + return errors.Match( + notFound => Problem(LoginError.InvalidCredentials), + deactivated => Problem(AccountError.AccountDeactivated), + notActivated => Problem(AccountError.AccountNotActivated), + oauthOnly => Problem(AccountError.AccountOAuthOnly) + ); + } - return loginAction.Match( - ok => - { - HttpContext.SetSessionKeyCookie(ok.Token, "." + cookieDomainToUse); - return LegacyEmptyOk("Successfully logged in"); - }, - deactivated => Problem(AccountError.AccountDeactivated), - oauthOnly => Problem(AccountError.AccountOAuthOnly), - notActivated => Problem(AccountError.AccountNotActivated), - notFound => Problem(LoginError.InvalidCredentials) - ); + var session = await sessionService.CreateSessionAsync(account.Id, HttpContext.GetUserAgent(), HttpContext.GetRemoteIP().ToString()); + HttpContext.SetSessionKeyCookie(session.Token, "." + cookieDomainToUse); + return LegacyEmptyOk("Successfully logged in"); } } \ No newline at end of file diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index 5a6edecf..5b7e6d2a 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.Options; using OpenShock.API.Models.Response; using OpenShock.Common.Options; +using OpenShock.Common.Services.Session; namespace OpenShock.API.Controller.Account; @@ -32,40 +33,37 @@ public sealed partial class AccountController public async Task LoginV2( [FromBody] LoginV2 body, [FromServices] ICloudflareTurnstileService turnstileService, + [FromServices] ISessionService sessionService, [FromServices] IOptions options, CancellationToken cancellationToken) { var cookieDomainToUse = options.Value.CookieDomain.Split(',').FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase)); if (cookieDomainToUse is null) return Problem(LoginError.InvalidDomain); - var remoteIP = HttpContext.GetRemoteIP(); + var remoteIp = HttpContext.GetRemoteIP(); - var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, remoteIP, cancellationToken); - if (!turnStile.IsT0) + var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, remoteIp, cancellationToken); + if (!turnStile.TryPickT0(out _, out var cfErrors)) { - var cfErrors = turnStile.AsT1.Value; - if (cfErrors.All(err => err == CloduflareTurnstileError.InvalidResponse)) + if (cfErrors.Value.All(err => err == CloduflareTurnstileError.InvalidResponse)) return Problem(TurnstileError.InvalidTurnstile); return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError)); } - - var loginAction = await _accountService.CreateUserLoginSessionAsync(body.UsernameOrEmail, body.Password, new LoginContext + + var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.UsernameOrEmail, body.Password, cancellationToken); + if (!getAccountResult.TryPickT0(out var account, out var errors)) { - Ip = remoteIP.ToString(), - UserAgent = HttpContext.GetUserAgent(), - }, cancellationToken); - - return loginAction.Match( - ok => - { - HttpContext.SetSessionKeyCookie(ok.Token, "." + cookieDomainToUse); - return Ok(LoginV2OkResponse.FromUser(ok.User)); - }, - deactivated => Problem(AccountError.AccountDeactivated), - oauthOnly => Problem(AccountError.AccountOAuthOnly), - notActivated => Problem(AccountError.AccountNotActivated), - notFound => Problem(LoginError.InvalidCredentials) - ); + return errors.Match( + notFound => Problem(LoginError.InvalidCredentials), + deactivated => Problem(AccountError.AccountDeactivated), + notActivated => Problem(AccountError.AccountNotActivated), + oauthOnly => Problem(AccountError.AccountOAuthOnly) + ); + } + + var session = await sessionService.CreateSessionAsync(account.Id, HttpContext.GetUserAgent(), remoteIp.ToString()); + HttpContext.SetSessionKeyCookie(session.Token, "." + cookieDomainToUse); + return Ok(LoginV2OkResponse.FromUser(account)); } } \ No newline at end of file diff --git a/API/Controller/OAuth/Finalize.cs b/API/Controller/OAuth/Finalize.cs index b421b949..a294e2d9 100644 --- a/API/Controller/OAuth/Finalize.cs +++ b/API/Controller/OAuth/Finalize.cs @@ -90,20 +90,15 @@ private async Task HandleLink(string provider, string externalId, } // Linking requires an authenticated session - var userRef = HttpContext.RequestServices.GetRequiredService(); - if (userRef.AuthReference is null || !userRef.AuthReference.Value.IsT0) + if (!TryGetAuthenticatedOpenShockUserId(out var currentUserId)) { // Not a logged-in session (could be API token or anonymous) return Problem(OAuthError.NotAuthenticatedForLink); } - var currentUser = HttpContext.RequestServices - .GetRequiredService>() - .CurrentClient; - var ok = await connectionService.TryAddConnectionAsync( - userId: currentUser.Id, + userId: currentUserId, provider: provider, providerAccountId: externalId, providerAccountName: displayName diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index a03fa534..53085170 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -7,7 +7,11 @@ using OpenShock.Common.Problems; using System.Net.Mime; using System.Security.Claims; +using Microsoft.AspNetCore.Http.Extensions; using OpenShock.API.OAuth; +using OpenShock.Common.Errors; +using OpenShock.Common.Services.Session; +using OpenShock.Common.Utils; namespace OpenShock.API.Controller.OAuth; @@ -23,7 +27,9 @@ public sealed partial class OAuthController /// /// Provider key (e.g. discord). /// + /// /// + /// /// Redirect to the frontend (create/link) or home on direct sign-in. /// Flow missing or not supported. [EnableRateLimiting("auth")] @@ -33,36 +39,43 @@ public sealed partial class OAuthController public async Task OAuthHandOff( [FromRoute] string provider, [FromServices] IOAuthConnectionService connectionService, - [FromServices] IOptions frontendOptions) + [FromServices] ISessionService sessionService, + [FromServices] IOptions frontendOptions, + CancellationToken cancellationToken) { var result = await ValidateOAuthFlowAsync(provider); if (!result.TryPickT0(out var auth, out var response)) { return response; } - - // External subject is required to resolve/link. - var externalId = auth.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(externalId)) - { - await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.FlowMissingData); - } - var connection = await connectionService.GetByProviderExternalIdAsync(provider, externalId); + var connection = await connectionService.GetByProviderExternalIdAsync(provider, auth.ExternalAccountId); switch (auth.Flow) { case OAuthFlow.LoginOrCreate: { - // TODO: Fail if currently logged in + if (IsOpenShockUserAuthenticated()) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return GetBadUrl("mustBeAuthenticated"); + } if (connection is not null) { - // Log In - // TODO: Initialize authentication session + var cookieDomainToUse = frontendOptions.Value.CookieDomain.Split(',').FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase)); + if (cookieDomainToUse is null) return Problem(LoginError.InvalidDomain); + + var session = await sessionService.CreateSessionAsync( + connection.UserId, + HttpContext.GetUserAgent(), + HttpContext.GetRemoteIP().ToString() + ); + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Redirect("/"); + HttpContext.SetSessionKeyCookie(session.Token, "." + cookieDomainToUse); + + return Redirect("/"); // TODO: Make this go to frontend } // Create @@ -76,6 +89,12 @@ public async Task OAuthHandOff( case OAuthFlow.Link: { + if (!IsOpenShockUserAuthenticated()) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return GetBadUrl("cannotBeAuthenticated"); + } + if (connection is not null) { // Connection already exists, FAILURE @@ -98,5 +117,18 @@ public async Task OAuthHandOff( await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return Problem(OAuthError.UnsupportedFlow); } + + RedirectResult GetBadUrl(string errorType) + { + var frontendUrl = new UriBuilder(frontendOptions.Value.BaseUrl) + { + Path = "some/bad/url", + Query = new QueryBuilder + { + { "error", errorType } + }.ToString() + }; + return Redirect(frontendUrl.Uri.ToString()); + } } } \ No newline at end of file diff --git a/API/Controller/OAuth/_ApiController.cs b/API/Controller/OAuth/_ApiController.cs index d2aaae38..0f2426f9 100644 --- a/API/Controller/OAuth/_ApiController.cs +++ b/API/Controller/OAuth/_ApiController.cs @@ -1,4 +1,5 @@ -using Asp.Versioning; +using System.Security.Claims; +using Asp.Versioning; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using OneOf; @@ -65,10 +66,19 @@ private async Task> ValidateOAuthFlow await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return Problem(OAuthError.FlowMismatch); } + + // External subject is required to resolve/link. + var externalId = auth.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(externalId)) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return Problem(OAuthError.FlowMissingData); + } return new ValidatedFlowContext( Provider: expectedProvider, Flow: flow, + ExternalAccountId: externalId, Principal: auth.Principal, Properties: auth.Properties ); diff --git a/API/OAuth/ValidatedFlowContext.cs b/API/OAuth/ValidatedFlowContext.cs index 27f9e752..b8ae9ec0 100644 --- a/API/OAuth/ValidatedFlowContext.cs +++ b/API/OAuth/ValidatedFlowContext.cs @@ -3,4 +3,4 @@ namespace OpenShock.API.OAuth; -public sealed record ValidatedFlowContext(string Provider, OAuthFlow Flow, ClaimsPrincipal Principal, AuthenticationProperties Properties); \ No newline at end of file +public sealed record ValidatedFlowContext(string Provider, OAuthFlow Flow, string ExternalAccountId, ClaimsPrincipal Principal, AuthenticationProperties Properties); \ No newline at end of file diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 2ec98d47..e0464979 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -314,12 +314,12 @@ public async Task - public async Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password, - LoginContext loginContext, CancellationToken cancellationToken = default) + /// + public async Task> GetAccountByCredentialsAsync(string usernameOrEmail, string password, CancellationToken cancellationToken) { var lowercaseUsernameOrEmail = usernameOrEmail.ToLowerInvariant(); var user = await _db.Users + .AsNoTracking() .Include(u => u.UserDeactivation) .FirstOrDefaultAsync(x => x.Email == lowercaseUsernameOrEmail || x.Name == lowercaseUsernameOrEmail, cancellationToken); if (user is null) @@ -328,6 +328,12 @@ public async Task diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 3c9b66f1..9352d369 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -56,15 +56,14 @@ public interface IAccountService public Task> DeleteAccountAsync(Guid executingUserId, Guid userId); /// - /// Login a user into his user session + /// Get a user by credentials /// /// /// - /// /// /// - public Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password, LoginContext loginContext, CancellationToken cancellationToken = default); - + public Task> GetAccountByCredentialsAsync(string usernameOrEmail, string password, CancellationToken cancellationToken = default); + /// /// Check if a password reset request exists and the secret is valid /// diff --git a/Common/Errors/LoginError.cs b/Common/Errors/LoginError.cs index 8185ce98..d6f8d10b 100644 --- a/Common/Errors/LoginError.cs +++ b/Common/Errors/LoginError.cs @@ -5,6 +5,6 @@ namespace OpenShock.Common.Errors; public static class LoginError { - public static OpenShockProblem InvalidCredentials => new OpenShockProblem("Login.InvalidCredentials", "Invalid credentials provided", HttpStatusCode.Unauthorized); + public static OpenShockProblem InvalidCredentials => new OpenShockProblem("Login.InvalidCredentials", "Invalid username or password", HttpStatusCode.Unauthorized); public static OpenShockProblem InvalidDomain => new OpenShockProblem("Login.InvalidDomain", "The url you are requesting a login from is not whitelisted", HttpStatusCode.Forbidden); } \ No newline at end of file diff --git a/Common/OpenShockControllerBase.cs b/Common/OpenShockControllerBase.cs index 658cee34..682d0a5d 100644 --- a/Common/OpenShockControllerBase.cs +++ b/Common/OpenShockControllerBase.cs @@ -1,5 +1,8 @@ -using System.Net.Mime; +using System.Diagnostics.CodeAnalysis; +using System.Net.Mime; +using System.Security.Claims; using Microsoft.AspNetCore.Mvc; +using OpenShock.Common.Authentication; using OpenShock.Common.Models; using OpenShock.Common.Problems; @@ -9,23 +12,91 @@ namespace OpenShock.Common; public class OpenShockControllerBase : ControllerBase { [NonAction] - public ObjectResult Problem(OpenShockProblem problem) => problem.ToObjectResult(HttpContext); + protected ObjectResult Problem(OpenShockProblem problem) => problem.ToObjectResult(HttpContext); [NonAction] - public OkObjectResult LegacyDataOk(T data) + protected OkObjectResult LegacyDataOk(T data) { return Ok(new LegacyDataResponse(data)); } [NonAction] - public CreatedResult LegacyDataCreated(string? uri, T data) + protected CreatedResult LegacyDataCreated(string? uri, T data) { return Created(uri, new LegacyDataResponse(data)); } [NonAction] - public OkObjectResult LegacyEmptyOk(string message = "") + protected OkObjectResult LegacyEmptyOk(string message = "") { return Ok(new LegacyEmptyResponse(message)); } + + [NonAction] + protected bool TryGetOpenShockUserIdentity([NotNullWhen(true)] out ClaimsIdentity? identity) + { + foreach (var ident in User.Identities) + { + if (!ident.IsAuthenticated) continue; + + foreach (var claim in ident.Claims) + { + if (claim is + { + Type: ClaimTypes.AuthenticationMethod, + Value: OpenShockAuthSchemes.UserSessionCookie + }) + { + identity = ident; + return true; + } + } + } + + identity = null; + return false; + } + + [NonAction] + protected bool IsOpenShockUserAuthenticated() + { + foreach (var ident in User.Identities) + { + if (!ident.IsAuthenticated) continue; + + foreach (var claim in ident.Claims) + { + if (claim is + { + Type: ClaimTypes.AuthenticationMethod, + Value: OpenShockAuthSchemes.UserSessionCookie + }) + { + return true; + } + } + } + + return false; + } + + [NonAction] + protected bool TryGetAuthenticatedOpenShockUserId(out Guid userId) + { + if (!TryGetOpenShockUserIdentity(out var identity)) + { + userId = Guid.Empty; + return false; + } + + var idStr = identity.Claims.SingleOrDefault(x => x.Type == ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(idStr)) + { + userId = Guid.Empty; + return false; + } + + return Guid.TryParse(idStr, out userId); + + } } \ No newline at end of file From 9f51b1b8ef106968322e535f9020d80336ce9387 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 9 Sep 2025 14:27:39 +0200 Subject: [PATCH 47/63] Unify cookie logic --- API/Controller/Account/Login.cs | 7 +++---- API/Controller/Account/LoginV2.cs | 7 ++++--- API/Controller/Account/Logout.cs | 19 ++----------------- API/Controller/OAuth/Finalize.cs | 3 --- API/Controller/OAuth/GetData.cs | 2 -- API/Controller/OAuth/HandOff.cs | 7 +++---- API/Controller/OAuth/ListProviders.cs | 1 - Common/OpenShockControllerBase.cs | 22 ++++++++++++++++++++++ 8 files changed, 34 insertions(+), 34 deletions(-) diff --git a/API/Controller/Account/Login.cs b/API/Controller/Account/Login.cs index 5432968e..26af6e7b 100644 --- a/API/Controller/Account/Login.cs +++ b/API/Controller/Account/Login.cs @@ -30,11 +30,10 @@ public sealed partial class AccountController public async Task Login( [FromBody] Login body, [FromServices] ISessionService sessionService, - [FromServices] IOptions options, CancellationToken cancellationToken) { - var cookieDomainToUse = options.Value.CookieDomain.Split(',').FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase)); - if (cookieDomainToUse is null) return Problem(LoginError.InvalidDomain); + var cookieDomain = GetCurrentCookieDomain(); + if (cookieDomain is null) return Problem(LoginError.InvalidDomain); var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.Email, body.Password, cancellationToken); if (!getAccountResult.TryPickT0(out var account, out var errors)) @@ -48,7 +47,7 @@ public async Task Login( } var session = await sessionService.CreateSessionAsync(account.Id, HttpContext.GetUserAgent(), HttpContext.GetRemoteIP().ToString()); - HttpContext.SetSessionKeyCookie(session.Token, "." + cookieDomainToUse); + HttpContext.SetSessionKeyCookie(session.Token, "." + cookieDomain); return LegacyEmptyOk("Successfully logged in"); } } \ No newline at end of file diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index 5b7e6d2a..1be197e2 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -3,6 +3,7 @@ using System.Net; using System.Net.Mime; using Asp.Versioning; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Services.Account; using OpenShock.Common.Errors; @@ -37,8 +38,8 @@ public async Task LoginV2( [FromServices] IOptions options, CancellationToken cancellationToken) { - var cookieDomainToUse = options.Value.CookieDomain.Split(',').FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase)); - if (cookieDomainToUse is null) return Problem(LoginError.InvalidDomain); + var cookieDomain = GetCurrentCookieDomain(); + if (cookieDomain is null) return Problem(LoginError.InvalidDomain); var remoteIp = HttpContext.GetRemoteIP(); @@ -63,7 +64,7 @@ public async Task LoginV2( } var session = await sessionService.CreateSessionAsync(account.Id, HttpContext.GetUserAgent(), remoteIp.ToString()); - HttpContext.SetSessionKeyCookie(session.Token, "." + cookieDomainToUse); + HttpContext.SetSessionKeyCookie(session.Token, "." + cookieDomain); return Ok(LoginV2OkResponse.FromUser(account)); } } \ No newline at end of file diff --git a/API/Controller/Account/Logout.cs b/API/Controller/Account/Logout.cs index d689299d..b27e0efe 100644 --- a/API/Controller/Account/Logout.cs +++ b/API/Controller/Account/Logout.cs @@ -12,12 +12,8 @@ public sealed partial class AccountController [HttpPost("logout")] [ProducesResponseType(StatusCodes.Status200OK)] [MapToApiVersion("1")] - public async Task Logout( - [FromServices] ISessionService sessionService, - [FromServices] IOptions options) + public async Task Logout([FromServices] ISessionService sessionService) { - var config = options.Value; - // Remove session if valid if (HttpContext.TryGetUserSessionToken(out var sessionToken)) { @@ -25,18 +21,7 @@ public async Task Logout( } // Make sure cookie is removed, no matter if authenticated or not - var cookieDomainToUse = config.CookieDomain.Split(',').FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase)); - if (cookieDomainToUse is not null) - { - HttpContext.RemoveSessionKeyCookie("." + cookieDomainToUse); - } - else // Fallback to all domains - { - foreach (var domain in config.CookieDomain.Split(',')) - { - HttpContext.RemoveSessionKeyCookie("." + domain); - } - } + RemoveSessionKeyCookie(); // its always a success, logout endpoints should be idempotent return Ok(); diff --git a/API/Controller/OAuth/Finalize.cs b/API/Controller/OAuth/Finalize.cs index a294e2d9..d16fff77 100644 --- a/API/Controller/OAuth/Finalize.cs +++ b/API/Controller/OAuth/Finalize.cs @@ -5,11 +5,8 @@ using OpenShock.API.Models.Response; using OpenShock.API.Services.Account; using OpenShock.API.Services.OAuthConnection; -using OpenShock.Common.Authentication.Services; using OpenShock.Common.Errors; -using OpenShock.Common.OpenShockDb; using OpenShock.Common.Problems; -using OpenShock.Common.Utils; using System.Net.Mime; using System.Security.Claims; using OpenShock.API.OAuth; diff --git a/API/Controller/OAuth/GetData.cs b/API/Controller/OAuth/GetData.cs index 33c895ed..e953b0f5 100644 --- a/API/Controller/OAuth/GetData.cs +++ b/API/Controller/OAuth/GetData.cs @@ -2,8 +2,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Models.Response; -using OpenShock.Common.Authentication; -using OpenShock.Common.Errors; using OpenShock.Common.Problems; using System.Net.Mime; using System.Security.Claims; diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index 53085170..ac9e864b 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -6,7 +6,6 @@ using OpenShock.Common.Options; using OpenShock.Common.Problems; using System.Net.Mime; -using System.Security.Claims; using Microsoft.AspNetCore.Http.Extensions; using OpenShock.API.OAuth; using OpenShock.Common.Errors; @@ -63,8 +62,8 @@ public async Task OAuthHandOff( if (connection is not null) { - var cookieDomainToUse = frontendOptions.Value.CookieDomain.Split(',').FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase)); - if (cookieDomainToUse is null) return Problem(LoginError.InvalidDomain); + var cookieDomain = GetCurrentCookieDomain(); + if (cookieDomain is null) return Problem(LoginError.InvalidDomain); var session = await sessionService.CreateSessionAsync( connection.UserId, @@ -73,7 +72,7 @@ public async Task OAuthHandOff( ); await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - HttpContext.SetSessionKeyCookie(session.Token, "." + cookieDomainToUse); + HttpContext.SetSessionKeyCookie(session.Token, "." + cookieDomain); return Redirect("/"); // TODO: Make this go to frontend } diff --git a/API/Controller/OAuth/ListProviders.cs b/API/Controller/OAuth/ListProviders.cs index 6f2dba72..be0631cf 100644 --- a/API/Controller/OAuth/ListProviders.cs +++ b/API/Controller/OAuth/ListProviders.cs @@ -1,4 +1,3 @@ -using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using OpenShock.API.OAuth; diff --git a/Common/OpenShockControllerBase.cs b/Common/OpenShockControllerBase.cs index 682d0a5d..ed9951c6 100644 --- a/Common/OpenShockControllerBase.cs +++ b/Common/OpenShockControllerBase.cs @@ -2,9 +2,12 @@ using System.Net.Mime; using System.Security.Claims; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using OpenShock.Common.Authentication; using OpenShock.Common.Models; +using OpenShock.Common.Options; using OpenShock.Common.Problems; +using OpenShock.Common.Utils; namespace OpenShock.Common; @@ -31,6 +34,25 @@ protected OkObjectResult LegacyEmptyOk(string message = "") { return Ok(new LegacyEmptyResponse(message)); } + + [NonAction] + protected string? GetCurrentCookieDomain() + { + var options = HttpContext.RequestServices.GetRequiredService>().Value; + + return options.CookieDomain.Split(',').FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase)); + } + + [NonAction] + protected void RemoveSessionKeyCookie() + { + var options = HttpContext.RequestServices.GetRequiredService>().Value; + + foreach (var domain in options.CookieDomain.Split(',')) + { + HttpContext.RemoveSessionKeyCookie("." + domain); + } + } [NonAction] protected bool TryGetOpenShockUserIdentity([NotNullWhen(true)] out ClaimsIdentity? identity) From bc34d97cb994b82934b248b753e1d75f64907a98 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 10 Sep 2025 13:35:16 +0200 Subject: [PATCH 48/63] More work on stuff --- API/Controller/OAuth/Authorize.cs | 3 +-- API/Controller/OAuth/GetData.cs | 27 +++++--------------------- API/Controller/OAuth/HandOff.cs | 12 ++++++------ API/Controller/OAuth/_ApiController.cs | 9 +++++++-- 4 files changed, 19 insertions(+), 32 deletions(-) diff --git a/API/Controller/OAuth/Authorize.cs b/API/Controller/OAuth/Authorize.cs index ee2e1b84..989f5e33 100644 --- a/API/Controller/OAuth/Authorize.cs +++ b/API/Controller/OAuth/Authorize.cs @@ -28,8 +28,7 @@ public async Task OAuthAuthorize([FromRoute] string provider) if (!await _schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.UnsupportedProvider); - if (User.Identities.Any(ident => string.Equals(ident.AuthenticationType, OpenShockAuthSchemes.UserSessionCookie, - StringComparison.InvariantCultureIgnoreCase))) + if (IsOpenShockUserAuthenticated()) { return Problem(OAuthError.AnonymousOnlyEndpoint); } diff --git a/API/Controller/OAuth/GetData.cs b/API/Controller/OAuth/GetData.cs index e953b0f5..c2f7f665 100644 --- a/API/Controller/OAuth/GetData.cs +++ b/API/Controller/OAuth/GetData.cs @@ -28,35 +28,18 @@ public sealed partial class OAuthController [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] public async Task OAuthGetData([FromRoute] string provider) { - if (!await _schemeProvider.IsSupportedOAuthScheme(provider)) - return Problem(OAuthError.UnsupportedProvider); - - // Temp external principal (set by OAuth handler with SignInScheme=OAuthFlowScheme, SaveTokens=true) - var auth = await HttpContext.AuthenticateAsync(OAuthConstants.FlowScheme); - if (!auth.Succeeded || auth.Principal is null) - return Problem(OAuthError.FlowStateNotFound); - - // Read identifiers from claims (no props.Items) - var providerClaim = auth.Principal.Identity?.AuthenticationType; - - if (string.IsNullOrWhiteSpace(providerClaim)) - { - await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.FlowStateNotFound); - } - - if (!string.Equals(providerClaim, provider, StringComparison.InvariantCultureIgnoreCase)) + var result = await ValidateOAuthFlowAsync(provider); + if (!result.TryPickT0(out var auth, out var response)) { - await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.ProviderMismatch); + return response; } return Ok(new OAuthDataResponse { - Provider = providerClaim, + Provider = auth.Provider, Email = auth.Principal.FindFirst(ClaimTypes.Email)?.Value, DisplayName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value, - ExpiresAt = auth.Ticket.Properties.ExpiresUtc!.Value.UtcDateTime + ExpiresAt = auth.Properties.ExpiresUtc!.Value.UtcDateTime }); } } diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index ac9e864b..b978dee2 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -57,13 +57,13 @@ public async Task OAuthHandOff( if (IsOpenShockUserAuthenticated()) { await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return GetBadUrl("mustBeAuthenticated"); + return GetBadUrl("mustBeAnonymous"); } if (connection is not null) { var cookieDomain = GetCurrentCookieDomain(); - if (cookieDomain is null) return Problem(LoginError.InvalidDomain); + if (cookieDomain is null) return GetBadUrl("internalError"); var session = await sessionService.CreateSessionAsync( connection.UserId, @@ -91,7 +91,7 @@ public async Task OAuthHandOff( if (!IsOpenShockUserAuthenticated()) { await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return GetBadUrl("cannotBeAuthenticated"); + return GetBadUrl("mustBeAuthenticated"); } if (connection is not null) @@ -100,7 +100,7 @@ public async Task OAuthHandOff( // TODO: Check if the connection is connected to our account with same externalId (AlreadyLinked), different externalId (AlreadyExists), or to another account (LinkedToAnotherAccount) await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.ExternalAlreadyLinked); + return GetBadUrl("alreadyLinked"); } // Link connection to account @@ -114,14 +114,14 @@ public async Task OAuthHandOff( default: await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.UnsupportedFlow); + return GetBadUrl("internalError"); } RedirectResult GetBadUrl(string errorType) { var frontendUrl = new UriBuilder(frontendOptions.Value.BaseUrl) { - Path = "some/bad/url", + Path = "oauth/error", Query = new QueryBuilder { { "error", errorType } diff --git a/API/Controller/OAuth/_ApiController.cs b/API/Controller/OAuth/_ApiController.cs index 0f2426f9..dd6ed184 100644 --- a/API/Controller/OAuth/_ApiController.cs +++ b/API/Controller/OAuth/_ApiController.cs @@ -45,8 +45,13 @@ private async Task> ValidateOAuthFlow return Problem(OAuthError.FlowStateNotFound); // 3) scheme/provider check — prefer the ticket's scheme over a magic Item - var actualScheme = auth.Properties.Items[".AuthScheme"]; - if (actualScheme is null || !string.Equals(actualScheme, expectedProvider, StringComparison.OrdinalIgnoreCase)) + if (!auth.Properties.Items.TryGetValue(".AuthScheme", out var actualScheme) || string.IsNullOrEmpty(actualScheme)) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return Problem(OAuthError.FlowStateNotFound); + } + + if (actualScheme != expectedProvider) { await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return Problem(OAuthError.ProviderMismatch); From b0f1c034b38503aac9188bc37ae632bcb3069225 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Wed, 10 Sep 2025 20:16:09 +0200 Subject: [PATCH 49/63] Some improvements to domain matching --- API/Controller/Account/Login.cs | 2 +- API/Controller/Account/LoginV2.cs | 3 +- API/Controller/OAuth/HandOff.cs | 2 +- Common.Tests/Utils/DomainValidator.cs | 311 ++++++++++++++++++++++++++ Common/OpenShockControllerBase.cs | 39 +++- Common/Utils/AuthUtils.cs | 22 -- Common/Utils/DomainValidator.cs | 175 +++++++++++++++ 7 files changed, 522 insertions(+), 32 deletions(-) create mode 100644 Common.Tests/Utils/DomainValidator.cs create mode 100644 Common/Utils/DomainValidator.cs diff --git a/API/Controller/Account/Login.cs b/API/Controller/Account/Login.cs index 26af6e7b..d7e91efd 100644 --- a/API/Controller/Account/Login.cs +++ b/API/Controller/Account/Login.cs @@ -47,7 +47,7 @@ public async Task Login( } var session = await sessionService.CreateSessionAsync(account.Id, HttpContext.GetUserAgent(), HttpContext.GetRemoteIP().ToString()); - HttpContext.SetSessionKeyCookie(session.Token, "." + cookieDomain); + SetSessionCookie(session.Token, cookieDomain); return LegacyEmptyOk("Successfully logged in"); } } \ No newline at end of file diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index 1be197e2..7587d61d 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -35,7 +35,6 @@ public async Task LoginV2( [FromBody] LoginV2 body, [FromServices] ICloudflareTurnstileService turnstileService, [FromServices] ISessionService sessionService, - [FromServices] IOptions options, CancellationToken cancellationToken) { var cookieDomain = GetCurrentCookieDomain(); @@ -64,7 +63,7 @@ public async Task LoginV2( } var session = await sessionService.CreateSessionAsync(account.Id, HttpContext.GetUserAgent(), remoteIp.ToString()); - HttpContext.SetSessionKeyCookie(session.Token, "." + cookieDomain); + SetSessionCookie(session.Token, "." + cookieDomain); return Ok(LoginV2OkResponse.FromUser(account)); } } \ No newline at end of file diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index b978dee2..716e7238 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -72,7 +72,7 @@ public async Task OAuthHandOff( ); await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - HttpContext.SetSessionKeyCookie(session.Token, "." + cookieDomain); + SetSessionCookie(session.Token, "." + cookieDomain); return Redirect("/"); // TODO: Make this go to frontend } diff --git a/Common.Tests/Utils/DomainValidator.cs b/Common.Tests/Utils/DomainValidator.cs new file mode 100644 index 00000000..c8931e55 --- /dev/null +++ b/Common.Tests/Utils/DomainValidator.cs @@ -0,0 +1,311 @@ +using OpenShock.Common.Utils; + +namespace OpenShock.Common.Tests.Utils; + +public class DomainValidatorTests +{ + [Test] + public async Task NullString_ReturnsFalse() + { + // Act + var result = DomainValidator.IsValidDomain(null); + + // Assert + await Assert.That(result).IsFalse(); + } + + [Test] + public async Task EmptyString_ReturnsFalse() + { + // Act + var result = DomainValidator.IsValidDomain(""); + + // Assert + await Assert.That(result).IsFalse(); + } + + [Test] + public async Task NoDot_ReturnsFalse() + { + // Act + var result = DomainValidator.IsValidDomain("example"); + + // Assert + await Assert.That(result).IsFalse(); + } + + [Test] + public async Task LeadingDot_ReturnsFalse() + { + // Act + var result = DomainValidator.IsValidDomain(".example.com"); + + // Assert + await Assert.That(result).IsFalse(); + } + + [Test] + public async Task TrailingDot_ReturnsFalse() + { + // Act + var result = DomainValidator.IsValidDomain("example.com."); + + // Assert + await Assert.That(result).IsFalse(); + } + + [Test] + public async Task ConsecutiveDots_ReturnsFalse() + { + // Act + var result = DomainValidator.IsValidDomain("a..b.com"); + + // Assert + await Assert.That(result).IsFalse(); + } + + [Test] + public async Task LabelTooLong_ReturnsFalse() + { + // Arrange: label with 64 chars (invalid), then ".com" + var tooLong = new string('a', 64) + ".com"; + + // Act + var result = DomainValidator.IsValidDomain(tooLong); + + // Assert + await Assert.That(result).IsFalse(); + } + + [Test] + public async Task LabelStartingHyphen_ReturnsFalse() + { + // Act + var result = DomainValidator.IsValidDomain("-abc.com"); + + // Assert + await Assert.That(result).IsFalse(); + } + + [Test] + public async Task LabelEndingHyphen_ReturnsFalse() + { + // Act + var result = DomainValidator.IsValidDomain("abc-.com"); + + // Assert + await Assert.That(result).IsFalse(); + } + + [Test] + public async Task InvalidCharacters_ReturnsFalse() + { + // Act + var r1 = DomainValidator.IsValidDomain("exa_mple.com"); + var r2 = DomainValidator.IsValidDomain("examp le.com"); + var r3 = DomainValidator.IsValidDomain("exam!ple.com"); + + // Assert + await Assert.That(r1).IsFalse(); + await Assert.That(r2).IsFalse(); + await Assert.That(r3).IsFalse(); + } + + [Test] + public async Task TotalLengthOver253_ReturnsFalse() + { + // Arrange: construct >253 chars with dots + // "a." repeated 200 times yields 400 chars; ensure clearly >253. + var longHost = string.Join('.', Enumerable.Repeat("a", 130)); // 129 dots + 130 a's ~ 259 chars + + // Act + var result = DomainValidator.IsValidDomain(longHost); + + // Assert + await Assert.That(result).IsFalse(); + } + + [Test] + public async Task ValidAsciiLDH_ReturnsTrue() + { + // Act + var r1 = DomainValidator.IsValidDomain("example.com"); + var r2 = DomainValidator.IsValidDomain("a.b"); + var r3 = DomainValidator.IsValidDomain("foo-bar.baz0"); + var r4 = DomainValidator.IsValidDomain("xn--d1acufc.xn--p1ai"); // Punycode + + // Assert + await Assert.That(r1).IsTrue(); + await Assert.That(r2).IsTrue(); + await Assert.That(r3).IsTrue(); + await Assert.That(r4).IsTrue(); + } + + [Test] + public async Task HostMatchesCookieDomain_ExactMatch_ReturnsTrue() + { + // Act + var result = DomainValidator.HostMatchesCookieDomain("example.com", "example.com"); + + // Assert + await Assert.That(result).IsTrue(); + } + + [Test] + public async Task HostMatchesCookieDomain_SuffixLabelMatch_ReturnsTrue() + { + // Act + var result = DomainValidator.HostMatchesCookieDomain("shop.foo.example.com".AsSpan(), "example.com".AsSpan()); + + // Assert + await Assert.That(result).IsTrue(); + } + + [Test] + public async Task HostMatchesCookieDomain_PartialSuffixNoBoundary_ReturnsFalse() + { + // Act + var result = DomainValidator.HostMatchesCookieDomain("badexample.com", "example.com"); + + // Assert + await Assert.That(result).IsFalse(); + } + + [Test] + public async Task HostMatchesCookieDomain_CookieLongerThanHost_ReturnsFalse() + { + // Act + var result = DomainValidator.HostMatchesCookieDomain("example.com", "foo.example.com"); + + // Assert + await Assert.That(result).IsFalse(); + } + + [Test] + public async Task HostMatchesCookieDomain_InvalidHost_ReturnsFalse() + { + // Act + var result = DomainValidator.HostMatchesCookieDomain("example", "example.com"); // host without dot is invalid + + // Assert + await Assert.That(result).IsFalse(); + } + + [Test] + public async Task HostMatchesCookieDomain_CaseInsensitive_ReturnsTrue() + { + // Act + var result = DomainValidator.HostMatchesCookieDomain("SHOP.Foo.Example.COM", "example.com"); + + // Assert + await Assert.That(result).IsTrue(); + } + + // ---- GetBestMatchingCookieDomain ---- + + [Test] + public async Task GetBestMatchingCookieDomain_PicksMostSpecific() + { + // Arrange + var list = "example.com,foo.example.com,bar.com"; + + // Act + var best = DomainValidator.GetBestMatchingCookieDomain("shop.foo.example.com", list); + + // Assert + await Assert.That(best).IsEqualTo("foo.example.com"); + } + + [Test] + public async Task GetBestMatchingCookieDomain_ExactMatchBeatsShorterSuffix() + { + // Arrange + var list = "example.com,shop.foo.example.com"; + + // Act + var best = DomainValidator.GetBestMatchingCookieDomain("shop.foo.example.com", list); + + // Assert + await Assert.That(best).IsEqualTo("shop.foo.example.com"); + } + + [Test] + public async Task GetBestMatchingCookieDomain_EmptySegmentsIgnored() + { + // Arrange + var list = ",,example.com,,foo.example.com,,"; + + // Act + var best = DomainValidator.GetBestMatchingCookieDomain("shop.foo.example.com", list); + + // Assert + await Assert.That(best).IsEqualTo("foo.example.com"); + } + + [Test] + public async Task GetBestMatchingCookieDomain_InvalidDomainsIgnored() + { + // Arrange: includes invalid ".example.com" and "exa_mple.com" + var list = ".example.com,exa_mple.com,example.com"; + + // Act + var best = DomainValidator.GetBestMatchingCookieDomain("shop.example.com", list); + + // Assert + await Assert.That(best).IsEqualTo("example.com"); + } + + [Test] + public async Task GetBestMatchingCookieDomain_NoMatch_ReturnsNull() + { + // Arrange + var list = "foo.com,bar.net"; + + // Act + var best = DomainValidator.GetBestMatchingCookieDomain("example.com", list); + + // Assert + await Assert.That(best).IsNull(); + } + + [Test] + public async Task GetBestMatchingCookieDomain_HostInvalid_ReturnsNull() + { + // Arrange + var list = "example.com,foo.example.com"; + + // Act + var best = DomainValidator.GetBestMatchingCookieDomain("example", list); + + // Assert + await Assert.That(best).IsNull(); + } + + [Test] + public async Task GetBestMatchingCookieDomain_WhitespaceNotTrimmedInCurrentImplementation_SkipsEntry() + { + // Arrange: first entry has spaces (invalid for current impl), second is valid and more specific. + var list = " example.com ,foo.example.com"; + + // Use a host that matches *both* example.com and foo.example.com + var best = DomainValidator.GetBestMatchingCookieDomain("shop.foo.example.com", list); + + // Assert: because the first is invalid (whitespace not trimmed), the best is foo.example.com + await Assert.That(best).IsEqualTo("foo.example.com"); + } + + // ---- Additional boundary checks via HostMatchesCookieDomain ---- + + [Test] + public async Task HostBoundary_ChecksLabelBoundary() + { + // "ample.com" is substring but not a label-suffix of "example.com" + var r1 = DomainValidator.HostMatchesCookieDomain("example.com", "ample.com"); + var r2 = DomainValidator.HostMatchesCookieDomain("xample.com", "ample.com"); + var r3 = DomainValidator.HostMatchesCookieDomain("fooample.com", "ample.com"); + + await Assert.That(r1).IsFalse(); + await Assert.That(r2).IsFalse(); + await Assert.That(r3).IsFalse(); + } +} diff --git a/Common/OpenShockControllerBase.cs b/Common/OpenShockControllerBase.cs index ed9951c6..7ebd2df9 100644 --- a/Common/OpenShockControllerBase.cs +++ b/Common/OpenShockControllerBase.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using OpenShock.Common.Authentication; +using OpenShock.Common.Constants; using OpenShock.Common.Models; using OpenShock.Common.Options; using OpenShock.Common.Problems; @@ -35,22 +36,48 @@ protected OkObjectResult LegacyEmptyOk(string message = "") return Ok(new LegacyEmptyResponse(message)); } + [NonAction] + private string GetCookieDomains() + { + return HttpContext.RequestServices.GetRequiredService>().Value.CookieDomain; + } + [NonAction] protected string? GetCurrentCookieDomain() { - var options = HttpContext.RequestServices.GetRequiredService>().Value; + return DomainValidator.GetBestMatchingCookieDomain(HttpContext.Request.Host.Host, GetCookieDomains()); + } + + private static CookieOptions GetCookieOptions(string domain, TimeSpan lifetime) + { + return new CookieOptions + { + Expires = DateTimeOffset.UtcNow.Add(lifetime), + Secure = true, + HttpOnly = true, + SameSite = SameSiteMode.Strict, + Domain = domain + }; + } - return options.CookieDomain.Split(',').FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase)); + [NonAction] + protected void SetSessionCookie(string sessionKey, string domain) + { + HttpContext.Response.Cookies.Append(AuthConstants.UserSessionCookieName, sessionKey, GetCookieOptions(domain, Duration.LoginSessionLifetime)); } [NonAction] protected void RemoveSessionKeyCookie() { - var options = HttpContext.RequestServices.GetRequiredService>().Value; - - foreach (var domain in options.CookieDomain.Split(',')) + var domains = GetCookieDomains().AsSpan(); + foreach (var range in domains.Split(',')) { - HttpContext.RemoveSessionKeyCookie("." + domain); + var domain = domains[range]; + if (!DomainValidator.IsValidDomain(domain)) continue; + + var domainStr = "." + domain.ToString(); + + HttpContext.Response.Cookies.Append(AuthConstants.UserSessionCookieName, string.Empty, GetCookieOptions(domainStr, TimeSpan.FromDays(-1))); } } diff --git a/Common/Utils/AuthUtils.cs b/Common/Utils/AuthUtils.cs index 4c323389..f3e57559 100644 --- a/Common/Utils/AuthUtils.cs +++ b/Common/Utils/AuthUtils.cs @@ -16,28 +16,6 @@ public static class AuthUtils "Device-Token" ]; - private static CookieOptions GetCookieOptions(string domain, TimeSpan lifetime) - { - return new CookieOptions - { - Expires = new DateTimeOffset(DateTime.UtcNow.Add(lifetime)), - Secure = true, - HttpOnly = true, - SameSite = SameSiteMode.Strict, - Domain = domain - }; - } - - public static void SetSessionKeyCookie(this HttpContext context, string sessionKey, string domain) - { - context.Response.Cookies.Append(AuthConstants.UserSessionCookieName, sessionKey, GetCookieOptions(domain, Duration.LoginSessionLifetime)); - } - - public static void RemoveSessionKeyCookie(this HttpContext context, string domain) - { - context.Response.Cookies.Append(AuthConstants.UserSessionCookieName, string.Empty, GetCookieOptions(domain, TimeSpan.FromDays(-1))); - } - public static bool TryGetUserSessionToken(this HttpContext context, [NotNullWhen(true)] out string? sessionToken) { if (context.Request.Cookies.TryGetValue(AuthConstants.UserSessionCookieName, out sessionToken) && !string.IsNullOrEmpty(sessionToken)) diff --git a/Common/Utils/DomainValidator.cs b/Common/Utils/DomainValidator.cs new file mode 100644 index 00000000..bd713984 --- /dev/null +++ b/Common/Utils/DomainValidator.cs @@ -0,0 +1,175 @@ +using System.Buffers; +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace OpenShock.Common.Utils; + +public static class DomainValidator +{ + private static readonly SearchValues ValidLabelChars = + SearchValues.Create("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-"); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsValidDomain(string? s) + => s is not null && IsValidDomain(s.AsSpan()); + + public static bool IsValidDomain(ReadOnlySpan s) + { + if (s.Length is 0 or > 253) return false; + if (!ContainsDot(s)) return false; + if (s[0] == '.' || s[^1] == '.') return false; + + for (int i = 1; i < s.Length; i++) + if (s[i] == '.' && s[i - 1] == '.') return false; + + foreach (var range in s.Split('.')) + { + if (!IsValidLabel(s[range])) return false; + } + + return true; + } + + /// + /// Returns true if ends with on a label boundary. + /// Accepts cookie domains with an optional leading '.' (ignored). + /// Both must be valid domains (after normalizing the cookie domain). + /// + public static bool HostMatchesCookieDomain(ReadOnlySpan host, ReadOnlySpan cookieDomain) + { + // Optional: if you expect Unicode, punycode both here (see helper below) + cookieDomain = NormalizeCookieDomain(cookieDomain); + if (cookieDomain.Length == 0) return false; + + if (!IsValidDomain(host) || !IsValidDomain(cookieDomain)) + return false; + + // Optional hook: reject public suffixes (requires PSL) + // if (IsPublicSuffix(cookieDomain)) return false; + + var hostLabels = new ReverseLabelEnumerator(host); + var cookieLabels = new ReverseLabelEnumerator(cookieDomain); + + while (cookieLabels.MoveNext()) + { + if (!hostLabels.MoveNext()) return false; + if (!LabelsEqualIgnoreCase(hostLabels.Current, cookieLabels.Current)) + return false; + } + + // Boundary check + return !hostLabels.HasRemaining || host[hostLabels.Position + 1] == '.'; + } + + /// + /// Picks the most specific matching cookie domain (most labels) from a comma-separated list. + /// Accepts items with an optional leading '.' and optional ASCII whitespace around them. + /// + public static string? GetBestMatchingCookieDomain(string host, ReadOnlySpan cookieDomainList) + { + ReadOnlySpan hostSpan = host.AsSpan(); + if (!IsValidDomain(hostSpan)) return null; + + string? best = null; + int bestLabels = -1; + + foreach (var range in cookieDomainList.Split(',')) + { + var cd = cookieDomainList[range].Trim(); // trim ASCII whitespace + if (cd.Length == 0) continue; + + cd = NormalizeCookieDomain(cd); // strip a single leading '.' + + if (!IsValidDomain(cd)) continue; + // if (IsPublicSuffix(cd)) continue; + + if (!HostMatchesCookieDomain(hostSpan, cd)) continue; + + int labels = cd.Count('.') + 1; + if (labels <= bestLabels) continue; + + bestLabels = labels; + best = cd.ToString(); + } + + return best; + } + + // --- helpers --- + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static ReadOnlySpan NormalizeCookieDomain(ReadOnlySpan cd) + { + // RFC 6265: a leading dot is ignored (".example.com" == "example.com") + if (cd.Length > 0 && cd[0] == '.') + cd = cd[1..]; + return cd; + } + + // If you need IDN support, use this to punycode strings before validation + public static string ToAsciiIdn(string s) => + new IdnMapping { UseStd3AsciiRules = true }.GetAscii(s); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsValidLabel(ReadOnlySpan label) + { + if ((uint)label.Length is 0 or > 63) return false; + if (label[0] == '-' || label[^1] == '-') return false; + return !label.ContainsAnyExcept(ValidLabelChars); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool LabelsEqualIgnoreCase(ReadOnlySpan a, ReadOnlySpan b) + { + if (a.Length != b.Length) return false; + for (int i = 0; i < a.Length; i++) + { + if (ToLowerAscii(a[i]) != ToLowerAscii(b[i])) return false; + } + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static char ToLowerAscii(char c) + => c is >= 'A' and <= 'Z' ? (char)(c + 32) : c; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool ContainsDot(ReadOnlySpan s) + { + foreach (var t in s) + if (t == '.') + return true; + return false; + } +} + +// Your ReverseLabelEnumerator stays as-is +file ref struct ReverseLabelEnumerator +{ + private readonly ReadOnlySpan _span; + + public ReverseLabelEnumerator(ReadOnlySpan span) + { + _span = span; + Position = span.Length - 1; + Current = default; + } + + public ReadOnlySpan Current { get; private set; } + public int Position { get; private set; } + + public bool HasRemaining => Position >= 0; + + public bool MoveNext() + { + if (Position < 0) return false; + + int end = Position; + while (Position >= 0 && _span[Position] != '.') Position--; + + if (end == Position) return false; // empty label + Current = _span.Slice(Position + 1, end - Position); + Position--; // move to char before the dot + return true; + } +} From 9d52bdaef76ac6a2910b5c2587389fbfb157827e Mon Sep 17 00:00:00 2001 From: hhvrc Date: Thu, 11 Sep 2025 12:41:08 +0200 Subject: [PATCH 50/63] Fix some errors --- Common.Tests/Utils/DomainValidator.cs | 311 -------------------------- Common/OpenShockControllerBase.cs | 4 +- Common/Utils/DomainValidator.cs | 175 --------------- 3 files changed, 2 insertions(+), 488 deletions(-) delete mode 100644 Common.Tests/Utils/DomainValidator.cs delete mode 100644 Common/Utils/DomainValidator.cs diff --git a/Common.Tests/Utils/DomainValidator.cs b/Common.Tests/Utils/DomainValidator.cs deleted file mode 100644 index c8931e55..00000000 --- a/Common.Tests/Utils/DomainValidator.cs +++ /dev/null @@ -1,311 +0,0 @@ -using OpenShock.Common.Utils; - -namespace OpenShock.Common.Tests.Utils; - -public class DomainValidatorTests -{ - [Test] - public async Task NullString_ReturnsFalse() - { - // Act - var result = DomainValidator.IsValidDomain(null); - - // Assert - await Assert.That(result).IsFalse(); - } - - [Test] - public async Task EmptyString_ReturnsFalse() - { - // Act - var result = DomainValidator.IsValidDomain(""); - - // Assert - await Assert.That(result).IsFalse(); - } - - [Test] - public async Task NoDot_ReturnsFalse() - { - // Act - var result = DomainValidator.IsValidDomain("example"); - - // Assert - await Assert.That(result).IsFalse(); - } - - [Test] - public async Task LeadingDot_ReturnsFalse() - { - // Act - var result = DomainValidator.IsValidDomain(".example.com"); - - // Assert - await Assert.That(result).IsFalse(); - } - - [Test] - public async Task TrailingDot_ReturnsFalse() - { - // Act - var result = DomainValidator.IsValidDomain("example.com."); - - // Assert - await Assert.That(result).IsFalse(); - } - - [Test] - public async Task ConsecutiveDots_ReturnsFalse() - { - // Act - var result = DomainValidator.IsValidDomain("a..b.com"); - - // Assert - await Assert.That(result).IsFalse(); - } - - [Test] - public async Task LabelTooLong_ReturnsFalse() - { - // Arrange: label with 64 chars (invalid), then ".com" - var tooLong = new string('a', 64) + ".com"; - - // Act - var result = DomainValidator.IsValidDomain(tooLong); - - // Assert - await Assert.That(result).IsFalse(); - } - - [Test] - public async Task LabelStartingHyphen_ReturnsFalse() - { - // Act - var result = DomainValidator.IsValidDomain("-abc.com"); - - // Assert - await Assert.That(result).IsFalse(); - } - - [Test] - public async Task LabelEndingHyphen_ReturnsFalse() - { - // Act - var result = DomainValidator.IsValidDomain("abc-.com"); - - // Assert - await Assert.That(result).IsFalse(); - } - - [Test] - public async Task InvalidCharacters_ReturnsFalse() - { - // Act - var r1 = DomainValidator.IsValidDomain("exa_mple.com"); - var r2 = DomainValidator.IsValidDomain("examp le.com"); - var r3 = DomainValidator.IsValidDomain("exam!ple.com"); - - // Assert - await Assert.That(r1).IsFalse(); - await Assert.That(r2).IsFalse(); - await Assert.That(r3).IsFalse(); - } - - [Test] - public async Task TotalLengthOver253_ReturnsFalse() - { - // Arrange: construct >253 chars with dots - // "a." repeated 200 times yields 400 chars; ensure clearly >253. - var longHost = string.Join('.', Enumerable.Repeat("a", 130)); // 129 dots + 130 a's ~ 259 chars - - // Act - var result = DomainValidator.IsValidDomain(longHost); - - // Assert - await Assert.That(result).IsFalse(); - } - - [Test] - public async Task ValidAsciiLDH_ReturnsTrue() - { - // Act - var r1 = DomainValidator.IsValidDomain("example.com"); - var r2 = DomainValidator.IsValidDomain("a.b"); - var r3 = DomainValidator.IsValidDomain("foo-bar.baz0"); - var r4 = DomainValidator.IsValidDomain("xn--d1acufc.xn--p1ai"); // Punycode - - // Assert - await Assert.That(r1).IsTrue(); - await Assert.That(r2).IsTrue(); - await Assert.That(r3).IsTrue(); - await Assert.That(r4).IsTrue(); - } - - [Test] - public async Task HostMatchesCookieDomain_ExactMatch_ReturnsTrue() - { - // Act - var result = DomainValidator.HostMatchesCookieDomain("example.com", "example.com"); - - // Assert - await Assert.That(result).IsTrue(); - } - - [Test] - public async Task HostMatchesCookieDomain_SuffixLabelMatch_ReturnsTrue() - { - // Act - var result = DomainValidator.HostMatchesCookieDomain("shop.foo.example.com".AsSpan(), "example.com".AsSpan()); - - // Assert - await Assert.That(result).IsTrue(); - } - - [Test] - public async Task HostMatchesCookieDomain_PartialSuffixNoBoundary_ReturnsFalse() - { - // Act - var result = DomainValidator.HostMatchesCookieDomain("badexample.com", "example.com"); - - // Assert - await Assert.That(result).IsFalse(); - } - - [Test] - public async Task HostMatchesCookieDomain_CookieLongerThanHost_ReturnsFalse() - { - // Act - var result = DomainValidator.HostMatchesCookieDomain("example.com", "foo.example.com"); - - // Assert - await Assert.That(result).IsFalse(); - } - - [Test] - public async Task HostMatchesCookieDomain_InvalidHost_ReturnsFalse() - { - // Act - var result = DomainValidator.HostMatchesCookieDomain("example", "example.com"); // host without dot is invalid - - // Assert - await Assert.That(result).IsFalse(); - } - - [Test] - public async Task HostMatchesCookieDomain_CaseInsensitive_ReturnsTrue() - { - // Act - var result = DomainValidator.HostMatchesCookieDomain("SHOP.Foo.Example.COM", "example.com"); - - // Assert - await Assert.That(result).IsTrue(); - } - - // ---- GetBestMatchingCookieDomain ---- - - [Test] - public async Task GetBestMatchingCookieDomain_PicksMostSpecific() - { - // Arrange - var list = "example.com,foo.example.com,bar.com"; - - // Act - var best = DomainValidator.GetBestMatchingCookieDomain("shop.foo.example.com", list); - - // Assert - await Assert.That(best).IsEqualTo("foo.example.com"); - } - - [Test] - public async Task GetBestMatchingCookieDomain_ExactMatchBeatsShorterSuffix() - { - // Arrange - var list = "example.com,shop.foo.example.com"; - - // Act - var best = DomainValidator.GetBestMatchingCookieDomain("shop.foo.example.com", list); - - // Assert - await Assert.That(best).IsEqualTo("shop.foo.example.com"); - } - - [Test] - public async Task GetBestMatchingCookieDomain_EmptySegmentsIgnored() - { - // Arrange - var list = ",,example.com,,foo.example.com,,"; - - // Act - var best = DomainValidator.GetBestMatchingCookieDomain("shop.foo.example.com", list); - - // Assert - await Assert.That(best).IsEqualTo("foo.example.com"); - } - - [Test] - public async Task GetBestMatchingCookieDomain_InvalidDomainsIgnored() - { - // Arrange: includes invalid ".example.com" and "exa_mple.com" - var list = ".example.com,exa_mple.com,example.com"; - - // Act - var best = DomainValidator.GetBestMatchingCookieDomain("shop.example.com", list); - - // Assert - await Assert.That(best).IsEqualTo("example.com"); - } - - [Test] - public async Task GetBestMatchingCookieDomain_NoMatch_ReturnsNull() - { - // Arrange - var list = "foo.com,bar.net"; - - // Act - var best = DomainValidator.GetBestMatchingCookieDomain("example.com", list); - - // Assert - await Assert.That(best).IsNull(); - } - - [Test] - public async Task GetBestMatchingCookieDomain_HostInvalid_ReturnsNull() - { - // Arrange - var list = "example.com,foo.example.com"; - - // Act - var best = DomainValidator.GetBestMatchingCookieDomain("example", list); - - // Assert - await Assert.That(best).IsNull(); - } - - [Test] - public async Task GetBestMatchingCookieDomain_WhitespaceNotTrimmedInCurrentImplementation_SkipsEntry() - { - // Arrange: first entry has spaces (invalid for current impl), second is valid and more specific. - var list = " example.com ,foo.example.com"; - - // Use a host that matches *both* example.com and foo.example.com - var best = DomainValidator.GetBestMatchingCookieDomain("shop.foo.example.com", list); - - // Assert: because the first is invalid (whitespace not trimmed), the best is foo.example.com - await Assert.That(best).IsEqualTo("foo.example.com"); - } - - // ---- Additional boundary checks via HostMatchesCookieDomain ---- - - [Test] - public async Task HostBoundary_ChecksLabelBoundary() - { - // "ample.com" is substring but not a label-suffix of "example.com" - var r1 = DomainValidator.HostMatchesCookieDomain("example.com", "ample.com"); - var r2 = DomainValidator.HostMatchesCookieDomain("xample.com", "ample.com"); - var r3 = DomainValidator.HostMatchesCookieDomain("fooample.com", "ample.com"); - - await Assert.That(r1).IsFalse(); - await Assert.That(r2).IsFalse(); - await Assert.That(r3).IsFalse(); - } -} diff --git a/Common/OpenShockControllerBase.cs b/Common/OpenShockControllerBase.cs index 7ebd2df9..d5e412a0 100644 --- a/Common/OpenShockControllerBase.cs +++ b/Common/OpenShockControllerBase.cs @@ -45,7 +45,7 @@ private string GetCookieDomains() [NonAction] protected string? GetCurrentCookieDomain() { - return DomainValidator.GetBestMatchingCookieDomain(HttpContext.Request.Host.Host, GetCookieDomains()); + return DomainUtils.GetBestMatchingCookieDomain(HttpContext.Request.Host.Host, GetCookieDomains()); } private static CookieOptions GetCookieOptions(string domain, TimeSpan lifetime) @@ -73,7 +73,7 @@ protected void RemoveSessionKeyCookie() foreach (var range in domains.Split(',')) { var domain = domains[range]; - if (!DomainValidator.IsValidDomain(domain)) continue; + if (!DomainUtils.IsValidDomain(domain)) continue; var domainStr = "." + domain.ToString(); diff --git a/Common/Utils/DomainValidator.cs b/Common/Utils/DomainValidator.cs deleted file mode 100644 index bd713984..00000000 --- a/Common/Utils/DomainValidator.cs +++ /dev/null @@ -1,175 +0,0 @@ -using System.Buffers; -using System.Globalization; -using System.Runtime.CompilerServices; - -namespace OpenShock.Common.Utils; - -public static class DomainValidator -{ - private static readonly SearchValues ValidLabelChars = - SearchValues.Create("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-"); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool IsValidDomain(string? s) - => s is not null && IsValidDomain(s.AsSpan()); - - public static bool IsValidDomain(ReadOnlySpan s) - { - if (s.Length is 0 or > 253) return false; - if (!ContainsDot(s)) return false; - if (s[0] == '.' || s[^1] == '.') return false; - - for (int i = 1; i < s.Length; i++) - if (s[i] == '.' && s[i - 1] == '.') return false; - - foreach (var range in s.Split('.')) - { - if (!IsValidLabel(s[range])) return false; - } - - return true; - } - - /// - /// Returns true if ends with on a label boundary. - /// Accepts cookie domains with an optional leading '.' (ignored). - /// Both must be valid domains (after normalizing the cookie domain). - /// - public static bool HostMatchesCookieDomain(ReadOnlySpan host, ReadOnlySpan cookieDomain) - { - // Optional: if you expect Unicode, punycode both here (see helper below) - cookieDomain = NormalizeCookieDomain(cookieDomain); - if (cookieDomain.Length == 0) return false; - - if (!IsValidDomain(host) || !IsValidDomain(cookieDomain)) - return false; - - // Optional hook: reject public suffixes (requires PSL) - // if (IsPublicSuffix(cookieDomain)) return false; - - var hostLabels = new ReverseLabelEnumerator(host); - var cookieLabels = new ReverseLabelEnumerator(cookieDomain); - - while (cookieLabels.MoveNext()) - { - if (!hostLabels.MoveNext()) return false; - if (!LabelsEqualIgnoreCase(hostLabels.Current, cookieLabels.Current)) - return false; - } - - // Boundary check - return !hostLabels.HasRemaining || host[hostLabels.Position + 1] == '.'; - } - - /// - /// Picks the most specific matching cookie domain (most labels) from a comma-separated list. - /// Accepts items with an optional leading '.' and optional ASCII whitespace around them. - /// - public static string? GetBestMatchingCookieDomain(string host, ReadOnlySpan cookieDomainList) - { - ReadOnlySpan hostSpan = host.AsSpan(); - if (!IsValidDomain(hostSpan)) return null; - - string? best = null; - int bestLabels = -1; - - foreach (var range in cookieDomainList.Split(',')) - { - var cd = cookieDomainList[range].Trim(); // trim ASCII whitespace - if (cd.Length == 0) continue; - - cd = NormalizeCookieDomain(cd); // strip a single leading '.' - - if (!IsValidDomain(cd)) continue; - // if (IsPublicSuffix(cd)) continue; - - if (!HostMatchesCookieDomain(hostSpan, cd)) continue; - - int labels = cd.Count('.') + 1; - if (labels <= bestLabels) continue; - - bestLabels = labels; - best = cd.ToString(); - } - - return best; - } - - // --- helpers --- - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static ReadOnlySpan NormalizeCookieDomain(ReadOnlySpan cd) - { - // RFC 6265: a leading dot is ignored (".example.com" == "example.com") - if (cd.Length > 0 && cd[0] == '.') - cd = cd[1..]; - return cd; - } - - // If you need IDN support, use this to punycode strings before validation - public static string ToAsciiIdn(string s) => - new IdnMapping { UseStd3AsciiRules = true }.GetAscii(s); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsValidLabel(ReadOnlySpan label) - { - if ((uint)label.Length is 0 or > 63) return false; - if (label[0] == '-' || label[^1] == '-') return false; - return !label.ContainsAnyExcept(ValidLabelChars); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool LabelsEqualIgnoreCase(ReadOnlySpan a, ReadOnlySpan b) - { - if (a.Length != b.Length) return false; - for (int i = 0; i < a.Length; i++) - { - if (ToLowerAscii(a[i]) != ToLowerAscii(b[i])) return false; - } - return true; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static char ToLowerAscii(char c) - => c is >= 'A' and <= 'Z' ? (char)(c + 32) : c; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool ContainsDot(ReadOnlySpan s) - { - foreach (var t in s) - if (t == '.') - return true; - return false; - } -} - -// Your ReverseLabelEnumerator stays as-is -file ref struct ReverseLabelEnumerator -{ - private readonly ReadOnlySpan _span; - - public ReverseLabelEnumerator(ReadOnlySpan span) - { - _span = span; - Position = span.Length - 1; - Current = default; - } - - public ReadOnlySpan Current { get; private set; } - public int Position { get; private set; } - - public bool HasRemaining => Position >= 0; - - public bool MoveNext() - { - if (Position < 0) return false; - - int end = Position; - while (Position >= 0 && _span[Position] != '.') Position--; - - if (end == Position) return false; // empty label - Current = _span.Slice(Position + 1, end - Position); - Position--; // move to char before the dot - return true; - } -} From 4dc0e71f542c5c58db6da7289027e1106945ca6d Mon Sep 17 00:00:00 2001 From: hhvrc Date: Fri, 12 Sep 2025 18:15:41 +0200 Subject: [PATCH 51/63] More stuff --- .../Authenticated/OAuthConnectionRemove.cs | 5 +- .../Authenticated/OAuthConnectionsList.cs | 4 +- API/Controller/Account/Login.cs | 4 +- API/Controller/Account/LoginV2.cs | 5 +- API/Controller/OAuth/Authorize.cs | 4 +- API/Controller/OAuth/Finalize.cs | 156 ----------------- API/Controller/OAuth/GetData.cs | 45 ----- API/Controller/OAuth/HandOff.cs | 160 +++++++++--------- API/Controller/OAuth/SignupFinalize.cs | 124 ++++++++++++++ API/Controller/OAuth/SignupGetData.cs | 61 +++++++ API/Controller/OAuth/_ApiController.cs | 53 +++--- API/Models/Requests/OAuthFinalizeRequest.cs | 7 +- ...Response.cs => OAuthSignupDataResponse.cs} | 2 +- API/OAuth/OAuthError.cs | 2 +- API/OAuth/ValidatedFlowContext.cs | 2 +- API/Services/Account/AccountService.cs | 3 +- API/Services/Account/IAccountService.cs | 3 +- .../IOAuthConnectionService.cs | 11 +- .../OAuthConnection/OAuthConnectionService.cs | 29 ++-- Common/OpenShockControllerBase.cs | 84 +-------- Common/Utils/AuthUtils.cs | 64 +++++++ Common/Utils/DomainUtils.cs | 44 ++--- 22 files changed, 428 insertions(+), 444 deletions(-) delete mode 100644 API/Controller/OAuth/Finalize.cs delete mode 100644 API/Controller/OAuth/GetData.cs create mode 100644 API/Controller/OAuth/SignupFinalize.cs create mode 100644 API/Controller/OAuth/SignupGetData.cs rename API/Models/Response/{OAuthDataResponse.cs => OAuthSignupDataResponse.cs} (87%) diff --git a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs index 18100bee..8e4be621 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs @@ -10,14 +10,15 @@ public sealed partial class AuthenticatedAccountController /// /// Provider key (e.g. discord). /// + /// /// Connection removed. /// No connection found for this provider. [HttpDelete("connections/{provider}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task RemoveOAuthConnection([FromRoute] string provider, [FromServices] IOAuthConnectionService connectionService) + public async Task RemoveOAuthConnection([FromRoute] string provider, [FromServices] IOAuthConnectionService connectionService, CancellationToken cancellationToken) { - var deleted = await connectionService.TryRemoveConnectionAsync(CurrentUser.Id, provider); + var deleted = await connectionService.TryRemoveConnectionAsync(CurrentUser.Id, provider, cancellationToken); if (!deleted) return NotFound(); diff --git a/API/Controller/Account/Authenticated/OAuthConnectionsList.cs b/API/Controller/Account/Authenticated/OAuthConnectionsList.cs index 3cdc1b65..3644fabe 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionsList.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionsList.cs @@ -13,9 +13,9 @@ public sealed partial class AuthenticatedAccountController /// Returns the list of connections. [HttpGet("connections")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task ListOAuthConnections([FromServices] IOAuthConnectionService connectionService) + public async Task ListOAuthConnections([FromServices] IOAuthConnectionService connectionService, CancellationToken cancellationToken) { - var connections = await connectionService.GetConnectionsAsync(CurrentUser.Id); + var connections = await connectionService.GetConnectionsAsync(CurrentUser.Id, cancellationToken); return connections .Select(c => new OAuthConnectionResponse diff --git a/API/Controller/Account/Login.cs b/API/Controller/Account/Login.cs index d7e91efd..09282689 100644 --- a/API/Controller/Account/Login.cs +++ b/API/Controller/Account/Login.cs @@ -29,7 +29,6 @@ public sealed partial class AccountController [MapToApiVersion("1")] public async Task Login( [FromBody] Login body, - [FromServices] ISessionService sessionService, CancellationToken cancellationToken) { var cookieDomain = GetCurrentCookieDomain(); @@ -46,8 +45,7 @@ public async Task Login( ); } - var session = await sessionService.CreateSessionAsync(account.Id, HttpContext.GetUserAgent(), HttpContext.GetRemoteIP().ToString()); - SetSessionCookie(session.Token, cookieDomain); + await CreateSession(account.Id, "." + cookieDomain); return LegacyEmptyOk("Successfully logged in"); } } \ No newline at end of file diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index 7587d61d..dac3f4d3 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -34,7 +34,6 @@ public sealed partial class AccountController public async Task LoginV2( [FromBody] LoginV2 body, [FromServices] ICloudflareTurnstileService turnstileService, - [FromServices] ISessionService sessionService, CancellationToken cancellationToken) { var cookieDomain = GetCurrentCookieDomain(); @@ -62,8 +61,8 @@ public async Task LoginV2( ); } - var session = await sessionService.CreateSessionAsync(account.Id, HttpContext.GetUserAgent(), remoteIp.ToString()); - SetSessionCookie(session.Token, "." + cookieDomain); + await CreateSession(account.Id, "." + cookieDomain); + return Ok(LoginV2OkResponse.FromUser(account)); } } \ No newline at end of file diff --git a/API/Controller/OAuth/Authorize.cs b/API/Controller/OAuth/Authorize.cs index 989f5e33..d9e1d6cf 100644 --- a/API/Controller/OAuth/Authorize.cs +++ b/API/Controller/OAuth/Authorize.cs @@ -3,7 +3,7 @@ using OpenShock.Common.Problems; using System.Net.Mime; using OpenShock.API.OAuth; -using OpenShock.Common.Authentication; +using OpenShock.Common.Utils; namespace OpenShock.API.Controller.OAuth; @@ -28,7 +28,7 @@ public async Task OAuthAuthorize([FromRoute] string provider) if (!await _schemeProvider.IsSupportedOAuthScheme(provider)) return Problem(OAuthError.UnsupportedProvider); - if (IsOpenShockUserAuthenticated()) + if (User.HasOpenShockUserIdentity()) { return Problem(OAuthError.AnonymousOnlyEndpoint); } diff --git a/API/Controller/OAuth/Finalize.cs b/API/Controller/OAuth/Finalize.cs deleted file mode 100644 index d16fff77..00000000 --- a/API/Controller/OAuth/Finalize.cs +++ /dev/null @@ -1,156 +0,0 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.RateLimiting; -using OpenShock.API.Models.Requests; -using OpenShock.API.Models.Response; -using OpenShock.API.Services.Account; -using OpenShock.API.Services.OAuthConnection; -using OpenShock.Common.Errors; -using OpenShock.Common.Problems; -using System.Net.Mime; -using System.Security.Claims; -using OpenShock.API.OAuth; - -namespace OpenShock.API.Controller.OAuth; - -public sealed partial class OAuthController -{ - /// - /// Finalize an OAuth flow by either creating a new local account or linking to the current account. - /// - /// - /// Authenticates via the temporary OAuth flow cookie (set during the provider callback). - /// - create: creates a local account, then links the external identity.
- /// - link: requires a logged-in local user; links the external identity to that user.
- /// No access/refresh tokens are returned. - ///
- /// Provider key (e.g. discord). - /// Finalize request. - /// - /// - /// Finalization succeeded. - /// Flow not found, bad action, username invalid, or provider mismatch. - /// Link requested but user not authenticated. - /// External already linked to another account, or duplicate link attempt. - [EnableRateLimiting("auth")] - [HttpPost("{provider}/finalize")] - [ProducesResponseType(typeof(OAuthFinalizeResponse), StatusCodes.Status200OK, MediaTypeNames.Application.Json)] - [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] - [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status401Unauthorized, MediaTypeNames.Application.Json)] - [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status409Conflict, MediaTypeNames.Application.Json)] - public async Task OAuthFinalize( - [FromRoute] string provider, - [FromBody] OAuthFinalizeRequest body, - [FromServices] IAccountService accountService, - [FromServices] IOAuthConnectionService connectionService) - { - var result = await ValidateOAuthFlowAsync(provider); - if (!result.TryPickT0(out var auth, out var response)) - { - return response; - } - - // External identity basics from claims (added by your handler) - var externalId = auth.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; - var email = body.Email ?? auth.Principal.FindFirst(ClaimTypes.Email)?.Value; - var displayName = body.Username ?? auth.Principal.FindFirst(ClaimTypes.Name)?.Value; - if (string.IsNullOrEmpty(externalId) || string.IsNullOrEmpty(email) || string.IsNullOrEmpty(displayName)) - { - await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.FlowMissingData); - } - - return body.Action switch - { - OAuthFlow.Link => await HandleLink(provider, externalId, displayName, connectionService), - OAuthFlow.LoginOrCreate => await HandleLoginOrCreate(provider, externalId, email, displayName, accountService, connectionService), - _ => await HandleBadFlow() - }; - - async Task HandleBadFlow() - { - await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.UnsupportedFlow); - } - } - - [NonAction] - private async Task HandleLink(string provider, string externalId, string displayName, IOAuthConnectionService connectionService) - { - // If the external is already linked, don’t allow relinking in either flow. - var existing = await connectionService.GetByProviderExternalIdAsync(provider, externalId); - if (existing is not null) - { - // Already linked to someone, block. - await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.ExternalAlreadyLinked); - } - - // Linking requires an authenticated session - if (!TryGetAuthenticatedOpenShockUserId(out var currentUserId)) - { - // Not a logged-in session (could be API token or anonymous) - return Problem(OAuthError.NotAuthenticatedForLink); - } - - - var ok = await connectionService.TryAddConnectionAsync( - userId: currentUserId, - provider: provider, - providerAccountId: externalId, - providerAccountName: displayName - ); - - await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - - if (!ok) return Problem(OAuthError.ExternalAlreadyLinked); - - return Ok(new OAuthFinalizeResponse - { - Provider = provider, - ExternalId = externalId - }); - } - - [NonAction] - private async Task HandleLoginOrCreate(string provider, string externalId, string email, string displayName, IAccountService accountService, IOAuthConnectionService connectionService) - { - // If the external is already linked, don’t allow relinking in either flow. - var existing = await connectionService.GetByProviderExternalIdAsync(provider, externalId); - if (existing is not null) - { - // External already mapped; treat as conflict (or you could return 200 if you consider this a no-op login). - await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.ConnectionAlreadyExists); - } - - // We must create a local account. Your AccountService requires a password, so: - displayName = displayName.Trim(); - // TODO: Check if username valid, if invalid respond with bad request, dont clear cookie tho, so that frontend can try again - - var created = await accountService.CreateOAuthOnlyAccountAsync( - email: email, - username: displayName, - provider: provider, - providerAccountId: externalId, - providerAccountName: displayName - ); - - - if (created.IsT1) - { - return Problem(SignupError.UsernameOrEmailExists); - } - - await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - - var newUser = created.AsT0.Value; - - return Ok(new OAuthFinalizeResponse - { - Provider = provider, - ExternalId = externalId, - Username = newUser.Name - }); - } -} diff --git a/API/Controller/OAuth/GetData.cs b/API/Controller/OAuth/GetData.cs deleted file mode 100644 index c2f7f665..00000000 --- a/API/Controller/OAuth/GetData.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.RateLimiting; -using OpenShock.API.Models.Response; -using OpenShock.Common.Problems; -using System.Net.Mime; -using System.Security.Claims; -using OpenShock.API.OAuth; - -namespace OpenShock.API.Controller.OAuth; - -public sealed partial class OAuthController -{ - /// - /// Retrieve short-lived OAuth handoff information for the current flow. - /// - /// - /// Returns identity details from the external provider (e.g., email, display name) along with the flow expiry. - /// This endpoint is authenticated via the temporary OAuth flow cookie and is only accessible to the user who initiated the flow. - /// - /// Provider key (e.g. discord). - /// Handoff data returned. - /// Flow not found or provider mismatch. - [ResponseCache(NoStore = true)] - [EnableRateLimiting("auth")] - [HttpGet("{provider}/data")] - [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] - public async Task OAuthGetData([FromRoute] string provider) - { - var result = await ValidateOAuthFlowAsync(provider); - if (!result.TryPickT0(out var auth, out var response)) - { - return response; - } - - return Ok(new OAuthDataResponse - { - Provider = auth.Provider, - Email = auth.Principal.FindFirst(ClaimTypes.Email)?.Value, - DisplayName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value, - ExpiresAt = auth.Properties.ExpiresUtc!.Value.UtcDateTime - }); - } -} diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index 716e7238..60bf907a 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -6,9 +6,7 @@ using OpenShock.Common.Options; using OpenShock.Common.Problems; using System.Net.Mime; -using Microsoft.AspNetCore.Http.Extensions; using OpenShock.API.OAuth; -using OpenShock.Common.Errors; using OpenShock.Common.Services.Session; using OpenShock.Common.Utils; @@ -20,17 +18,10 @@ public sealed partial class OAuthController /// Handoff after provider callback. Decides next step (create, link, or direct sign-in). /// /// - /// This endpoint is reached after the OAuth middleware processed the provider callback. - /// It reads the temp OAuth flow principal and its flow (create/link). - /// If an existing connection is found, signs in and redirects home; otherwise redirects the frontend to continue the flow. + /// Reads the temp OAuth flow principal (flow cookie set by middleware). + /// If a matching connection exists -> signs in and redirects home. + /// Otherwise -> redirects frontend to continue the chosen flow. /// - /// Provider key (e.g. discord). - /// - /// - /// - /// - /// Redirect to the frontend (create/link) or home on direct sign-in. - /// Flow missing or not supported. [EnableRateLimiting("auth")] [HttpGet("{provider}/handoff")] [ProducesResponseType(StatusCodes.Status302Found)] @@ -38,96 +29,109 @@ public sealed partial class OAuthController public async Task OAuthHandOff( [FromRoute] string provider, [FromServices] IOAuthConnectionService connectionService, - [FromServices] ISessionService sessionService, [FromServices] IOptions frontendOptions, CancellationToken cancellationToken) { - var result = await ValidateOAuthFlowAsync(provider); - if (!result.TryPickT0(out var auth, out var response)) + var result = await ValidateOAuthFlowAsync(); + if (!result.TryPickT0(out var auth, out var error)) { - return response; + return error switch + { + OAuthValidationError.FlowStateMissing => RedirectFrontendError("OAuthFlowNotStarted"), + _ => RedirectFrontendError("InternalError") + }; + } + + // 1) Defense-in-depth: ensure the flow’s provider matches the route + if (!string.Equals(auth.Provider, provider, StringComparison.OrdinalIgnoreCase)) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return RedirectFrontendError("providerMismatch"); } - var connection = await connectionService.GetByProviderExternalIdAsync(provider, auth.ExternalAccountId); + var connection = await connectionService + .GetByProviderExternalIdAsync(provider, auth.ExternalAccountId, cancellationToken); switch (auth.Flow) { case OAuthFlow.LoginOrCreate: + { + if (User.HasOpenShockUserIdentity()) { - if (IsOpenShockUserAuthenticated()) - { - await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return GetBadUrl("mustBeAnonymous"); - } - - if (connection is not null) - { - var cookieDomain = GetCurrentCookieDomain(); - if (cookieDomain is null) return GetBadUrl("internalError"); - - var session = await sessionService.CreateSessionAsync( - connection.UserId, - HttpContext.GetUserAgent(), - HttpContext.GetRemoteIP().ToString() - ); - - await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - SetSessionCookie(session.Token, "." + cookieDomain); - - return Redirect("/"); // TODO: Make this go to frontend - } - - // Create - - var frontendUrl = new UriBuilder(frontendOptions.Value.BaseUrl) - { - Path = $"oauth/{provider}/create" - }; - return Redirect(frontendUrl.Uri.ToString()); + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return RedirectFrontendError("mustBeAnonymous"); + } + + if (connection is null) + { + // No connection -> continue to CREATE flow on frontend + return RedirectFrontendPath($"oauth/{provider}/create"); } + // Direct sign-in + var domain = GetCurrentCookieDomain(); + if (string.IsNullOrEmpty(domain)) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return RedirectFrontendError("internalError"); + } + + await CreateSession(connection.UserId, "." + domain); + + // Flow cookie no longer needed + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + + // TODO: optionally send to a specific frontend route + return RedirectFrontendPath(""); + } + case OAuthFlow.Link: + { + if (!User.TryGetAuthenticatedOpenShockUserId(out var userId)) { - if (!IsOpenShockUserAuthenticated()) - { - await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return GetBadUrl("mustBeAuthenticated"); - } - - if (connection is not null) - { - // Connection already exists, FAILURE - - // TODO: Check if the connection is connected to our account with same externalId (AlreadyLinked), different externalId (AlreadyExists), or to another account (LinkedToAnotherAccount) - await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return GetBadUrl("alreadyLinked"); - } - - // Link connection to account + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return RedirectFrontendError("mustBeAuthenticated"); + } - var frontendUrl = new UriBuilder(frontendOptions.Value.BaseUrl) - { - Path = $"oauth/{provider}/link" - }; - return Redirect(frontendUrl.Uri.ToString()); + if (connection is not null) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + + return RedirectFrontendError(connection.UserId == userId ? "alreadyLinked" : "linkedToAnotherAccount"); + } + + bool ok =await connectionService.TryAddConnectionAsync(userId, provider, auth.ExternalAccountId, auth.ExternalAccountName, cancellationToken); + if (!ok) + { + } + // No connection -> continue to LINK flow on frontend. + // IMPORTANT: keep the flow cookie so frontend can finalize with it. + return RedirectFrontendPath($"oauth/{provider}/link"); + } + default: await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return GetBadUrl("internalError"); + return RedirectFrontendError("internalError"); } - RedirectResult GetBadUrl(string errorType) + // --- helpers --- + + RedirectResult RedirectFrontendPath(string relativeOrQuery) { - var frontendUrl = new UriBuilder(frontendOptions.Value.BaseUrl) + // If caller passes only query (e.g. "oauth/error?error=x"), it still works; + // if they pass empty string, it redirects to base. + var target = relativeOrQuery switch { - Path = "oauth/error", - Query = new QueryBuilder - { - { "error", errorType } - }.ToString() + "" => frontendOptions.Value.BaseUrl, + _ when relativeOrQuery.StartsWith('?') => new Uri(frontendOptions.Value.BaseUrl, "/" + relativeOrQuery), // force query on root + _ => new Uri(frontendOptions.Value.BaseUrl, relativeOrQuery.StartsWith('/') ? relativeOrQuery : "/" + relativeOrQuery) }; - return Redirect(frontendUrl.Uri.ToString()); + return Redirect(target.ToString()); } + + RedirectResult RedirectFrontendError(string errorType) + => RedirectFrontendPath($"oauth/error?error={Uri.EscapeDataString(errorType)}"); } -} \ No newline at end of file +} diff --git a/API/Controller/OAuth/SignupFinalize.cs b/API/Controller/OAuth/SignupFinalize.cs new file mode 100644 index 00000000..184e1252 --- /dev/null +++ b/API/Controller/OAuth/SignupFinalize.cs @@ -0,0 +1,124 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.API.Models.Requests; +using OpenShock.API.Models.Response; +using OpenShock.API.Services.OAuthConnection; +using OpenShock.Common.Errors; +using OpenShock.Common.Problems; +using System.Net.Mime; +using System.Security.Claims; +using OpenShock.API.OAuth; +using OpenShock.Common.Services.Session; +using OpenShock.Common.Utils; + +namespace OpenShock.API.Controller.OAuth; + +public sealed partial class OAuthController +{ + /// + /// Finalize an OAuth flow by creating a new account with the external identity. + /// + /// + /// Authenticates via the temporary OAuth flow cookie (set during the provider callback). + /// Sets the regular session cookie on success. No access/refresh tokens are returned. + /// + /// Provider key (e.g. discord). + /// Request body containing optional Email and Username overrides. + /// + /// + /// Account created, external identity linked, and client authenticated. + /// Flow not found, missing data, or invalid username. + /// External already linked or username/email already exists. + [EnableRateLimiting("auth")] + [HttpPost("{provider}/signup-finalize")] + [ProducesResponseType(typeof(OAuthFinalizeResponse), StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status409Conflict, MediaTypeNames.Application.Json)] + public async Task OAuthSignupFinalize( + [FromRoute] string provider, + [FromBody] OAuthFinalizeRequest body, + [FromServices] IOAuthConnectionService connectionService, + CancellationToken cancellationToken) + { + var result = await ValidateOAuthFlowAsync(); + if (!result.TryPickT0(out var auth, out var error)) + { + return error switch + { + OAuthValidationError.FlowStateMissing => Problem(OAuthError.FlowNotFound), + _ => Problem(OAuthError.InternalError) + }; + } + + if (User.HasOpenShockUserIdentity()) + { + return Problem(OAuthError.AnonymousOnlyEndpoint); + } + + // 1) Defense-in-depth: ensure the flow’s provider matches the route + if (!string.Equals(auth.Provider, provider, StringComparison.OrdinalIgnoreCase) || auth.Flow != OAuthFlow.LoginOrCreate) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return Problem(OAuthError.FlowMismatch); + } + + // External identity basics from claims (added by your handler) + var externalId = auth.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var externalAccountName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value; + var email = body.Email ?? auth.Principal.FindFirst(ClaimTypes.Email)?.Value; + var displayName = string.IsNullOrEmpty(body.Username) ? externalAccountName : body.Username; + var isEmailTrusted = auth.Principal.FindFirst(OAuthConstants.ClaimEmailVerified)?.Value.Equals("true", StringComparison.InvariantCultureIgnoreCase) ?? false; + + if (string.IsNullOrEmpty(externalId) || string.IsNullOrEmpty(email) || string.IsNullOrEmpty(displayName)) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return Problem(OAuthError.FlowMissingData); + } + + // Do not allow creation if this external is already linked anywhere. + var existing = await connectionService.GetByProviderExternalIdAsync(provider, externalId, cancellationToken); + if (existing is not null) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return Problem(OAuthError.ExternalAlreadyLinked); + } + + var created = await _accountService.CreateOAuthOnlyAccountAsync( + email, + displayName, + provider, + externalId, + externalAccountName, + isEmailTrusted + ); + + if (!created.TryPickT0(out var newUser, out var _)) + { + // Username or email already exists — conflict. + // Do NOT clear the flow cookie so the frontend can retry with a different username. + return Problem(SignupError.UsernameOrEmailExists); + } + + // --- Authenticate the client (create session + set session cookie) --- + var domain = GetCurrentCookieDomain(); + if (string.IsNullOrEmpty(domain)) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return Problem(OAuthError.InternalError); + } + + await CreateSession(newUser.Value.Id, domain); + // -------------------------------------------------------------------- + + // Clear the temporary OAuth flow cookie. + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + + return Ok(new OAuthFinalizeResponse + { + Provider = provider, + ExternalId = externalId, + Username = newUser.Value.Name + }); + } +} diff --git a/API/Controller/OAuth/SignupGetData.cs b/API/Controller/OAuth/SignupGetData.cs new file mode 100644 index 00000000..e83efd19 --- /dev/null +++ b/API/Controller/OAuth/SignupGetData.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.API.Models.Response; +using OpenShock.Common.Problems; +using System.Net.Mime; +using System.Security.Claims; +using OpenShock.API.OAuth; +using OpenShock.Common.Utils; + +namespace OpenShock.API.Controller.OAuth; + +public sealed partial class OAuthController +{ + /// + /// Provides temporary OAuth handoff details for the active signup flow. + /// + /// + /// Returns basic identity information from the external provider (e.g., email, display name) + /// together with the expiration time of the current flow. + /// Access to this endpoint requires the temporary OAuth flow cookie and is restricted to the user + /// who initiated the flow. + /// + /// The provider key (e.g. discord). + /// Handoff information returned successfully. + /// No active flow found, or the provider did not match. + [ResponseCache(NoStore = true)] + [EnableRateLimiting("auth")] + [HttpGet("{provider}/signup-data")] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] + public async Task OAuthSignupGetData([FromRoute] string provider) + { + if (User.HasOpenShockUserIdentity()) + { + return Problem(OAuthError.AnonymousOnlyEndpoint); + } + + var result = await ValidateOAuthFlowAsync(); + if (!result.TryPickT0(out var auth, out var error)) + { + return error switch + { + OAuthValidationError.FlowStateMissing => Problem(OAuthError.FlowNotFound), + _ => Problem(OAuthError.InternalError) + }; + } + + if (auth.Provider != provider || auth.Flow != OAuthFlow.LoginOrCreate) + { + return Problem(OAuthError.AnonymousOnlyEndpoint); + } + + return Ok(new OAuthSignupDataResponse + { + Provider = auth.Provider, + Email = auth.Principal.FindFirst(ClaimTypes.Email)?.Value, + DisplayName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value, + ExpiresAt = auth.Properties.ExpiresUtc!.Value.UtcDateTime + }); + } +} diff --git a/API/Controller/OAuth/_ApiController.cs b/API/Controller/OAuth/_ApiController.cs index dd6ed184..b9688adf 100644 --- a/API/Controller/OAuth/_ApiController.cs +++ b/API/Controller/OAuth/_ApiController.cs @@ -29,61 +29,60 @@ public OAuthController(IAccountService accountService, IAuthenticationSchemeProv _logger = logger; } + private enum OAuthValidationError + { + FlowStateMissing, + FlowDataMissingOrInvalid, + } + /// - /// Validates: provider exists, temp cookie auth present, scheme matches, flow parsable. + /// Validates: provider exists, temp cookie auth present, scNOheme matches, flow parsable. /// On success returns ValidatedFlowContext; on failure returns IActionResult with proper problem details. /// - private async Task> ValidateOAuthFlowAsync(string expectedProvider, OAuthFlow? expectedFlow = null) + private async Task> ValidateOAuthFlowAsync() { - // 1) provider supported? - if (!await _schemeProvider.IsSupportedOAuthScheme(expectedProvider)) - return Problem(OAuthError.UnsupportedProvider); - - // 2) authenticate temp cookie + // 1) authenticate temp cookie var auth = await HttpContext.AuthenticateAsync(OAuthConstants.FlowScheme); if (!auth.Succeeded || auth.Principal is null || auth.Ticket is null) - return Problem(OAuthError.FlowStateNotFound); - - // 3) scheme/provider check — prefer the ticket's scheme over a magic Item - if (!auth.Properties.Items.TryGetValue(".AuthScheme", out var actualScheme) || string.IsNullOrEmpty(actualScheme)) { await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.FlowStateNotFound); + return OAuthValidationError.FlowStateMissing; } - - if (actualScheme != expectedProvider) + + // 2) scheme/provider check - prefer the ticket's scheme over a magic Item + if (!auth.Properties.Items.TryGetValue(".AuthScheme", out var actualScheme) || string.IsNullOrWhiteSpace(actualScheme)) { + _logger.LogError("Invalid OAuth scheme"); await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.ProviderMismatch); + return OAuthValidationError.FlowDataMissingOrInvalid; } - // 4) parse flow from properties + // 3) parse flow from properties if (auth.Properties is null || !auth.Properties.Items.TryGetValue(OAuthConstants.ItemKeyFlowType, out var flowStr) || !Enum.TryParse(flowStr, true, out var flow)) { + _logger.LogError("Invalid OAuth scheme"); await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.UnsupportedFlow); - } - - if (expectedFlow is not null && flow != expectedFlow) - { - await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.FlowMismatch); + return OAuthValidationError.FlowDataMissingOrInvalid; } - // External subject is required to resolve/link. + // 4) fetch id of external user var externalId = auth.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(externalId)) + if (string.IsNullOrWhiteSpace(externalId)) { + _logger.LogError("Invalid OAuth scheme"); await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.FlowMissingData); + return OAuthValidationError.FlowDataMissingOrInvalid; } + + return new ValidatedFlowContext( - Provider: expectedProvider, + Provider: actualScheme, Flow: flow, ExternalAccountId: externalId, + ExternalAccountName: auth.Principal.FindFirst(ClaimTypes.Name)?.Value, Principal: auth.Principal, Properties: auth.Properties ); diff --git a/API/Models/Requests/OAuthFinalizeRequest.cs b/API/Models/Requests/OAuthFinalizeRequest.cs index 6bd8e934..72f73a4c 100644 --- a/API/Models/Requests/OAuthFinalizeRequest.cs +++ b/API/Models/Requests/OAuthFinalizeRequest.cs @@ -1,5 +1,5 @@ -using System.ComponentModel.DataAnnotations; -using OpenShock.API.OAuth; +using OpenShock.API.OAuth; +using OpenShock.Common.DataAnnotations; namespace OpenShock.API.Models.Requests; @@ -9,12 +9,15 @@ public sealed class OAuthFinalizeRequest public required OAuthFlow Action { get; init; } /// Desired username (create only). If omitted, a name will be generated from the external profile. + [Username(true)] public required string? Username { get; init; } + [EmailAddress(true)] public required string? Email { get; init; } /// /// New account password (create only). /// + [Password(true)] public required string? Password { get; init; } } \ No newline at end of file diff --git a/API/Models/Response/OAuthDataResponse.cs b/API/Models/Response/OAuthSignupDataResponse.cs similarity index 87% rename from API/Models/Response/OAuthDataResponse.cs rename to API/Models/Response/OAuthSignupDataResponse.cs index 7f4ade5b..98617473 100644 --- a/API/Models/Response/OAuthDataResponse.cs +++ b/API/Models/Response/OAuthSignupDataResponse.cs @@ -1,7 +1,7 @@ namespace OpenShock.API.Models.Response; // what we return to frontend at /oauth/discord/data -public sealed class OAuthDataResponse +public sealed class OAuthSignupDataResponse { public required string Provider { get; init; } public required string? Email { get; init; } diff --git a/API/OAuth/OAuthError.cs b/API/OAuth/OAuthError.cs index 584aff7f..6f042505 100644 --- a/API/OAuth/OAuthError.cs +++ b/API/OAuth/OAuthError.cs @@ -32,7 +32,7 @@ public static class OAuthError "You must be signed out to call this endpoint", HttpStatusCode.Unauthorized); - public static OpenShockProblem FlowStateNotFound => new( + public static OpenShockProblem FlowNotFound => new( "OAuth.Flow.NotFound", "The OAuth flow was not found, has expired, or is invalid", HttpStatusCode.BadRequest); diff --git a/API/OAuth/ValidatedFlowContext.cs b/API/OAuth/ValidatedFlowContext.cs index b8ae9ec0..d276ec9c 100644 --- a/API/OAuth/ValidatedFlowContext.cs +++ b/API/OAuth/ValidatedFlowContext.cs @@ -3,4 +3,4 @@ namespace OpenShock.API.OAuth; -public sealed record ValidatedFlowContext(string Provider, OAuthFlow Flow, string ExternalAccountId, ClaimsPrincipal Principal, AuthenticationProperties Properties); \ No newline at end of file +public sealed record ValidatedFlowContext(string Provider, OAuthFlow Flow, string ExternalAccountId, string? ExternalAccountName, ClaimsPrincipal Principal, AuthenticationProperties Properties); \ No newline at end of file diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index e0464979..02bc1a1c 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -128,7 +128,8 @@ public async Task, AccountWithEmailOrUsernameExists>> Create string username, string provider, string providerAccountId, - string? providerAccountName) + string? providerAccountName, + bool isEmailTrusted) { email = email.ToLowerInvariant(); provider = provider.ToLowerInvariant(); diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 9352d369..c7d22cd5 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -38,8 +38,9 @@ public interface IAccountService /// e.g. "discord" /// external subject/id from provider /// display name from provider + /// /// Success with the created user, or AccountWithEmailOrUsernameExists when taken/blocked. - Task, AccountWithEmailOrUsernameExists>> CreateOAuthOnlyAccountAsync(string email, string username, string provider, string providerAccountId, string? providerAccountName); + Task, AccountWithEmailOrUsernameExists>> CreateOAuthOnlyAccountAsync(string email, string username, string provider, string providerAccountId, string? providerAccountName, bool isEmailTrusted); /// /// diff --git a/API/Services/OAuthConnection/IOAuthConnectionService.cs b/API/Services/OAuthConnection/IOAuthConnectionService.cs index 2706a479..ab19d5eb 100644 --- a/API/Services/OAuthConnection/IOAuthConnectionService.cs +++ b/API/Services/OAuthConnection/IOAuthConnectionService.cs @@ -7,9 +7,10 @@ namespace OpenShock.API.Services.OAuthConnection; /// public interface IOAuthConnectionService { - Task GetConnectionsAsync(Guid userId); - Task GetByProviderExternalIdAsync(string provider, string providerAccountId); - Task HasConnectionAsync(Guid userId, string provider); - Task TryAddConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName); - Task TryRemoveConnectionAsync(Guid userId, string provider); + Task GetConnectionsAsync(Guid userId, CancellationToken cancellationToken = default); + Task GetByProviderExternalIdAsync(string provider, string providerAccountId, CancellationToken cancellationToken = default); + Task ConnectionExistsAsync(string provider, string providerAccountId, CancellationToken cancellationToken = default); + Task HasConnectionAsync(Guid userId, string provider, CancellationToken cancellationToken = default); + Task TryAddConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName, CancellationToken cancellationToken = default); + Task TryRemoveConnectionAsync(Guid userId, string provider, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/API/Services/OAuthConnection/OAuthConnectionService.cs b/API/Services/OAuthConnection/OAuthConnectionService.cs index 572e88f7..10170b02 100644 --- a/API/Services/OAuthConnection/OAuthConnectionService.cs +++ b/API/Services/OAuthConnection/OAuthConnectionService.cs @@ -1,9 +1,8 @@ using Microsoft.EntityFrameworkCore; using Npgsql; -using OpenShock.API.Services.OAuthConnection; using OpenShock.Common.OpenShockDb; -namespace OpenShock.API.Services.Account; +namespace OpenShock.API.Services.OAuthConnection; public sealed class OAuthConnectionService : IOAuthConnectionService { @@ -16,28 +15,34 @@ public OAuthConnectionService(OpenShockContext db, ILogger GetConnectionsAsync(Guid userId) + public async Task GetConnectionsAsync(Guid userId, CancellationToken cancellationToken) { return await _db.UserOAuthConnections .AsNoTracking() .Where(c => c.UserId == userId) - .ToArrayAsync(); + .ToArrayAsync(cancellationToken); } - public async Task GetByProviderExternalIdAsync(string provider, string providerAccountId) + public async Task GetByProviderExternalIdAsync(string provider, string providerAccountId, CancellationToken cancellationToken) { var p = provider.ToLowerInvariant(); return await _db.UserOAuthConnections - .FirstOrDefaultAsync(c => c.ProviderKey == p && c.ExternalId == providerAccountId); + .FirstOrDefaultAsync(c => c.ProviderKey == p && c.ExternalId == providerAccountId, cancellationToken); } - public async Task HasConnectionAsync(Guid userId, string provider) + public async Task ConnectionExistsAsync(string provider, string providerAccountId, CancellationToken cancellationToken) { var p = provider.ToLowerInvariant(); - return await _db.UserOAuthConnections.AnyAsync(c => c.UserId == userId && c.ProviderKey == p); + return await _db.UserOAuthConnections.AnyAsync(c => c.ProviderKey == p && c.ExternalId == providerAccountId, cancellationToken); } - public async Task TryAddConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName) + public async Task HasConnectionAsync(Guid userId, string provider, CancellationToken cancellationToken) + { + var p = provider.ToLowerInvariant(); + return await _db.UserOAuthConnections.AnyAsync(c => c.UserId == userId && c.ProviderKey == p, cancellationToken); + } + + public async Task TryAddConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName, CancellationToken cancellationToken) { try { @@ -48,7 +53,7 @@ public async Task TryAddConnectionAsync(Guid userId, string provider, stri ExternalId = providerAccountId, DisplayName = providerAccountName }); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); return true; } catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" }) @@ -59,12 +64,12 @@ public async Task TryAddConnectionAsync(Guid userId, string provider, stri } } - public async Task TryRemoveConnectionAsync(Guid userId, string provider) + public async Task TryRemoveConnectionAsync(Guid userId, string provider, CancellationToken cancellationToken) { var p = provider.ToLowerInvariant(); var nDeleted = await _db.UserOAuthConnections .Where(c => c.UserId == userId && c.ProviderKey == p) - .ExecuteDeleteAsync(); + .ExecuteDeleteAsync(cancellationToken); return nDeleted > 0; } diff --git a/Common/OpenShockControllerBase.cs b/Common/OpenShockControllerBase.cs index d5e412a0..f4cf3e44 100644 --- a/Common/OpenShockControllerBase.cs +++ b/Common/OpenShockControllerBase.cs @@ -1,13 +1,11 @@ -using System.Diagnostics.CodeAnalysis; -using System.Net.Mime; -using System.Security.Claims; +using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; -using OpenShock.Common.Authentication; using OpenShock.Common.Constants; using OpenShock.Common.Models; using OpenShock.Common.Options; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Session; using OpenShock.Common.Utils; namespace OpenShock.Common; @@ -37,7 +35,7 @@ protected OkObjectResult LegacyEmptyOk(string message = "") } [NonAction] - private string GetCookieDomains() + protected string GetCookieDomains() { return HttpContext.RequestServices.GetRequiredService>().Value.CookieDomain; } @@ -61,9 +59,13 @@ private static CookieOptions GetCookieOptions(string domain, TimeSpan lifetime) } [NonAction] - protected void SetSessionCookie(string sessionKey, string domain) + protected async Task CreateSession(Guid accountId, string domain) { - HttpContext.Response.Cookies.Append(AuthConstants.UserSessionCookieName, sessionKey, GetCookieOptions(domain, Duration.LoginSessionLifetime)); + var sessionService = HttpContext.RequestServices.GetRequiredService(); + + var session = await sessionService.CreateSessionAsync(accountId, HttpContext.GetUserAgent(), HttpContext.GetRemoteIP().ToString()); + + HttpContext.Response.Cookies.Append(AuthConstants.UserSessionCookieName, session.Token, GetCookieOptions(domain, Duration.LoginSessionLifetime)); } [NonAction] @@ -80,72 +82,4 @@ protected void RemoveSessionKeyCookie() HttpContext.Response.Cookies.Append(AuthConstants.UserSessionCookieName, string.Empty, GetCookieOptions(domainStr, TimeSpan.FromDays(-1))); } } - - [NonAction] - protected bool TryGetOpenShockUserIdentity([NotNullWhen(true)] out ClaimsIdentity? identity) - { - foreach (var ident in User.Identities) - { - if (!ident.IsAuthenticated) continue; - - foreach (var claim in ident.Claims) - { - if (claim is - { - Type: ClaimTypes.AuthenticationMethod, - Value: OpenShockAuthSchemes.UserSessionCookie - }) - { - identity = ident; - return true; - } - } - } - - identity = null; - return false; - } - - [NonAction] - protected bool IsOpenShockUserAuthenticated() - { - foreach (var ident in User.Identities) - { - if (!ident.IsAuthenticated) continue; - - foreach (var claim in ident.Claims) - { - if (claim is - { - Type: ClaimTypes.AuthenticationMethod, - Value: OpenShockAuthSchemes.UserSessionCookie - }) - { - return true; - } - } - } - - return false; - } - - [NonAction] - protected bool TryGetAuthenticatedOpenShockUserId(out Guid userId) - { - if (!TryGetOpenShockUserIdentity(out var identity)) - { - userId = Guid.Empty; - return false; - } - - var idStr = identity.Claims.SingleOrDefault(x => x.Type == ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(idStr)) - { - userId = Guid.Empty; - return false; - } - - return Guid.TryParse(idStr, out userId); - - } } \ No newline at end of file diff --git a/Common/Utils/AuthUtils.cs b/Common/Utils/AuthUtils.cs index f3e57559..dce768bf 100644 --- a/Common/Utils/AuthUtils.cs +++ b/Common/Utils/AuthUtils.cs @@ -1,6 +1,7 @@ using OpenShock.Common.Constants; using System.Diagnostics.CodeAnalysis; using System.Security.Claims; +using OpenShock.Common.Authentication; namespace OpenShock.Common.Utils; @@ -79,4 +80,67 @@ public static string GetAuthenticationMethod(this HttpContext context) return authMethodClaim.Value; } + public static bool HasOpenShockUserIdentity(this ClaimsPrincipal user) + { + foreach (var ident in user.Identities) + { + if (!ident.IsAuthenticated) continue; + + foreach (var claim in ident.Claims) + { + if (claim is + { + Type: ClaimTypes.AuthenticationMethod, + Value: OpenShockAuthSchemes.UserSessionCookie + }) + { + return true; + } + } + } + + return false; + } + + public static bool TryGetOpenShockUserIdentity(this ClaimsPrincipal user, [NotNullWhen(true)] out ClaimsIdentity? identity) + { + foreach (var ident in user.Identities) + { + if (!ident.IsAuthenticated) continue; + + foreach (var claim in ident.Claims) + { + if (claim is + { + Type: ClaimTypes.AuthenticationMethod, + Value: OpenShockAuthSchemes.UserSessionCookie + }) + { + identity = ident; + return true; + } + } + } + + identity = null; + return false; + } + + public static bool TryGetAuthenticatedOpenShockUserId(this ClaimsPrincipal user, out Guid userId) + { + if (!user.TryGetOpenShockUserIdentity(out var identity)) + { + userId = Guid.Empty; + return false; + } + + var idStr = identity.Claims.SingleOrDefault(x => x.Type == ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(idStr)) + { + userId = Guid.Empty; + return false; + } + + return Guid.TryParse(idStr, out userId); + } } diff --git a/Common/Utils/DomainUtils.cs b/Common/Utils/DomainUtils.cs index 5a4e708b..5f05bba8 100644 --- a/Common/Utils/DomainUtils.cs +++ b/Common/Utils/DomainUtils.cs @@ -12,7 +12,7 @@ public static class DomainUtils public static bool IsValidDomain(ReadOnlySpan str) { if (str.Length is 0 or > 253) return false; - if (!ContainsDot(str)) return false; + if (str.IndexOf('.') == -1) return str is "localhost"; foreach (var range in str.Split('.')) { @@ -21,24 +21,9 @@ public static bool IsValidDomain(ReadOnlySpan str) return true; } - - /// - /// Returns true if ends with on a label boundary. - /// Accepts cookie domains with an optional leading '.' (ignored). - /// Both must be valid domains (after normalizing the cookie domain). - /// - public static bool HostMatchesCookieDomain(ReadOnlySpan host, ReadOnlySpan cookieDomain) + + public static bool HostMatchesCookieDomainCore(ReadOnlySpan host, ReadOnlySpan cookieDomain) { - // Optional: if you expect Unicode, punycode both here (see helper below) - cookieDomain = RemoveLeadingDot(cookieDomain); - if (cookieDomain.Length == 0) return false; - - if (!IsValidDomain(host) || !IsValidDomain(cookieDomain)) - return false; - - // Optional hook: reject public suffixes (requires PSL) - // if (IsPublicSuffix(cookieDomain)) return false; - var hostLabels = new ReverseLabelEnumerator(host); var cookieLabels = new ReverseLabelEnumerator(cookieDomain); @@ -53,6 +38,20 @@ public static bool HostMatchesCookieDomain(ReadOnlySpan host, ReadOnlySpan return !hostLabels.HasRemaining || host[hostLabels.Position + 1] == '.'; } + /// + /// Returns true if ends with on a label boundary. + /// Accepts cookie domains with an optional leading '.' (ignored). + /// Both must be valid domains (after normalizing the cookie domain). + /// + public static bool HostMatchesCookieDomain(ReadOnlySpan host, ReadOnlySpan cookieDomain) + { + cookieDomain = RemoveLeadingDot(cookieDomain); + + if (!IsValidDomain(host) || !IsValidDomain(cookieDomain)) return false; + + return HostMatchesCookieDomainCore(host, cookieDomain); + } + /// /// Picks the most specific matching cookie domain (most labels) from a comma-separated list. /// Accepts items with an optional leading '.' and optional ASCII whitespace around them. @@ -120,15 +119,6 @@ private static bool LabelsEqualIgnoreCase(ReadOnlySpan a, ReadOnlySpan c is >= 'A' and <= 'Z' ? (char)(c + 32) : c; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool ContainsDot(ReadOnlySpan s) - { - foreach (var t in s) - if (t == '.') - return true; - return false; - } } // Your ReverseLabelEnumerator stays as-is From e06b230a536d0b2cce9fea102adfa4ddb3fa1bbf Mon Sep 17 00:00:00 2001 From: hhvrc Date: Fri, 12 Sep 2025 20:53:27 +0200 Subject: [PATCH 52/63] Fix other places --- Common.Tests/Utils/DomainUtilsTests.cs | 14 +++++++------- Common/OpenShockControllerBase.cs | 10 ++++------ Common/Utils/DomainUtils.cs | 6 +++--- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/Common.Tests/Utils/DomainUtilsTests.cs b/Common.Tests/Utils/DomainUtilsTests.cs index 369587dd..d883a79f 100644 --- a/Common.Tests/Utils/DomainUtilsTests.cs +++ b/Common.Tests/Utils/DomainUtilsTests.cs @@ -207,7 +207,7 @@ public async Task HostMatchesCookieDomain_CaseInsensitive_ReturnsTrue() public async Task GetBestMatchingCookieDomain_PicksMostSpecific() { // Arrange - var list = "example.com,foo.example.com,bar.com"; + string[] list = ["example.com","foo.example.com","bar.com"]; // Act var best = DomainUtils.GetBestMatchingCookieDomain("shop.foo.example.com", list); @@ -220,7 +220,7 @@ public async Task GetBestMatchingCookieDomain_PicksMostSpecific() public async Task GetBestMatchingCookieDomain_ExactMatchBeatsShorterSuffix() { // Arrange - var list = "example.com,shop.foo.example.com"; + string[] list = ["example.com","shop.foo.example.com"]; // Act var best = DomainUtils.GetBestMatchingCookieDomain("shop.foo.example.com", list); @@ -233,7 +233,7 @@ public async Task GetBestMatchingCookieDomain_ExactMatchBeatsShorterSuffix() public async Task GetBestMatchingCookieDomain_EmptySegmentsIgnored() { // Arrange - var list = ",,example.com,,foo.example.com,,"; + string[] list = ["","","example.com","","foo.example.com",""]; // Act var best = DomainUtils.GetBestMatchingCookieDomain("shop.foo.example.com", list); @@ -246,7 +246,7 @@ public async Task GetBestMatchingCookieDomain_EmptySegmentsIgnored() public async Task GetBestMatchingCookieDomain_InvalidDomainsIgnored() { // Arrange: includes invalid ".example.com" and "exa_mple.com" - var list = ".example.com,exa_mple.com,example.com"; + string[] list = [".example.com","exa_mple.com","example.com"]; // Act var best = DomainUtils.GetBestMatchingCookieDomain("shop.example.com", list); @@ -259,7 +259,7 @@ public async Task GetBestMatchingCookieDomain_InvalidDomainsIgnored() public async Task GetBestMatchingCookieDomain_NoMatch_ReturnsNull() { // Arrange - var list = "foo.com,bar.net"; + string[] list = ["foo.com","bar.net"]; // Act var best = DomainUtils.GetBestMatchingCookieDomain("example.com", list); @@ -272,7 +272,7 @@ public async Task GetBestMatchingCookieDomain_NoMatch_ReturnsNull() public async Task GetBestMatchingCookieDomain_HostInvalid_ReturnsNull() { // Arrange - var list = "example.com,foo.example.com"; + string[] list = ["example.com","foo.example.com"]; // Act var best = DomainUtils.GetBestMatchingCookieDomain("example", list); @@ -285,7 +285,7 @@ public async Task GetBestMatchingCookieDomain_HostInvalid_ReturnsNull() public async Task GetBestMatchingCookieDomain_WhitespaceNotTrimmedInCurrentImplementation_SkipsEntry() { // Arrange: first entry has spaces (invalid for current impl), second is valid and more specific. - var list = " example.com ,foo.example.com"; + string[] list = [" example.com ","foo.example.com"]; // Use a host that matches *both* example.com and foo.example.com var best = DomainUtils.GetBestMatchingCookieDomain("shop.foo.example.com", list); diff --git a/Common/OpenShockControllerBase.cs b/Common/OpenShockControllerBase.cs index f4cf3e44..0af05698 100644 --- a/Common/OpenShockControllerBase.cs +++ b/Common/OpenShockControllerBase.cs @@ -35,9 +35,9 @@ protected OkObjectResult LegacyEmptyOk(string message = "") } [NonAction] - protected string GetCookieDomains() + protected IReadOnlyCollection GetCookieDomains() { - return HttpContext.RequestServices.GetRequiredService>().Value.CookieDomain; + return HttpContext.RequestServices.GetRequiredService>().Value.CookieDomains; } [NonAction] @@ -71,13 +71,11 @@ protected async Task CreateSession(Guid accountId, string domain) [NonAction] protected void RemoveSessionKeyCookie() { - var domains = GetCookieDomains().AsSpan(); - foreach (var range in domains.Split(',')) + foreach (var domain in GetCookieDomains()) { - var domain = domains[range]; if (!DomainUtils.IsValidDomain(domain)) continue; - var domainStr = "." + domain.ToString(); + var domainStr = "." + domain; HttpContext.Response.Cookies.Append(AuthConstants.UserSessionCookieName, string.Empty, GetCookieOptions(domainStr, TimeSpan.FromDays(-1))); } diff --git a/Common/Utils/DomainUtils.cs b/Common/Utils/DomainUtils.cs index 5f05bba8..d3c92d6a 100644 --- a/Common/Utils/DomainUtils.cs +++ b/Common/Utils/DomainUtils.cs @@ -56,7 +56,7 @@ public static bool HostMatchesCookieDomain(ReadOnlySpan host, ReadOnlySpan /// Picks the most specific matching cookie domain (most labels) from a comma-separated list. /// Accepts items with an optional leading '.' and optional ASCII whitespace around them. /// - public static string? GetBestMatchingCookieDomain(string host, ReadOnlySpan cookieDomainList) + public static string? GetBestMatchingCookieDomain(string host, IReadOnlyCollection cookieDomainList) { ReadOnlySpan hostSpan = host.AsSpan(); if (!IsValidDomain(hostSpan)) return null; @@ -64,9 +64,9 @@ public static bool HostMatchesCookieDomain(ReadOnlySpan host, ReadOnlySpan string? best = null; int bestLabels = -1; - foreach (var range in cookieDomainList.Split(',')) + foreach (var range in cookieDomainList) { - var cd = cookieDomainList[range].Trim(); // trim ASCII whitespace + var cd = range.AsSpan().Trim(); // trim ASCII whitespace if (cd.Length == 0) continue; cd = RemoveLeadingDot(cd); // strip a single leading '.' From 09e924e16f0f6c269c3f9f1a77e6f85976e1b20c Mon Sep 17 00:00:00 2001 From: hhvrc Date: Fri, 12 Sep 2025 22:05:43 +0200 Subject: [PATCH 53/63] Fix cookie remover --- Common/OpenShockControllerBase.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Common/OpenShockControllerBase.cs b/Common/OpenShockControllerBase.cs index 0af05698..da0745b0 100644 --- a/Common/OpenShockControllerBase.cs +++ b/Common/OpenShockControllerBase.cs @@ -74,10 +74,8 @@ protected void RemoveSessionKeyCookie() foreach (var domain in GetCookieDomains()) { if (!DomainUtils.IsValidDomain(domain)) continue; - - var domainStr = "." + domain; - HttpContext.Response.Cookies.Append(AuthConstants.UserSessionCookieName, string.Empty, GetCookieOptions(domainStr, TimeSpan.FromDays(-1))); + HttpContext.Response.Cookies.Append(AuthConstants.UserSessionCookieName, string.Empty, GetCookieOptions(domain, TimeSpan.FromDays(-1))); } } } \ No newline at end of file From e2e55aea085ce31f2472d4e6c1fce68ac04df908 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Fri, 12 Sep 2025 22:36:31 +0200 Subject: [PATCH 54/63] Some other fixes --- API/Controller/OAuth/HandOff.cs | 8 ++-- Common/OpenShockControllerBase.cs | 14 ++----- Common/Utils/DomainUtils.cs | 69 +++++++++++++------------------ 3 files changed, 36 insertions(+), 55 deletions(-) diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index 60bf907a..e0f4a40a 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -29,7 +29,7 @@ public sealed partial class OAuthController public async Task OAuthHandOff( [FromRoute] string provider, [FromServices] IOAuthConnectionService connectionService, - [FromServices] IOptions frontendOptions, + [FromServices] FrontendOptions frontendOptions, CancellationToken cancellationToken) { var result = await ValidateOAuthFlowAsync(); @@ -124,9 +124,9 @@ RedirectResult RedirectFrontendPath(string relativeOrQuery) // if they pass empty string, it redirects to base. var target = relativeOrQuery switch { - "" => frontendOptions.Value.BaseUrl, - _ when relativeOrQuery.StartsWith('?') => new Uri(frontendOptions.Value.BaseUrl, "/" + relativeOrQuery), // force query on root - _ => new Uri(frontendOptions.Value.BaseUrl, relativeOrQuery.StartsWith('/') ? relativeOrQuery : "/" + relativeOrQuery) + "" => frontendOptions.BaseUrl, + _ when relativeOrQuery.StartsWith('?') => new Uri(frontendOptions.BaseUrl, "/" + relativeOrQuery), // force query on root + _ => new Uri(frontendOptions.BaseUrl, relativeOrQuery.StartsWith('/') ? relativeOrQuery : "/" + relativeOrQuery) }; return Redirect(target.ToString()); } diff --git a/Common/OpenShockControllerBase.cs b/Common/OpenShockControllerBase.cs index da0745b0..afb164ff 100644 --- a/Common/OpenShockControllerBase.cs +++ b/Common/OpenShockControllerBase.cs @@ -34,16 +34,11 @@ protected OkObjectResult LegacyEmptyOk(string message = "") return Ok(new LegacyEmptyResponse(message)); } - [NonAction] - protected IReadOnlyCollection GetCookieDomains() - { - return HttpContext.RequestServices.GetRequiredService>().Value.CookieDomains; - } - [NonAction] protected string? GetCurrentCookieDomain() { - return DomainUtils.GetBestMatchingCookieDomain(HttpContext.Request.Host.Host, GetCookieDomains()); + var cookieDomains = HttpContext.RequestServices.GetRequiredService().CookieDomains; + return DomainUtils.GetBestMatchingCookieDomain(HttpContext.Request.Host.Host, cookieDomains); } private static CookieOptions GetCookieOptions(string domain, TimeSpan lifetime) @@ -71,10 +66,9 @@ protected async Task CreateSession(Guid accountId, string domain) [NonAction] protected void RemoveSessionKeyCookie() { - foreach (var domain in GetCookieDomains()) + var cookieDomains = HttpContext.RequestServices.GetRequiredService().CookieDomains; + foreach (var domain in cookieDomains) { - if (!DomainUtils.IsValidDomain(domain)) continue; - HttpContext.Response.Cookies.Append(AuthConstants.UserSessionCookieName, string.Empty, GetCookieOptions(domain, TimeSpan.FromDays(-1))); } } diff --git a/Common/Utils/DomainUtils.cs b/Common/Utils/DomainUtils.cs index d3c92d6a..506ea497 100644 --- a/Common/Utils/DomainUtils.cs +++ b/Common/Utils/DomainUtils.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Runtime.CompilerServices; +using System.Text; namespace OpenShock.Common.Utils; @@ -8,6 +9,14 @@ public static class DomainUtils private static readonly SearchValues ValidLabelChars = SearchValues.Create("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-"); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsValidLabel(ReadOnlySpan label) + { + if ((uint)label.Length is 0 or > 63) return false; + if (label[0] == '-' || label[^1] == '-') return false; + return !label.ContainsAnyExcept(ValidLabelChars); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsValidDomain(ReadOnlySpan str) { @@ -22,7 +31,13 @@ public static bool IsValidDomain(ReadOnlySpan str) return true; } - public static bool HostMatchesCookieDomainCore(ReadOnlySpan host, ReadOnlySpan cookieDomain) + /// + /// Doesnt accept leading '.' + /// + /// + /// + /// + private static bool HostMatchesCookieDomainCore(ReadOnlySpan host, ReadOnlySpan cookieDomain) { var hostLabels = new ReverseLabelEnumerator(host); var cookieLabels = new ReverseLabelEnumerator(cookieDomain); @@ -30,12 +45,10 @@ public static bool HostMatchesCookieDomainCore(ReadOnlySpan host, ReadOnly while (cookieLabels.MoveNext()) { if (!hostLabels.MoveNext()) return false; - if (!LabelsEqualIgnoreCase(hostLabels.Current, cookieLabels.Current)) - return false; + if (!Ascii.EqualsIgnoreCase(hostLabels.Current, cookieLabels.Current)) return false; } - // Boundary check - return !hostLabels.HasRemaining || host[hostLabels.Position + 1] == '.'; + return true; // Even if host has more labels, the cookiedomain will match it } /// @@ -69,12 +82,11 @@ public static bool HostMatchesCookieDomain(ReadOnlySpan host, ReadOnlySpan var cd = range.AsSpan().Trim(); // trim ASCII whitespace if (cd.Length == 0) continue; - cd = RemoveLeadingDot(cd); // strip a single leading '.' + cd = RemoveLeadingDot(cd); if (!IsValidDomain(cd)) continue; - // if (IsPublicSuffix(cd)) continue; - if (!HostMatchesCookieDomain(hostSpan, cd)) continue; + if (!HostMatchesCookieDomainCore(hostSpan, cd)) continue; int labels = cd.Count('.') + 1; if (labels <= bestLabels) continue; @@ -96,58 +108,33 @@ private static ReadOnlySpan RemoveLeadingDot(ReadOnlySpan cd) cd = cd[1..]; return cd; } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsValidLabel(ReadOnlySpan label) - { - if ((uint)label.Length is 0 or > 63) return false; - if (label[0] == '-' || label[^1] == '-') return false; - return !label.ContainsAnyExcept(ValidLabelChars); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool LabelsEqualIgnoreCase(ReadOnlySpan a, ReadOnlySpan b) - { - if (a.Length != b.Length) return false; - for (int i = 0; i < a.Length; i++) - { - if (ToLowerAscii(a[i]) != ToLowerAscii(b[i])) return false; - } - return true; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static char ToLowerAscii(char c) - => c is >= 'A' and <= 'Z' ? (char)(c + 32) : c; } // Your ReverseLabelEnumerator stays as-is file ref struct ReverseLabelEnumerator { private readonly ReadOnlySpan _span; + private int _position; public ReverseLabelEnumerator(ReadOnlySpan span) { _span = span; - Position = span.Length - 1; + _position = span.Length - 1; Current = default; } public ReadOnlySpan Current { get; private set; } - public int Position { get; private set; } - - public bool HasRemaining => Position >= 0; public bool MoveNext() { - if (Position < 0) return false; + if (_position < 0) return false; - int end = Position; - while (Position >= 0 && _span[Position] != '.') Position--; + int end = _position; + while (_position >= 0 && _span[_position] != '.') _position--; - if (end == Position) return false; // empty label - Current = _span.Slice(Position + 1, end - Position); - Position--; // move to char before the dot + if (end == _position) return false; // empty label + Current = _span.Slice(_position + 1, end - _position); + _position--; // move to char before the dot return true; } } From 55247b01aa39165c5b37452bd37de682bd3f89d9 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Fri, 12 Sep 2025 22:44:19 +0200 Subject: [PATCH 55/63] Update DomainUtils.cs --- Common/Utils/DomainUtils.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Common/Utils/DomainUtils.cs b/Common/Utils/DomainUtils.cs index 506ea497..f539adc1 100644 --- a/Common/Utils/DomainUtils.cs +++ b/Common/Utils/DomainUtils.cs @@ -71,7 +71,7 @@ public static bool HostMatchesCookieDomain(ReadOnlySpan host, ReadOnlySpan /// public static string? GetBestMatchingCookieDomain(string host, IReadOnlyCollection cookieDomainList) { - ReadOnlySpan hostSpan = host.AsSpan(); + var hostSpan = host.AsSpan(); if (!IsValidDomain(hostSpan)) return null; string? best = null; From 240d9e90b068cf48996855bf261b9d2dff76ebef Mon Sep 17 00:00:00 2001 From: hhvrc Date: Fri, 12 Sep 2025 22:49:58 +0200 Subject: [PATCH 56/63] Make discord OAuth2 options optional --- API/Program.cs | 42 +++++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/API/Program.cs b/API/Program.cs index de0ce20f..48b46002 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -30,34 +30,38 @@ builder.Services .AddOpenShockMemDB(redisOptions) .AddOpenShockDB(databaseOptions) - .AddOpenShockServices(auth => auth - .AddCookie(OAuthConstants.FlowScheme, o => - { + .AddOpenShockServices(auth => + { + auth.AddCookie(OAuthConstants.FlowScheme, o => { o.Cookie.Name = OAuthConstants.FlowCookieName; o.ExpireTimeSpan = TimeSpan.FromMinutes(10); o.SlidingExpiration = false; - }) - .AddDiscord(OAuthConstants.DiscordScheme, o => + }); + + var options = builder.Configuration.GetSection(DiscordOAuthOptions.SectionName).Get(); + if (options is not null) { - o.SignInScheme = OAuthConstants.FlowScheme; + auth.AddDiscord(OAuthConstants.DiscordScheme, o => { + o.SignInScheme = OAuthConstants.FlowScheme; - var options = builder.Configuration.GetRequiredSection(DiscordOAuthOptions.SectionName).Get()!; + - o.ClientId = options.ClientId; - o.ClientSecret = options.ClientSecret; - o.CallbackPath = options.CallbackPath; - o.AccessDeniedPath = options.AccessDeniedPath; - foreach (var scope in options.Scopes) o.Scope.Add(scope); + o.ClientId = options.ClientId; + o.ClientSecret = options.ClientSecret; + o.CallbackPath = options.CallbackPath; + o.AccessDeniedPath = options.AccessDeniedPath; + foreach (var scope in options.Scopes) o.Scope.Add(scope); - o.Prompt = "none"; - o.SaveTokens = false; + o.Prompt = "none"; + o.SaveTokens = false; - o.ClaimActions.MapJsonKey(OAuthConstants.ClaimEmailVerified, "verified"); - o.ClaimActions.MapJsonKey(OAuthConstants.ClaimDisplayName, "global_name"); + o.ClaimActions.MapJsonKey(OAuthConstants.ClaimEmailVerified, "verified"); + o.ClaimActions.MapJsonKey(OAuthConstants.ClaimDisplayName, "global_name"); - o.Validate(); - }) - ) + o.Validate(); + }); + } + }) .AddOpenShockSignalR(redisOptions); builder.Services.AddScoped(); From 074281407fe5805f46625d15d9aab9e097bfdeb8 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Fri, 12 Sep 2025 23:49:11 +0200 Subject: [PATCH 57/63] Final touchups? --- API/Controller/OAuth/HandOff.cs | 49 +++++++++++++++----------- API/Controller/OAuth/SignupFinalize.cs | 29 +++++++++++++-- 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index e0f4a40a..19be1bbd 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -65,7 +65,7 @@ public async Task OAuthHandOff( if (connection is null) { // No connection -> continue to CREATE flow on frontend - return RedirectFrontendPath($"oauth/{provider}/create"); + return RedirectFrontendPath($"/oauth/{Uri.EscapeDataString(provider)}/create"); } // Direct sign-in @@ -76,13 +76,13 @@ public async Task OAuthHandOff( return RedirectFrontendError("internalError"); } - await CreateSession(connection.UserId, "." + domain); + await CreateSession(connection.UserId, domain); // Flow cookie no longer needed await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); // TODO: optionally send to a specific frontend route - return RedirectFrontendPath(""); + return RedirectFrontendPath("/home"); } case OAuthFlow.Link: @@ -96,19 +96,31 @@ public async Task OAuthHandOff( if (connection is not null) { await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - - return RedirectFrontendError(connection.UserId == userId ? "alreadyLinked" : "linkedToAnotherAccount"); + return RedirectFrontendConnections(connection.UserId == userId ? "alreadyLinked" : "linkedToAnotherAccount"); } - bool ok =await connectionService.TryAddConnectionAsync(userId, provider, auth.ExternalAccountId, auth.ExternalAccountName, cancellationToken); + var ok = await connectionService.TryAddConnectionAsync(userId, provider, auth.ExternalAccountId, auth.ExternalAccountName, cancellationToken); if (!ok) { - + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return RedirectFrontendConnections("linkFailed"); + } + + // Direct sign-in + var domain = GetCurrentCookieDomain(); + if (string.IsNullOrEmpty(domain)) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return RedirectFrontendConnections("internalError"); } - // No connection -> continue to LINK flow on frontend. - // IMPORTANT: keep the flow cookie so frontend can finalize with it. - return RedirectFrontendPath($"oauth/{provider}/link"); + await CreateSession(userId, domain); + + // Flow cookie no longer needed + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + + // TODO: optionally send to a specific frontend route + return RedirectFrontendConnections("linked"); } default: @@ -118,20 +130,15 @@ public async Task OAuthHandOff( // --- helpers --- - RedirectResult RedirectFrontendPath(string relativeOrQuery) + RedirectResult RedirectFrontendPath(string path) { - // If caller passes only query (e.g. "oauth/error?error=x"), it still works; - // if they pass empty string, it redirects to base. - var target = relativeOrQuery switch - { - "" => frontendOptions.BaseUrl, - _ when relativeOrQuery.StartsWith('?') => new Uri(frontendOptions.BaseUrl, "/" + relativeOrQuery), // force query on root - _ => new Uri(frontendOptions.BaseUrl, relativeOrQuery.StartsWith('/') ? relativeOrQuery : "/" + relativeOrQuery) - }; - return Redirect(target.ToString()); + return Redirect(new Uri(frontendOptions.BaseUrl, path).ToString()); } RedirectResult RedirectFrontendError(string errorType) - => RedirectFrontendPath($"oauth/error?error={Uri.EscapeDataString(errorType)}"); + => RedirectFrontendPath($"/oauth/error?error={Uri.EscapeDataString(errorType)}"); + + RedirectResult RedirectFrontendConnections(string statusType) + => RedirectFrontendPath($"/settings/connections?status={Uri.EscapeDataString(statusType)}"); } } diff --git a/API/Controller/OAuth/SignupFinalize.cs b/API/Controller/OAuth/SignupFinalize.cs index 184e1252..f3b0872c 100644 --- a/API/Controller/OAuth/SignupFinalize.cs +++ b/API/Controller/OAuth/SignupFinalize.cs @@ -66,15 +66,18 @@ public async Task OAuthSignupFinalize( // External identity basics from claims (added by your handler) var externalId = auth.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; var externalAccountName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value; - var email = body.Email ?? auth.Principal.FindFirst(ClaimTypes.Email)?.Value; - var displayName = string.IsNullOrEmpty(body.Username) ? externalAccountName : body.Username; - var isEmailTrusted = auth.Principal.FindFirst(OAuthConstants.ClaimEmailVerified)?.Value.Equals("true", StringComparison.InvariantCultureIgnoreCase) ?? false; + var externalAccountEmail = auth.Principal.FindFirst(ClaimTypes.Email)?.Value; + var displayName = body.Username ?? externalAccountName; + var email = body.Email ?? externalAccountEmail; if (string.IsNullOrEmpty(externalId) || string.IsNullOrEmpty(email) || string.IsNullOrEmpty(displayName)) { await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return Problem(OAuthError.FlowMissingData); } + + bool externalTrustsEmail = IsTruthy(auth.Principal.FindFirst(OAuthConstants.ClaimEmailVerified)?.Value); + var isEmailTrusted = CanEmailBeTrusted(email, auth.Provider, externalAccountEmail, externalTrustsEmail); // Do not allow creation if this external is already linked anywhere. var existing = await connectionService.GetByProviderExternalIdAsync(provider, externalId, cancellationToken); @@ -120,5 +123,25 @@ public async Task OAuthSignupFinalize( ExternalId = externalId, Username = newUser.Value.Name }); + + static bool CanEmailBeTrusted(string email, string provider, string? externalEmail, bool externalEmailTrust) + { + if (!externalEmailTrust || externalEmail == null || email != externalEmail) return false; + + if (provider is not ("discord" or "google")) return false; + + return true; + } + + static bool IsTruthy(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + return value.Trim().ToLowerInvariant() switch + { + "0" or "no" or "false" => false, + "1" or "yes" or "true" => true, + _ => false + }; + } } } From fda853e2271dc644d0327cc3c81175ff90aa112f Mon Sep 17 00:00:00 2001 From: hhvrc Date: Sat, 13 Sep 2025 00:08:50 +0200 Subject: [PATCH 58/63] Update AccountService.cs --- API/Services/Account/AccountService.cs | 41 +++++++++++++++++++++----- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index b1db856d..5cccf174 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -144,6 +144,9 @@ public async Task, AccountWithEmailOrUsernameExists>> Create await using var tx = await _db.Database.BeginTransactionAsync(); + string? activationToken = null; + Guid userId; + try { var user = new User @@ -151,13 +154,27 @@ public async Task, AccountWithEmailOrUsernameExists>> Create Id = Guid.CreateVersion7(), Name = username, Email = email, - PasswordHash = null, // OAuth-only account - ActivatedAt = DateTime.UtcNow // no activation flow + PasswordHash = null, + ActivatedAt = isEmailTrusted ? DateTime.UtcNow : null }; _db.Users.Add(user); await _db.SaveChangesAsync(); + // If email isn't trusted, create an activation request (email verification) + if (!isEmailTrusted) + { + activationToken = CryptoUtils.RandomString(AuthConstants.GeneratedTokenLength); + + user.UserActivationRequest = new UserActivationRequest + { + UserId = user.Id, + TokenHash = HashingUtils.HashToken(activationToken) + }; + + await _db.SaveChangesAsync(); + } + // Link external identity _db.UserOAuthConnections.Add(new UserOAuthConnection { @@ -167,27 +184,37 @@ public async Task, AccountWithEmailOrUsernameExists>> Create DisplayName = providerAccountName }); + // Tidy-up if clock skew makes CreatedAt > ActivatedAt (trusted case only) + if (user.ActivatedAt is not null && user.CreatedAt > user.ActivatedAt) + { + user.ActivatedAt = user.CreatedAt; + } + await _db.SaveChangesAsync(); + userId = user.Id; + await tx.CommitAsync(); - // Ensure ActivatedAt <= CreatedAt (optional monotonic tidy-up) - if (user.CreatedAt > user.ActivatedAt) + // Send verification email only after successful commit + if (!isEmailTrusted && activationToken is not null) { - user.ActivatedAt = user.CreatedAt; - await _db.SaveChangesAsync(); + await _emailService.VerifyEmail( + new Contact(email, username), + new Uri(_frontendConfig.BaseUrl, $"/#/account/activate/{userId}/{activationToken}") + ); } return new Success(user); } catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" }) { - // Unique constraint hit: either username/email already exists, or (provider, externalId) is already linked. await tx.RollbackAsync(); return new AccountWithEmailOrUsernameExists(); } } + public async Task TryActivateAccountAsync(string secret, CancellationToken cancellationToken = default) { var hash = HashingUtils.HashToken(secret); From ffd35934fcb0f3c88d0a82b0b2d7c46ae045b97d Mon Sep 17 00:00:00 2001 From: hhvrc Date: Sat, 13 Sep 2025 01:31:16 +0200 Subject: [PATCH 59/63] Clean up more --- API/Models/Requests/OAuthFinalizeRequest.cs | 6 +----- API/Models/Response/OAuthFinalizeResponse.cs | 5 +---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/API/Models/Requests/OAuthFinalizeRequest.cs b/API/Models/Requests/OAuthFinalizeRequest.cs index 72f73a4c..75044ba1 100644 --- a/API/Models/Requests/OAuthFinalizeRequest.cs +++ b/API/Models/Requests/OAuthFinalizeRequest.cs @@ -1,13 +1,9 @@ -using OpenShock.API.OAuth; -using OpenShock.Common.DataAnnotations; +using OpenShock.Common.DataAnnotations; namespace OpenShock.API.Models.Requests; public sealed class OAuthFinalizeRequest { - /// Action to perform: "create" or "link". - public required OAuthFlow Action { get; init; } - /// Desired username (create only). If omitted, a name will be generated from the external profile. [Username(true)] public required string? Username { get; init; } diff --git a/API/Models/Response/OAuthFinalizeResponse.cs b/API/Models/Response/OAuthFinalizeResponse.cs index 25f57840..d21b6a4e 100644 --- a/API/Models/Response/OAuthFinalizeResponse.cs +++ b/API/Models/Response/OAuthFinalizeResponse.cs @@ -2,9 +2,6 @@ public sealed class OAuthFinalizeResponse { - /// "ok" on success; otherwise not returned (problem details emitted). - public string Status { get; init; } = "ok"; - /// The provider key that was processed. public required string Provider { get; init; } @@ -12,5 +9,5 @@ public sealed class OAuthFinalizeResponse public required string ExternalId { get; init; } /// When action=create, the username of the newly created account. - public string? Username { get; init; } + public required string? Username { get; init; } } \ No newline at end of file From ba7f28ff76b0c6886578453e450d3a57059246dc Mon Sep 17 00:00:00 2001 From: hhvrc Date: Sun, 14 Sep 2025 17:40:48 +0200 Subject: [PATCH 60/63] More touchups --- API/Controller/OAuth/HandOff.cs | 4 -- API/Controller/OAuth/SignupFinalize.cs | 47 +++++++------------- API/Controller/OAuth/SignupGetData.cs | 11 +++-- API/Models/Response/LoginV2OkResponse.cs | 2 + API/Models/Response/OAuthFinalizeResponse.cs | 13 ------ API/OAuth/OAuthConstants.cs | 1 + API/Program.cs | 3 +- API/Services/Account/AccountService.cs | 22 ++++----- 8 files changed, 38 insertions(+), 65 deletions(-) delete mode 100644 API/Models/Response/OAuthFinalizeResponse.cs diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index 19be1bbd..83be56dc 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -1,13 +1,11 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using Microsoft.Extensions.Options; using OpenShock.API.Services.OAuthConnection; using OpenShock.Common.Options; using OpenShock.Common.Problems; using System.Net.Mime; using OpenShock.API.OAuth; -using OpenShock.Common.Services.Session; using OpenShock.Common.Utils; namespace OpenShock.API.Controller.OAuth; @@ -81,7 +79,6 @@ public async Task OAuthHandOff( // Flow cookie no longer needed await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - // TODO: optionally send to a specific frontend route return RedirectFrontendPath("/home"); } @@ -119,7 +116,6 @@ public async Task OAuthHandOff( // Flow cookie no longer needed await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - // TODO: optionally send to a specific frontend route return RedirectFrontendConnections("linked"); } diff --git a/API/Controller/OAuth/SignupFinalize.cs b/API/Controller/OAuth/SignupFinalize.cs index f3b0872c..2b459cd6 100644 --- a/API/Controller/OAuth/SignupFinalize.cs +++ b/API/Controller/OAuth/SignupFinalize.cs @@ -32,7 +32,7 @@ public sealed partial class OAuthController /// External already linked or username/email already exists. [EnableRateLimiting("auth")] [HttpPost("{provider}/signup-finalize")] - [ProducesResponseType(typeof(OAuthFinalizeResponse), StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(LoginV2OkResponse), StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)] [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status409Conflict, MediaTypeNames.Application.Json)] public async Task OAuthSignupFinalize( @@ -41,6 +41,14 @@ public async Task OAuthSignupFinalize( [FromServices] IOAuthConnectionService connectionService, CancellationToken cancellationToken) { + // If domain is not supported for cookies, cancel the flow + var domain = GetCurrentCookieDomain(); + if (string.IsNullOrEmpty(domain)) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return Problem(OAuthError.InternalError); + } + var result = await ValidateOAuthFlowAsync(); if (!result.TryPickT0(out var auth, out var error)) { @@ -70,14 +78,13 @@ public async Task OAuthSignupFinalize( var displayName = body.Username ?? externalAccountName; var email = body.Email ?? externalAccountEmail; - if (string.IsNullOrEmpty(externalId) || string.IsNullOrEmpty(email) || string.IsNullOrEmpty(displayName)) + if (string.IsNullOrWhiteSpace(externalId) || string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(displayName)) { - await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return Problem(OAuthError.FlowMissingData); } - - bool externalTrustsEmail = IsTruthy(auth.Principal.FindFirst(OAuthConstants.ClaimEmailVerified)?.Value); - var isEmailTrusted = CanEmailBeTrusted(email, auth.Provider, externalAccountEmail, externalTrustsEmail); + + var isVerifiedString = auth.Principal.FindFirst(OAuthConstants.ClaimEmailVerified)?.Value; + var isEmailTrusted = IsTruthy(isVerifiedString) && string.Equals(externalAccountEmail, email, StringComparison.InvariantCultureIgnoreCase); // Do not allow creation if this external is already linked anywhere. var existing = await connectionService.GetByProviderExternalIdAsync(provider, externalId, cancellationToken); @@ -96,42 +103,20 @@ public async Task OAuthSignupFinalize( isEmailTrusted ); - if (!created.TryPickT0(out var newUser, out var _)) + if (!created.TryPickT0(out var newUser, out _)) { // Username or email already exists — conflict. // Do NOT clear the flow cookie so the frontend can retry with a different username. return Problem(SignupError.UsernameOrEmailExists); } - // --- Authenticate the client (create session + set session cookie) --- - var domain = GetCurrentCookieDomain(); - if (string.IsNullOrEmpty(domain)) - { - await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Problem(OAuthError.InternalError); - } - + // Authenticate the client (create session and set session cookie) await CreateSession(newUser.Value.Id, domain); - // -------------------------------------------------------------------- // Clear the temporary OAuth flow cookie. await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Ok(new OAuthFinalizeResponse - { - Provider = provider, - ExternalId = externalId, - Username = newUser.Value.Name - }); - - static bool CanEmailBeTrusted(string email, string provider, string? externalEmail, bool externalEmailTrust) - { - if (!externalEmailTrust || externalEmail == null || email != externalEmail) return false; - - if (provider is not ("discord" or "google")) return false; - - return true; - } + return Ok(LoginV2OkResponse.FromUser(newUser.Value)); static bool IsTruthy(string? value) { diff --git a/API/Controller/OAuth/SignupGetData.cs b/API/Controller/OAuth/SignupGetData.cs index e83efd19..51fe6ac0 100644 --- a/API/Controller/OAuth/SignupGetData.cs +++ b/API/Controller/OAuth/SignupGetData.cs @@ -45,16 +45,21 @@ public async Task OAuthSignupGetData([FromRoute] string provider) }; } - if (auth.Provider != provider || auth.Flow != OAuthFlow.LoginOrCreate) + if (auth.Provider != provider) { - return Problem(OAuthError.AnonymousOnlyEndpoint); + return Problem(OAuthError.ProviderMismatch); + } + + if (auth.Flow != OAuthFlow.LoginOrCreate) + { + return Problem(OAuthError.FlowMismatch); } return Ok(new OAuthSignupDataResponse { Provider = auth.Provider, Email = auth.Principal.FindFirst(ClaimTypes.Email)?.Value, - DisplayName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value, + DisplayName = auth.Principal.FindFirst(OAuthConstants.ClaimDisplayName)?.Value ?? auth.Principal.FindFirst(OAuthConstants.ClaimUserName)?.Value, ExpiresAt = auth.Properties.ExpiresUtc!.Value.UtcDateTime }); } diff --git a/API/Models/Response/LoginV2OkResponse.cs b/API/Models/Response/LoginV2OkResponse.cs index 6a487afb..763493c6 100644 --- a/API/Models/Response/LoginV2OkResponse.cs +++ b/API/Models/Response/LoginV2OkResponse.cs @@ -9,6 +9,7 @@ public sealed class LoginV2OkResponse public required Guid AccountId { get; init; } public required string AccountName { get; init; } public required string AccountEmail { get; init; } + public required bool IsVerified { get; init; } public required Uri ProfileImage { get; init; } public required List AccountRoles { get; init; } @@ -17,6 +18,7 @@ public sealed class LoginV2OkResponse AccountId = argUser.Id, AccountName = argUser.Name, AccountEmail = argUser.Email, + IsVerified = argUser.ActivatedAt is not null, ProfileImage = argUser.GetImageUrl(), AccountRoles = argUser.Roles }; diff --git a/API/Models/Response/OAuthFinalizeResponse.cs b/API/Models/Response/OAuthFinalizeResponse.cs deleted file mode 100644 index d21b6a4e..00000000 --- a/API/Models/Response/OAuthFinalizeResponse.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace OpenShock.API.Models.Response; - -public sealed class OAuthFinalizeResponse -{ - /// The provider key that was processed. - public required string Provider { get; init; } - - /// The external account id that was linked. - public required string ExternalId { get; init; } - - /// When action=create, the username of the newly created account. - public required string? Username { get; init; } -} \ No newline at end of file diff --git a/API/OAuth/OAuthConstants.cs b/API/OAuth/OAuthConstants.cs index 4bae08a4..c4552032 100644 --- a/API/OAuth/OAuthConstants.cs +++ b/API/OAuth/OAuthConstants.cs @@ -11,5 +11,6 @@ public static class OAuthConstants public const string ItemKeyFlowType = ".FlowType"; public const string ClaimEmailVerified = "openshock.oauth.email_verified"; + public const string ClaimUserName = "openshock.oauth.user_name"; public const string ClaimDisplayName = "openshock.oauth.display_name"; } \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 48b46002..454f9ca6 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -56,7 +56,8 @@ o.SaveTokens = false; o.ClaimActions.MapJsonKey(OAuthConstants.ClaimEmailVerified, "verified"); - o.ClaimActions.MapJsonKey(OAuthConstants.ClaimDisplayName, "global_name"); + o.ClaimActions.MapJsonKey(OAuthConstants.ClaimDisplayName, "username"); + o.ClaimActions.MapJsonKey(OAuthConstants.ClaimUserName, "global_name"); o.Validate(); }); diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 5cccf174..fb453ca9 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -145,17 +145,19 @@ public async Task, AccountWithEmailOrUsernameExists>> Create await using var tx = await _db.Database.BeginTransactionAsync(); string? activationToken = null; - Guid userId; try { + var creationTime = DateTime.UtcNow; + var user = new User { Id = Guid.CreateVersion7(), Name = username, Email = email, PasswordHash = null, - ActivatedAt = isEmailTrusted ? DateTime.UtcNow : null + CreatedAt = creationTime, + ActivatedAt = isEmailTrusted ? creationTime : null }; _db.Users.Add(user); @@ -169,7 +171,8 @@ public async Task, AccountWithEmailOrUsernameExists>> Create user.UserActivationRequest = new UserActivationRequest { UserId = user.Id, - TokenHash = HashingUtils.HashToken(activationToken) + TokenHash = HashingUtils.HashToken(activationToken), + CreatedAt = creationTime }; await _db.SaveChangesAsync(); @@ -181,19 +184,12 @@ public async Task, AccountWithEmailOrUsernameExists>> Create UserId = user.Id, ProviderKey = provider, ExternalId = providerAccountId, - DisplayName = providerAccountName + DisplayName = providerAccountName, + CreatedAt = creationTime, }); - // Tidy-up if clock skew makes CreatedAt > ActivatedAt (trusted case only) - if (user.ActivatedAt is not null && user.CreatedAt > user.ActivatedAt) - { - user.ActivatedAt = user.CreatedAt; - } - await _db.SaveChangesAsync(); - userId = user.Id; - await tx.CommitAsync(); // Send verification email only after successful commit @@ -201,7 +197,7 @@ public async Task, AccountWithEmailOrUsernameExists>> Create { await _emailService.VerifyEmail( new Contact(email, username), - new Uri(_frontendConfig.BaseUrl, $"/#/account/activate/{userId}/{activationToken}") + new Uri(_frontendConfig.BaseUrl, $"/#/account/activate/{user.Id}/{activationToken}") ); } From a8da0197b7f558a73dd850d0db69351896564dfd Mon Sep 17 00:00:00 2001 From: hhvrc Date: Sun, 14 Sep 2025 17:51:16 +0200 Subject: [PATCH 61/63] Don't sign in if email unverified --- API/Controller/OAuth/SignupFinalize.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/API/Controller/OAuth/SignupFinalize.cs b/API/Controller/OAuth/SignupFinalize.cs index 2b459cd6..97fc9396 100644 --- a/API/Controller/OAuth/SignupFinalize.cs +++ b/API/Controller/OAuth/SignupFinalize.cs @@ -110,8 +110,11 @@ public async Task OAuthSignupFinalize( return Problem(SignupError.UsernameOrEmailExists); } - // Authenticate the client (create session and set session cookie) - await CreateSession(newUser.Value.Id, domain); + // Authenticate the client if its activated (create session and set session cookie) + if (newUser.Value.ActivatedAt is not null) + { + await CreateSession(newUser.Value.Id, domain); + } // Clear the temporary OAuth flow cookie. await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); From 337f395b9efadc6bbbd7daeef02388c99e29290f Mon Sep 17 00:00:00 2001 From: hhvrc Date: Sun, 14 Sep 2025 18:09:18 +0200 Subject: [PATCH 62/63] Fix name and displayname resolution --- API/Controller/OAuth/HandOff.cs | 2 +- API/Controller/OAuth/SignupFinalize.cs | 14 ++++++-------- API/Controller/OAuth/_ApiController.cs | 7 +++---- API/OAuth/ValidatedFlowContext.cs | 2 +- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index 83be56dc..0e2cb322 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -96,7 +96,7 @@ public async Task OAuthHandOff( return RedirectFrontendConnections(connection.UserId == userId ? "alreadyLinked" : "linkedToAnotherAccount"); } - var ok = await connectionService.TryAddConnectionAsync(userId, provider, auth.ExternalAccountId, auth.ExternalAccountName, cancellationToken); + var ok = await connectionService.TryAddConnectionAsync(userId, provider, auth.ExternalAccountId, auth.ExternalAccountDisplayName ?? auth.ExternalAccountName, cancellationToken); if (!ok) { await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); diff --git a/API/Controller/OAuth/SignupFinalize.cs b/API/Controller/OAuth/SignupFinalize.cs index 97fc9396..67a8c97e 100644 --- a/API/Controller/OAuth/SignupFinalize.cs +++ b/API/Controller/OAuth/SignupFinalize.cs @@ -72,13 +72,11 @@ public async Task OAuthSignupFinalize( } // External identity basics from claims (added by your handler) - var externalId = auth.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; - var externalAccountName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value; var externalAccountEmail = auth.Principal.FindFirst(ClaimTypes.Email)?.Value; - var displayName = body.Username ?? externalAccountName; + var username = body.Username ?? auth.ExternalAccountDisplayName ?? auth.ExternalAccountName; var email = body.Email ?? externalAccountEmail; - if (string.IsNullOrWhiteSpace(externalId) || string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(displayName)) + if (string.IsNullOrWhiteSpace(auth.ExternalAccountId) || string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(username)) { return Problem(OAuthError.FlowMissingData); } @@ -87,7 +85,7 @@ public async Task OAuthSignupFinalize( var isEmailTrusted = IsTruthy(isVerifiedString) && string.Equals(externalAccountEmail, email, StringComparison.InvariantCultureIgnoreCase); // Do not allow creation if this external is already linked anywhere. - var existing = await connectionService.GetByProviderExternalIdAsync(provider, externalId, cancellationToken); + var existing = await connectionService.GetByProviderExternalIdAsync(provider, auth.ExternalAccountId, cancellationToken); if (existing is not null) { await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); @@ -96,10 +94,10 @@ public async Task OAuthSignupFinalize( var created = await _accountService.CreateOAuthOnlyAccountAsync( email, - displayName, + username, provider, - externalId, - externalAccountName, + auth.ExternalAccountId, + auth.ExternalAccountDisplayName ?? auth.ExternalAccountName, isEmailTrusted ); diff --git a/API/Controller/OAuth/_ApiController.cs b/API/Controller/OAuth/_ApiController.cs index b9688adf..b4692b9e 100644 --- a/API/Controller/OAuth/_ApiController.cs +++ b/API/Controller/OAuth/_ApiController.cs @@ -36,7 +36,7 @@ private enum OAuthValidationError } /// - /// Validates: provider exists, temp cookie auth present, scNOheme matches, flow parsable. + /// Validates: provider exists, temp cookie auth present, scheme matches, flow parsable. /// On success returns ValidatedFlowContext; on failure returns IActionResult with proper problem details. /// private async Task> ValidateOAuthFlowAsync() @@ -76,13 +76,12 @@ private async Task> ValidateOA return OAuthValidationError.FlowDataMissingOrInvalid; } - - return new ValidatedFlowContext( Provider: actualScheme, Flow: flow, ExternalAccountId: externalId, - ExternalAccountName: auth.Principal.FindFirst(ClaimTypes.Name)?.Value, + ExternalAccountName: auth.Principal.FindFirst(OAuthConstants.ClaimUserName)?.Value, + ExternalAccountDisplayName: auth.Principal.FindFirst(OAuthConstants.ClaimDisplayName)?.Value, Principal: auth.Principal, Properties: auth.Properties ); diff --git a/API/OAuth/ValidatedFlowContext.cs b/API/OAuth/ValidatedFlowContext.cs index d276ec9c..3bca965f 100644 --- a/API/OAuth/ValidatedFlowContext.cs +++ b/API/OAuth/ValidatedFlowContext.cs @@ -3,4 +3,4 @@ namespace OpenShock.API.OAuth; -public sealed record ValidatedFlowContext(string Provider, OAuthFlow Flow, string ExternalAccountId, string? ExternalAccountName, ClaimsPrincipal Principal, AuthenticationProperties Properties); \ No newline at end of file +public sealed record ValidatedFlowContext(string Provider, OAuthFlow Flow, string ExternalAccountId, string? ExternalAccountName, string? ExternalAccountDisplayName, ClaimsPrincipal Principal, AuthenticationProperties Properties); \ No newline at end of file From ce18b73a572a03665b5974d67ebb787cd90e5a04 Mon Sep 17 00:00:00 2001 From: hhvrc Date: Mon, 15 Sep 2025 07:29:45 +0200 Subject: [PATCH 63/63] Make requested changes --- API/Controller/OAuth/SignupGetData.cs | 2 +- API/Controller/OAuth/_ApiController.cs | 7 +++++-- API/OAuth/OAuthConstants.cs | 3 +-- API/Options/OAuth/DiscordOAuthOptions.cs | 3 --- API/Program.cs | 9 ++++----- 5 files changed, 11 insertions(+), 13 deletions(-) diff --git a/API/Controller/OAuth/SignupGetData.cs b/API/Controller/OAuth/SignupGetData.cs index 51fe6ac0..fdfe80af 100644 --- a/API/Controller/OAuth/SignupGetData.cs +++ b/API/Controller/OAuth/SignupGetData.cs @@ -59,7 +59,7 @@ public async Task OAuthSignupGetData([FromRoute] string provider) { Provider = auth.Provider, Email = auth.Principal.FindFirst(ClaimTypes.Email)?.Value, - DisplayName = auth.Principal.FindFirst(OAuthConstants.ClaimDisplayName)?.Value ?? auth.Principal.FindFirst(OAuthConstants.ClaimUserName)?.Value, + DisplayName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value ?? auth.Principal.FindFirst(OAuthConstants.ClaimGlobalName)?.Value, ExpiresAt = auth.Properties.ExpiresUtc!.Value.UtcDateTime }); } diff --git a/API/Controller/OAuth/_ApiController.cs b/API/Controller/OAuth/_ApiController.cs index b4692b9e..5da612d1 100644 --- a/API/Controller/OAuth/_ApiController.cs +++ b/API/Controller/OAuth/_ApiController.cs @@ -75,13 +75,16 @@ private async Task> ValidateOA await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return OAuthValidationError.FlowDataMissingOrInvalid; } + + string? displayName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value; + string? globalName = auth.Principal.FindFirst(OAuthConstants.ClaimGlobalName)?.Value; return new ValidatedFlowContext( Provider: actualScheme, Flow: flow, ExternalAccountId: externalId, - ExternalAccountName: auth.Principal.FindFirst(OAuthConstants.ClaimUserName)?.Value, - ExternalAccountDisplayName: auth.Principal.FindFirst(OAuthConstants.ClaimDisplayName)?.Value, + ExternalAccountName: globalName ?? displayName, + ExternalAccountDisplayName: displayName ?? globalName, Principal: auth.Principal, Properties: auth.Properties ); diff --git a/API/OAuth/OAuthConstants.cs b/API/OAuth/OAuthConstants.cs index c4552032..0ca755c3 100644 --- a/API/OAuth/OAuthConstants.cs +++ b/API/OAuth/OAuthConstants.cs @@ -11,6 +11,5 @@ public static class OAuthConstants public const string ItemKeyFlowType = ".FlowType"; public const string ClaimEmailVerified = "openshock.oauth.email_verified"; - public const string ClaimUserName = "openshock.oauth.user_name"; - public const string ClaimDisplayName = "openshock.oauth.display_name"; + public const string ClaimGlobalName = "openshock.oauth.global_name"; } \ No newline at end of file diff --git a/API/Options/OAuth/DiscordOAuthOptions.cs b/API/Options/OAuth/DiscordOAuthOptions.cs index 00bc7a9d..b70c969e 100644 --- a/API/Options/OAuth/DiscordOAuthOptions.cs +++ b/API/Options/OAuth/DiscordOAuthOptions.cs @@ -8,7 +8,4 @@ public sealed class DiscordOAuthOptions public required string ClientId { get; init; } public required string ClientSecret { get; init; } - public required PathString CallbackPath { get; init; } - public required PathString AccessDeniedPath { get; init; } - public required string[] Scopes { get; init; } } \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 454f9ca6..7542829b 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -48,16 +48,15 @@ o.ClientId = options.ClientId; o.ClientSecret = options.ClientSecret; - o.CallbackPath = options.CallbackPath; - o.AccessDeniedPath = options.AccessDeniedPath; - foreach (var scope in options.Scopes) o.Scope.Add(scope); + o.CallbackPath = "/oauth/discord/callback"; + o.CallbackPath = "/oauth/discord/rejected"; // TODO: Make this do something + o.Scope.Add("email"); o.Prompt = "none"; o.SaveTokens = false; o.ClaimActions.MapJsonKey(OAuthConstants.ClaimEmailVerified, "verified"); - o.ClaimActions.MapJsonKey(OAuthConstants.ClaimDisplayName, "username"); - o.ClaimActions.MapJsonKey(OAuthConstants.ClaimUserName, "global_name"); + o.ClaimActions.MapJsonKey(OAuthConstants.ClaimGlobalName, "global_name"); o.Validate(); });