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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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: