From e9c1790d4e77fff9ad6895b687e08622d3e5e843 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 10 Feb 2026 02:32:04 +0100 Subject: [PATCH 01/13] Rename __Host_ cookies to __Host- and migrate to Microsoft.IdentityModel.JsonWebTokens --- .../AuthenticationCookieMiddleware.cs | 50 +++++++++++-------- .../AuthenticationTokenHttpKeys.cs | 6 +-- .../TokenGeneration/AccessTokenGenerator.cs | 2 +- .../AuthenticationTokenService.cs | 5 +- .../TokenGeneration/RefreshTokenGenerator.cs | 2 +- .../SecurityTokenDescriptorExtensions.cs | 8 +-- 6 files changed, 40 insertions(+), 33 deletions(-) diff --git a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs index 3cdfc1381..4e61d45ec 100644 --- a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs +++ b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs @@ -1,6 +1,5 @@ -using System.IdentityModel.Tokens.Jwt; using System.Net.Http.Headers; -using System.Security.Claims; +using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using PlatformPlatform.SharedKernel.Authentication; using PlatformPlatform.SharedKernel.Authentication.TokenSigning; @@ -17,6 +16,8 @@ ILogger logger private const string RefreshAuthenticationTokensEndpoint = "/internal-api/account-management/authentication/refresh-authentication-tokens"; private const string UnauthorizedReasonItemKey = "UnauthorizedReason"; + private static readonly JsonWebTokenHandler TokenHandler = new(); + public async Task InvokeAsync(HttpContext context, RequestDelegate next) { if (context.Request.Cookies.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenCookieName, out var refreshTokenCookieValue)) @@ -38,8 +39,9 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) // For non-API requests (SPA routes): delete cookies and let the page load // The SPA will load without auth and redirect to login as needed - context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName); - context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName); + var hostCookieOptions = new CookieOptions { Secure = true }; + context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName, hostCookieOptions); + context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName, hostCookieOptions); } await next(context); @@ -49,13 +51,13 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) { logger.LogDebug("Refreshing authentication tokens as requested by endpoint"); var (refreshToken, accessToken) = await RefreshAuthenticationTokensAsync(refreshTokenCookieValue!); - ReplaceAuthenticationHeaderWithCookie(context, refreshToken, accessToken); + await ReplaceAuthenticationHeaderWithCookieAsync(context, refreshToken, accessToken); context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey); } else if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey, out var refreshToken) && context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey, out var accessToken)) { - ReplaceAuthenticationHeaderWithCookie(context, refreshToken.Single()!, accessToken.Single()!); + await ReplaceAuthenticationHeaderWithCookieAsync(context, refreshToken.Single()!, accessToken.Single()!); } } @@ -70,12 +72,13 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http try { - if (accessToken is null || ExtractExpirationFromToken(accessToken) < timeProvider.GetUtcNow()) + if (accessToken is null || await ExtractExpirationFromTokenAsync(accessToken) < timeProvider.GetUtcNow()) { - if (ExtractExpirationFromToken(refreshToken) < timeProvider.GetUtcNow()) + if (await ExtractExpirationFromTokenAsync(refreshToken) < timeProvider.GetUtcNow()) { - context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName); - context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName); + var expiredCookieOptions = new CookieOptions { Secure = true }; + context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName, expiredCookieOptions); + context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName, expiredCookieOptions); logger.LogDebug("The refresh-token has expired; authentication token cookies are removed"); return; } @@ -85,7 +88,7 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http (refreshToken, accessToken) = await RefreshAuthenticationTokensAsync(refreshToken); // Update the authentication token cookies with the new tokens - ReplaceAuthenticationHeaderWithCookie(context, refreshToken, accessToken); + await ReplaceAuthenticationHeaderWithCookieAsync(context, refreshToken, accessToken); } context.Request.Headers.Authorization = $"Bearer {accessToken}"; @@ -164,13 +167,14 @@ private static void DeleteCookiesForApiRequestsOnly(HttpContext context) return; } - context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName); - context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName); + var hostCookieOptions = new CookieOptions { Secure = true }; + context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName, hostCookieOptions); + context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName, hostCookieOptions); } - private void ReplaceAuthenticationHeaderWithCookie(HttpContext context, string refreshToken, string accessToken) + private async Task ReplaceAuthenticationHeaderWithCookieAsync(HttpContext context, string refreshToken, string accessToken) { - var refreshTokenExpires = ExtractExpirationFromToken(refreshToken); + var refreshTokenExpires = await ExtractExpirationFromTokenAsync(refreshToken); // The refresh token cookie is SameSiteMode.Lax, which makes the cookie available on the first request when redirected // from another site. This means we can redirect to the login page if the user is not authenticated without @@ -188,11 +192,9 @@ private void ReplaceAuthenticationHeaderWithCookie(HttpContext context, string r context.Response.Headers.Remove(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey); } - private DateTimeOffset ExtractExpirationFromToken(string token) + private async Task ExtractExpirationFromTokenAsync(string token) { - var tokenHandler = new JwtSecurityTokenHandler(); - - if (!tokenHandler.CanReadToken(token)) + if (!TokenHandler.CanReadToken(token)) { throw new SecurityTokenMalformedException("The token is not a valid JWT."); } @@ -202,11 +204,15 @@ private DateTimeOffset ExtractExpirationFromToken(string token) clockSkew: TimeSpan.FromSeconds(2) // In Azure, we don't need any clock skew, but this must be a lower value than in downstream APIs ); - // This will throw if the token is invalid - var tokenClaims = tokenHandler.ValidateToken(token, validationParameters, out _); + var validationResult = await TokenHandler.ValidateTokenAsync(token, validationParameters); + + if (!validationResult.IsValid) + { + throw validationResult.Exception; + } // The 'exp' claim is the number of seconds since Unix epoch (00:00:00 UTC on 1st January 1970) - var expires = tokenClaims.FindFirstValue(JwtRegisteredClaimNames.Exp)!; + var expires = validationResult.Claims[JwtRegisteredClaimNames.Exp]?.ToString()!; return DateTimeOffset.FromUnixTimeSeconds(long.Parse(expires)); } diff --git a/application/shared-kernel/SharedKernel/Authentication/AuthenticationTokenHttpKeys.cs b/application/shared-kernel/SharedKernel/Authentication/AuthenticationTokenHttpKeys.cs index 7a399d306..deff7b764 100644 --- a/application/shared-kernel/SharedKernel/Authentication/AuthenticationTokenHttpKeys.cs +++ b/application/shared-kernel/SharedKernel/Authentication/AuthenticationTokenHttpKeys.cs @@ -13,9 +13,9 @@ public static class AuthenticationTokenHttpKeys public const string UnauthorizedReasonHeaderKey = "x-unauthorized-reason"; // __Host prefix ensures the cookie is sent only to the host, requires Secure, HTTPS, Path=/ and no Domain specified - public const string RefreshTokenCookieName = "__Host_Refresh_Token"; + public const string RefreshTokenCookieName = "__Host-refresh-token"; - public const string AccessTokenCookieName = "__Host_Access_Token"; + public const string AccessTokenCookieName = "__Host-access-token"; - public const string AntiforgeryTokenCookieName = "__Host_Xsrf_Token"; + public const string AntiforgeryTokenCookieName = "__Host-xsrf-token"; } diff --git a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs index 5470a032f..4d7742def 100644 --- a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs +++ b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs @@ -1,5 +1,5 @@ -using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; +using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using PlatformPlatform.SharedKernel.Authentication.TokenSigning; diff --git a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AuthenticationTokenService.cs b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AuthenticationTokenService.cs index c9ceafa45..354792dd4 100644 --- a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AuthenticationTokenService.cs +++ b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AuthenticationTokenService.cs @@ -44,7 +44,8 @@ public void Logout() httpContext.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey); httpContext.Response.Headers.Remove(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey); - httpContext.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName); - httpContext.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName); + var hostCookieOptions = new CookieOptions { Secure = true }; + httpContext.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName, hostCookieOptions); + httpContext.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName, hostCookieOptions); } } diff --git a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/RefreshTokenGenerator.cs b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/RefreshTokenGenerator.cs index bf4b67223..83da963a9 100644 --- a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/RefreshTokenGenerator.cs +++ b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/RefreshTokenGenerator.cs @@ -1,5 +1,5 @@ -using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; +using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using PlatformPlatform.SharedKernel.Authentication.TokenSigning; diff --git a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/SecurityTokenDescriptorExtensions.cs b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/SecurityTokenDescriptorExtensions.cs index a49092136..8d3aa548d 100644 --- a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/SecurityTokenDescriptorExtensions.cs +++ b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/SecurityTokenDescriptorExtensions.cs @@ -1,10 +1,12 @@ -using System.IdentityModel.Tokens.Jwt; +using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; namespace PlatformPlatform.SharedKernel.Authentication.TokenGeneration; internal static class SecurityTokenDescriptorExtensions { + private static readonly JsonWebTokenHandler TokenHandler = new(); + extension(SecurityTokenDescriptor tokenDescriptor) { internal string GenerateToken(DateTimeOffset now, DateTimeOffset expires, string issuer, string audience, SigningCredentials signingCredentials) @@ -16,9 +18,7 @@ internal string GenerateToken(DateTimeOffset now, DateTimeOffset expires, string tokenDescriptor.Audience = audience; tokenDescriptor.SigningCredentials = signingCredentials; - var tokenHandler = new JwtSecurityTokenHandler(); - var securityToken = tokenHandler.CreateToken(tokenDescriptor); - return tokenHandler.WriteToken(securityToken); + return TokenHandler.CreateToken(tokenDescriptor); } } } From 270ac44876c36849961dbf79b08f307ef1f31f4b Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 10 Feb 2026 02:49:11 +0100 Subject: [PATCH 02/13] Merge EmailConfirmation and Login into single EmailLogin aggregate --- .../Api/Endpoints/AuthenticationEndpoints.cs | 15 - .../Endpoints/EmailAuthenticationEndpoints.cs | 40 ++ .../Api/Endpoints/SignupEndpoints.cs | 29 -- .../account-management/Core/Configuration.cs | 3 + ...00_MergeEmailConfirmationIntoEmailLogin.cs | 24 + ...20260210102300_AddLoginMethodToSessions.cs | 15 + .../Commands/RefreshAuthenticationTokens.cs | 2 +- .../Authentication/Commands/SwitchTenant.cs | 2 +- .../Features/Authentication/Domain/Login.cs | 49 -- .../Domain/LoginConfiguration.cs | 24 - .../Authentication/Domain/LoginRepository.cs | 13 - .../Features/Authentication/Domain/Session.cs | 9 +- .../Authentication/Domain/SessionTypes.cs | 7 + .../Authentication/Queries/GetUserSessions.cs | 2 + .../Commands/CompleteEmailLogin.cs} | 53 +-- .../Commands/CompleteEmailSignup.cs} | 24 +- .../Commands/ResendEmailLoginCode.cs | 67 +++ .../Commands/StartEmailLogin.cs} | 45 +- .../Commands/StartEmailSignup.cs | 51 ++ .../Domain/EmailLogin.cs} | 47 +- .../Domain/EmailLoginConfiguration.cs | 14 + .../Domain/EmailLoginRepository.cs | 24 + .../Domain/EmailLoginTypes.cs | 7 + .../Shared/CompleteEmailConfirmation.cs | 61 +++ .../Shared/StartEmailConfirmation.cs | 49 ++ .../Commands/CompleteEmailConfirmation.cs | 67 --- .../Commands/ResendEmailConfirmationCode.cs | 67 --- .../Commands/StartEmailConfirmation.cs | 57 --- .../Domain/EmailConfirmationConfiguration.cs | 13 - .../Domain/EmailConfirmationRepository.cs | 24 - .../Domain/EmailConfirmationTypes.cs | 7 - .../Features/Signups/Commands/StartSignup.cs | 54 --- .../Core/Features/TelemetryEvents.cs | 38 +- .../Authentication/GetUserSessionsTests.cs | 1 + .../RefreshAuthenticationTokensTests.cs | 2 + .../Authentication/RevokeSessionTests.cs | 1 + .../Tests/DatabaseSeeder.cs | 4 +- .../CompleteEmailLoginTests.cs} | 180 ++++--- .../StartEmailLoginTests.cs} | 61 ++- ...upTests.cs => CompleteEmailSignupTests.cs} | 83 ++-- ...ignupTests.cs => StartEmailSignupTests.cs} | 31 +- .../Tests/Users/DeleteUserTests.cs | 23 +- .../WebApp/routes/login/-shared/loginState.ts | 3 +- .../WebApp/routes/login/index.tsx | 7 +- .../WebApp/routes/login/verify.tsx | 15 +- .../routes/signup/-shared/signupState.ts | 2 +- .../WebApp/routes/signup/index.tsx | 6 +- .../WebApp/routes/signup/verify.tsx | 10 +- .../shared/lib/api/AccountManagement.Api.json | 447 +++++++++--------- 49 files changed, 883 insertions(+), 996 deletions(-) create mode 100644 application/account-management/Api/Endpoints/EmailAuthenticationEndpoints.cs delete mode 100644 application/account-management/Api/Endpoints/SignupEndpoints.cs create mode 100644 application/account-management/Core/Database/Migrations/20260210101500_MergeEmailConfirmationIntoEmailLogin.cs create mode 100644 application/account-management/Core/Database/Migrations/20260210102300_AddLoginMethodToSessions.cs delete mode 100644 application/account-management/Core/Features/Authentication/Domain/Login.cs delete mode 100644 application/account-management/Core/Features/Authentication/Domain/LoginConfiguration.cs delete mode 100644 application/account-management/Core/Features/Authentication/Domain/LoginRepository.cs rename application/account-management/Core/Features/{Authentication/Commands/CompleteLogin.cs => EmailAuthentication/Commands/CompleteEmailLogin.cs} (65%) rename application/account-management/Core/Features/{Signups/Commands/CompleteSignup.cs => EmailAuthentication/Commands/CompleteEmailSignup.cs} (70%) create mode 100644 application/account-management/Core/Features/EmailAuthentication/Commands/ResendEmailLoginCode.cs rename application/account-management/Core/Features/{Authentication/Commands/StartLogin.cs => EmailAuthentication/Commands/StartEmailLogin.cs} (51%) create mode 100644 application/account-management/Core/Features/EmailAuthentication/Commands/StartEmailSignup.cs rename application/account-management/Core/Features/{EmailConfirmations/Domain/EmailConfirmation.cs => EmailAuthentication/Domain/EmailLogin.cs} (52%) create mode 100644 application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginConfiguration.cs create mode 100644 application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginRepository.cs create mode 100644 application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginTypes.cs create mode 100644 application/account-management/Core/Features/EmailAuthentication/Shared/CompleteEmailConfirmation.cs create mode 100644 application/account-management/Core/Features/EmailAuthentication/Shared/StartEmailConfirmation.cs delete mode 100644 application/account-management/Core/Features/EmailConfirmations/Commands/CompleteEmailConfirmation.cs delete mode 100644 application/account-management/Core/Features/EmailConfirmations/Commands/ResendEmailConfirmationCode.cs delete mode 100644 application/account-management/Core/Features/EmailConfirmations/Commands/StartEmailConfirmation.cs delete mode 100644 application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmationConfiguration.cs delete mode 100644 application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmationRepository.cs delete mode 100644 application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmationTypes.cs delete mode 100644 application/account-management/Core/Features/Signups/Commands/StartSignup.cs rename application/account-management/Tests/{Authentication/CompleteLoginTests.cs => EmailAuthentication/CompleteEmailLoginTests.cs} (62%) rename application/account-management/Tests/{Authentication/StartLoginTests.cs => EmailAuthentication/StartEmailLoginTests.cs} (77%) rename application/account-management/Tests/Signups/{CompleteSignupTests.cs => CompleteEmailSignupTests.cs} (64%) rename application/account-management/Tests/Signups/{StartSignupTests.cs => StartEmailSignupTests.cs} (78%) diff --git a/application/account-management/Api/Endpoints/AuthenticationEndpoints.cs b/application/account-management/Api/Endpoints/AuthenticationEndpoints.cs index 5107357e6..ec44ab8a7 100644 --- a/application/account-management/Api/Endpoints/AuthenticationEndpoints.cs +++ b/application/account-management/Api/Endpoints/AuthenticationEndpoints.cs @@ -1,8 +1,5 @@ using PlatformPlatform.AccountManagement.Features.Authentication.Commands; -using PlatformPlatform.AccountManagement.Features.Authentication.Domain; using PlatformPlatform.AccountManagement.Features.Authentication.Queries; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Commands; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; using PlatformPlatform.SharedKernel.ApiResults; using PlatformPlatform.SharedKernel.Authentication.TokenGeneration; using PlatformPlatform.SharedKernel.Endpoints; @@ -17,18 +14,6 @@ public void MapEndpoints(IEndpointRouteBuilder routes) { var group = routes.MapGroup(RoutesPrefix).WithTags("Authentication").RequireAuthorization().ProducesValidationProblem(); - group.MapPost("/login/start", async Task> (StartLoginCommand command, IMediator mediator) - => await mediator.Send(command) - ).Produces().AllowAnonymous(); - - group.MapPost("/login/{id}/complete", async Task (LoginId id, CompleteLoginCommand command, IMediator mediator) - => await mediator.Send(command with { Id = id }) - ).AllowAnonymous(); - - group.MapPost("/login/{emailConfirmationId}/resend-code", async Task> (EmailConfirmationId emailConfirmationId, IMediator mediator) - => await mediator.Send(new ResendEmailConfirmationCodeCommand { Id = emailConfirmationId }) - ).Produces().AllowAnonymous(); - group.MapPost("/logout", async Task (IMediator mediator) => await mediator.Send(new LogoutCommand()) ); diff --git a/application/account-management/Api/Endpoints/EmailAuthenticationEndpoints.cs b/application/account-management/Api/Endpoints/EmailAuthenticationEndpoints.cs new file mode 100644 index 000000000..31ff4ee7c --- /dev/null +++ b/application/account-management/Api/Endpoints/EmailAuthenticationEndpoints.cs @@ -0,0 +1,40 @@ +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Commands; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain; +using PlatformPlatform.SharedKernel.ApiResults; +using PlatformPlatform.SharedKernel.Endpoints; + +namespace PlatformPlatform.AccountManagement.Api.Endpoints; + +public sealed class EmailAuthenticationEndpoints : IEndpoints +{ + private const string RoutesPrefix = "/api/account-management/authentication/email"; + + public void MapEndpoints(IEndpointRouteBuilder routes) + { + var group = routes.MapGroup(RoutesPrefix).WithTags("EmailAuthentication").RequireAuthorization().ProducesValidationProblem(); + + group.MapPost("/login/start", async Task> (StartEmailLoginCommand command, IMediator mediator) + => await mediator.Send(command) + ).Produces().AllowAnonymous(); + + group.MapPost("/login/{id}/complete", async Task (EmailLoginId id, CompleteEmailLoginCommand command, IMediator mediator) + => await mediator.Send(command with { Id = id }) + ).AllowAnonymous(); + + group.MapPost("/login/{id}/resend-code", async Task> (EmailLoginId id, IMediator mediator) + => await mediator.Send(new ResendEmailLoginCodeCommand { Id = id }) + ).Produces().AllowAnonymous(); + + group.MapPost("/signup/start", async Task> (StartEmailSignupCommand command, IMediator mediator) + => await mediator.Send(command) + ).Produces().AllowAnonymous(); + + group.MapPost("/signup/{id}/complete", async Task (EmailLoginId id, CompleteEmailSignupCommand command, IMediator mediator) + => await mediator.Send(command with { EmailLoginId = id }) + ).AllowAnonymous(); + + group.MapPost("/signup/{id}/resend-code", async Task> (EmailLoginId id, IMediator mediator) + => await mediator.Send(new ResendEmailLoginCodeCommand { Id = id }) + ).Produces().AllowAnonymous(); + } +} diff --git a/application/account-management/Api/Endpoints/SignupEndpoints.cs b/application/account-management/Api/Endpoints/SignupEndpoints.cs deleted file mode 100644 index db8ab55b8..000000000 --- a/application/account-management/Api/Endpoints/SignupEndpoints.cs +++ /dev/null @@ -1,29 +0,0 @@ -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Commands; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; -using PlatformPlatform.AccountManagement.Features.Signups.Commands; -using PlatformPlatform.SharedKernel.ApiResults; -using PlatformPlatform.SharedKernel.Endpoints; - -namespace PlatformPlatform.AccountManagement.Api.Endpoints; - -public sealed class SignupEndpoints : IEndpoints -{ - private const string RoutesPrefix = "/api/account-management/signups"; - - public void MapEndpoints(IEndpointRouteBuilder routes) - { - var group = routes.MapGroup(RoutesPrefix).WithTags("Signups").RequireAuthorization().ProducesValidationProblem(); - - group.MapPost("/start", async Task> (StartSignupCommand command, IMediator mediator) - => await mediator.Send(command) - ).Produces().AllowAnonymous(); - - group.MapPost("/{emailConfirmationId}/complete", async Task (EmailConfirmationId emailConfirmationId, CompleteSignupCommand command, IMediator mediator) - => await mediator.Send(command with { EmailConfirmationId = emailConfirmationId }) - ).AllowAnonymous(); - - group.MapPost("/{emailConfirmationId}/resend-code", async Task> (EmailConfirmationId emailConfirmationId, IMediator mediator) - => await mediator.Send(new ResendEmailConfirmationCodeCommand { Id = emailConfirmationId }) - ).Produces().AllowAnonymous(); - } -} diff --git a/application/account-management/Core/Configuration.cs b/application/account-management/Core/Configuration.cs index cd1ef7124..5177a9ff7 100644 --- a/application/account-management/Core/Configuration.cs +++ b/application/account-management/Core/Configuration.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using PlatformPlatform.AccountManagement.Database; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Shared; using PlatformPlatform.AccountManagement.Features.Users.Shared; using PlatformPlatform.AccountManagement.Integrations.Gravatar; using PlatformPlatform.SharedKernel.Configuration; @@ -36,6 +37,8 @@ public IServiceCollection AddAccountManagementServices() return services .AddSharedServices([Assembly]) .AddScoped() + .AddScoped() + .AddScoped() .AddScoped(); } } diff --git a/application/account-management/Core/Database/Migrations/20260210101500_MergeEmailConfirmationIntoEmailLogin.cs b/application/account-management/Core/Database/Migrations/20260210101500_MergeEmailConfirmationIntoEmailLogin.cs new file mode 100644 index 000000000..b2767cf3c --- /dev/null +++ b/application/account-management/Core/Database/Migrations/20260210101500_MergeEmailConfirmationIntoEmailLogin.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace PlatformPlatform.AccountManagement.Database.Migrations; + +[DbContext(typeof(AccountManagementDbContext))] +[Migration("20260210101500_MergeEmailConfirmationIntoEmailLogin")] +public sealed class MergeEmailConfirmationIntoEmailLogin : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable("Logins"); + + migrationBuilder.DropColumn("ValidUntil", "EmailConfirmations"); + + migrationBuilder.DropIndex("IX_EmailConfirmations_Email", "EmailConfirmations"); + + migrationBuilder.RenameTable("EmailConfirmations", newName: "EmailLogins"); + + migrationBuilder.CreateIndex("IX_EmailLogins_Email", "EmailLogins", "Email"); + + migrationBuilder.Sql("UPDATE EmailLogins SET Id = REPLACE(Id, 'econf_', 'emlog_') WHERE Id LIKE 'econf_%'"); + } +} diff --git a/application/account-management/Core/Database/Migrations/20260210102300_AddLoginMethodToSessions.cs b/application/account-management/Core/Database/Migrations/20260210102300_AddLoginMethodToSessions.cs new file mode 100644 index 000000000..92c4c52cf --- /dev/null +++ b/application/account-management/Core/Database/Migrations/20260210102300_AddLoginMethodToSessions.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace PlatformPlatform.AccountManagement.Database.Migrations; + +[DbContext(typeof(AccountManagementDbContext))] +[Migration("20260210102300_AddLoginMethodToSessions")] +public sealed class AddLoginMethodToSessions : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn("LoginMethod", "Sessions", "varchar(20)", nullable: false, defaultValue: "OneTimePassword"); + migrationBuilder.AlterColumn("LoginMethod", "Sessions", "varchar(20)", nullable: false); + } +} diff --git a/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs b/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs index 5b0a61920..8385bb435 100644 --- a/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs +++ b/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs @@ -1,7 +1,7 @@ -using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using JetBrains.Annotations; using Microsoft.AspNetCore.Http; +using Microsoft.IdentityModel.JsonWebTokens; using PlatformPlatform.AccountManagement.Features.Authentication.Domain; using PlatformPlatform.AccountManagement.Features.Users.Domain; using PlatformPlatform.AccountManagement.Features.Users.Shared; diff --git a/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs b/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs index 304eb3f61..035fabb99 100644 --- a/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs +++ b/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs @@ -69,7 +69,7 @@ public async Task Handle(SwitchTenantCommand command, CancellationToken var userAgent = httpContextAccessor.HttpContext?.Request.Headers.UserAgent.ToString() ?? string.Empty; var ipAddress = executionContext.ClientIpAddress; - var session = Session.Create(targetUser.TenantId, targetUser.Id, userAgent, ipAddress); + var session = Session.Create(targetUser.TenantId, targetUser.Id, currentSession.LoginMethod, userAgent, ipAddress); await sessionRepository.AddAsync(session, cancellationToken); targetUser.UpdateLastSeen(timeProvider.GetUtcNow()); diff --git a/application/account-management/Core/Features/Authentication/Domain/Login.cs b/application/account-management/Core/Features/Authentication/Domain/Login.cs deleted file mode 100644 index ded5197e8..000000000 --- a/application/account-management/Core/Features/Authentication/Domain/Login.cs +++ /dev/null @@ -1,49 +0,0 @@ -using JetBrains.Annotations; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; -using PlatformPlatform.AccountManagement.Features.Users.Domain; -using PlatformPlatform.SharedKernel.Domain; -using PlatformPlatform.SharedKernel.StronglyTypedIds; - -namespace PlatformPlatform.AccountManagement.Features.Authentication.Domain; - -public sealed class Login : AggregateRoot -{ - private Login(TenantId tenantId, UserId userId, EmailConfirmationId emailConfirmationId) - : base(LoginId.NewId()) - { - TenantId = tenantId; - UserId = userId; - EmailConfirmationId = emailConfirmationId; - } - - public TenantId TenantId { get; } - - public UserId UserId { get; private set; } - - public EmailConfirmationId EmailConfirmationId { get; private set; } - - public bool Completed { get; private set; } - - public static Login Create(User user, EmailConfirmationId emailConfirmationId) - { - return new Login(user.TenantId, user.Id, emailConfirmationId); - } - - public void MarkAsCompleted() - { - if (Completed) throw new UnreachableException("The login process id has already been created."); - - Completed = true; - } -} - -[PublicAPI] -[IdPrefix("login")] -[JsonConverter(typeof(StronglyTypedIdJsonConverter))] -public sealed record LoginId(string Value) : StronglyTypedUlid(Value) -{ - public override string ToString() - { - return Value; - } -} diff --git a/application/account-management/Core/Features/Authentication/Domain/LoginConfiguration.cs b/application/account-management/Core/Features/Authentication/Domain/LoginConfiguration.cs deleted file mode 100644 index fa01b1e05..000000000 --- a/application/account-management/Core/Features/Authentication/Domain/LoginConfiguration.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; -using PlatformPlatform.AccountManagement.Features.Users.Domain; -using PlatformPlatform.SharedKernel.Domain; -using PlatformPlatform.SharedKernel.EntityFramework; - -namespace PlatformPlatform.AccountManagement.Features.Authentication.Domain; - -public sealed class LoginConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.MapStronglyTypedId(l => l.Id); - builder.MapStronglyTypedLongId(l => l.TenantId); - builder.MapStronglyTypedUuid(l => l.UserId); - builder.MapStronglyTypedUuid(l => l.EmailConfirmationId); - - builder.HasOne() - .WithMany() - .HasForeignKey(l => l.UserId) - .OnDelete(DeleteBehavior.Cascade); - } -} diff --git a/application/account-management/Core/Features/Authentication/Domain/LoginRepository.cs b/application/account-management/Core/Features/Authentication/Domain/LoginRepository.cs deleted file mode 100644 index ef857f275..000000000 --- a/application/account-management/Core/Features/Authentication/Domain/LoginRepository.cs +++ /dev/null @@ -1,13 +0,0 @@ -using PlatformPlatform.AccountManagement.Database; -using PlatformPlatform.SharedKernel.Domain; -using PlatformPlatform.SharedKernel.Persistence; - -namespace PlatformPlatform.AccountManagement.Features.Authentication.Domain; - -public interface ILoginRepository : IAppendRepository -{ - void Update(Login aggregate); -} - -public sealed class LoginRepository(AccountManagementDbContext accountManagementDbContext) - : RepositoryBase(accountManagementDbContext), ILoginRepository; diff --git a/application/account-management/Core/Features/Authentication/Domain/Session.cs b/application/account-management/Core/Features/Authentication/Domain/Session.cs index 16fe7b0a7..bdeef5748 100644 --- a/application/account-management/Core/Features/Authentication/Domain/Session.cs +++ b/application/account-management/Core/Features/Authentication/Domain/Session.cs @@ -11,13 +11,14 @@ public sealed class Session : AggregateRoot, ITenantScopedEntity // Allows in-flight requests using the previous token version to complete when a parallel request triggers a token refresh. public const int GracePeriodSeconds = 30; - private Session(TenantId tenantId, UserId userId, DeviceType deviceType, string userAgent, string ipAddress) + private Session(TenantId tenantId, UserId userId, LoginMethod loginMethod, DeviceType deviceType, string userAgent, string ipAddress) : base(SessionId.NewId()) { TenantId = tenantId; UserId = userId; RefreshTokenJti = RefreshTokenJti.NewId(); RefreshTokenVersion = 1; + LoginMethod = loginMethod; DeviceType = deviceType; UserAgent = userAgent; IpAddress = ipAddress; @@ -34,6 +35,8 @@ private Session(TenantId tenantId, UserId userId, DeviceType deviceType, string [UsedImplicitly] // Updated via raw SQL in SessionRepository.TryRefreshAsync public int RefreshTokenVersion { get; private set; } + public LoginMethod LoginMethod { get; private init; } + public DeviceType DeviceType { get; private init; } public string UserAgent { get; private init; } @@ -50,10 +53,10 @@ private Session(TenantId tenantId, UserId userId, DeviceType deviceType, string public TenantId TenantId { get; } - public static Session Create(TenantId tenantId, UserId userId, string userAgent, IPAddress ipAddress) + public static Session Create(TenantId tenantId, UserId userId, LoginMethod loginMethod, string userAgent, IPAddress ipAddress) { var deviceType = ParseDeviceType(userAgent); - return new Session(tenantId, userId, deviceType, userAgent, ipAddress.ToString()); + return new Session(tenantId, userId, loginMethod, deviceType, userAgent, ipAddress.ToString()); } public void Revoke(DateTimeOffset now, SessionRevokedReason reason) diff --git a/application/account-management/Core/Features/Authentication/Domain/SessionTypes.cs b/application/account-management/Core/Features/Authentication/Domain/SessionTypes.cs index 84e85c055..086595a89 100644 --- a/application/account-management/Core/Features/Authentication/Domain/SessionTypes.cs +++ b/application/account-management/Core/Features/Authentication/Domain/SessionTypes.cs @@ -12,6 +12,13 @@ public enum DeviceType Tablet } +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum LoginMethod +{ + OneTimePassword +} + /// /// Represents why a session was revoked. This is a domain concept stored in the Session aggregate. /// For HTTP header reasons (which include additional cases like SessionNotFound), see diff --git a/application/account-management/Core/Features/Authentication/Queries/GetUserSessions.cs b/application/account-management/Core/Features/Authentication/Queries/GetUserSessions.cs index a4046d12a..16123244a 100644 --- a/application/account-management/Core/Features/Authentication/Queries/GetUserSessions.cs +++ b/application/account-management/Core/Features/Authentication/Queries/GetUserSessions.cs @@ -18,6 +18,7 @@ public sealed record UserSessionsResponse(UserSessionInfo[] Sessions); public sealed record UserSessionInfo( SessionId Id, DateTimeOffset CreatedAt, + LoginMethod LoginMethod, DeviceType DeviceType, string UserAgent, string IpAddress, @@ -50,6 +51,7 @@ public async Task> Handle(GetUserSessionsQuery quer var sessionInfos = sessions.Select(s => new UserSessionInfo( s.Id, s.CreatedAt, + s.LoginMethod, s.DeviceType, s.UserAgent, s.IpAddress, diff --git a/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs b/application/account-management/Core/Features/EmailAuthentication/Commands/CompleteEmailLogin.cs similarity index 65% rename from application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs rename to application/account-management/Core/Features/EmailAuthentication/Commands/CompleteEmailLogin.cs index 2374ad13f..29c5aa7d3 100644 --- a/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs +++ b/application/account-management/Core/Features/EmailAuthentication/Commands/CompleteEmailLogin.cs @@ -1,7 +1,8 @@ using JetBrains.Annotations; using Microsoft.AspNetCore.Http; using PlatformPlatform.AccountManagement.Features.Authentication.Domain; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Commands; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Shared; using PlatformPlatform.AccountManagement.Features.Users.Domain; using PlatformPlatform.AccountManagement.Features.Users.Shared; using PlatformPlatform.AccountManagement.Integrations.Gravatar; @@ -11,56 +12,45 @@ using PlatformPlatform.SharedKernel.ExecutionContext; using PlatformPlatform.SharedKernel.Telemetry; -namespace PlatformPlatform.AccountManagement.Features.Authentication.Commands; +namespace PlatformPlatform.AccountManagement.Features.EmailAuthentication.Commands; [PublicAPI] -public sealed record CompleteLoginCommand(string OneTimePassword, TenantId? PreferredTenantId = null) : ICommand, IRequest +public sealed record CompleteEmailLoginCommand(string OneTimePassword, TenantId? PreferredTenantId = null) : ICommand, IRequest { [JsonIgnore] // Removes this property from the API contract - public LoginId Id { get; init; } = null!; + public EmailLoginId Id { get; init; } = null!; } -public sealed class CompleteLoginHandler( +public sealed class CompleteEmailLoginHandler( IUserRepository userRepository, - ILoginRepository loginRepository, ISessionRepository sessionRepository, UserInfoFactory userInfoFactory, AuthenticationTokenService authenticationTokenService, - IMediator mediator, + CompleteEmailConfirmation completeEmailConfirmation, AvatarUpdater avatarUpdater, GravatarClient gravatarClient, IHttpContextAccessor httpContextAccessor, IExecutionContext executionContext, ITelemetryEventsCollector events, TimeProvider timeProvider, - ILogger logger -) : IRequestHandler + ILogger logger +) : IRequestHandler { - public async Task Handle(CompleteLoginCommand command, CancellationToken cancellationToken) + public async Task Handle(CompleteEmailLoginCommand command, CancellationToken cancellationToken) { - var login = await loginRepository.GetByIdAsync(command.Id, cancellationToken); - if (login is null) - { - // For security, avoid confirming the existence of login IDs - return Result.BadRequest("The code is wrong or no longer valid."); - } - - if (login.Completed) - { - logger.LogWarning("Login with id '{LoginId}' has already been completed", login.Id); - return Result.BadRequest($"The login process '{login.Id}' for user '{login.UserId}' has already been completed."); - } - - var completeEmailConfirmationResult = await mediator.Send( - new CompleteEmailConfirmationCommand(login.EmailConfirmationId, command.OneTimePassword), - cancellationToken + var completeEmailConfirmationResult = await completeEmailConfirmation.CompleteAsync( + command.Id, command.OneTimePassword, cancellationToken ); if (!completeEmailConfirmationResult.IsSuccess) return Result.From(completeEmailConfirmationResult); - var user = (await userRepository.GetByIdUnfilteredAsync(login.UserId, cancellationToken))!; + var user = await userRepository.GetUserByEmailUnfilteredAsync(completeEmailConfirmationResult.Value!.Email, cancellationToken); + if (user is null) + { + logger.LogWarning("User not found for email after completing email login '{EmailLoginId}'", command.Id); + return Result.BadRequest("The code is wrong or no longer valid."); + } - // Check if PreferredTenantId is provided and valid if (command.PreferredTenantId is not null) { var usersWithSameEmail = await userRepository.GetUsersByEmailUnfilteredAsync(user.Email, cancellationToken); @@ -89,13 +79,10 @@ public async Task Handle(CompleteLoginCommand command, CancellationToken } } - login.MarkAsCompleted(); - loginRepository.Update(login); - var userAgent = httpContextAccessor.HttpContext?.Request.Headers.UserAgent.ToString() ?? string.Empty; var ipAddress = executionContext.ClientIpAddress; - var session = Session.Create(user.TenantId, user.Id, userAgent, ipAddress); + var session = Session.Create(user.TenantId, user.Id, LoginMethod.OneTimePassword, userAgent, ipAddress); await sessionRepository.AddAsync(session, cancellationToken); user.UpdateLastSeen(timeProvider.GetUtcNow()); @@ -105,7 +92,7 @@ public async Task Handle(CompleteLoginCommand command, CancellationToken authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo, session.Id, session.RefreshTokenJti); events.CollectEvent(new SessionCreated(session.Id)); - events.CollectEvent(new LoginCompleted(user.Id, completeEmailConfirmationResult.Value!.ConfirmationTimeInSeconds)); + events.CollectEvent(new EmailLoginCompleted(user.Id, completeEmailConfirmationResult.Value!.ConfirmationTimeInSeconds)); return Result.Success(); } diff --git a/application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs b/application/account-management/Core/Features/EmailAuthentication/Commands/CompleteEmailSignup.cs similarity index 70% rename from application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs rename to application/account-management/Core/Features/EmailAuthentication/Commands/CompleteEmailSignup.cs index 9246d9a75..9621331a8 100644 --- a/application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs +++ b/application/account-management/Core/Features/EmailAuthentication/Commands/CompleteEmailSignup.cs @@ -1,8 +1,8 @@ using JetBrains.Annotations; using Microsoft.AspNetCore.Http; using PlatformPlatform.AccountManagement.Features.Authentication.Domain; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Commands; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Shared; using PlatformPlatform.AccountManagement.Features.Tenants.Commands; using PlatformPlatform.AccountManagement.Features.Users.Domain; using PlatformPlatform.AccountManagement.Features.Users.Shared; @@ -11,32 +11,32 @@ using PlatformPlatform.SharedKernel.ExecutionContext; using PlatformPlatform.SharedKernel.Telemetry; -namespace PlatformPlatform.AccountManagement.Features.Signups.Commands; +namespace PlatformPlatform.AccountManagement.Features.EmailAuthentication.Commands; [PublicAPI] -public sealed record CompleteSignupCommand(string OneTimePassword, string PreferredLocale) : ICommand, IRequest +public sealed record CompleteEmailSignupCommand(string OneTimePassword, string PreferredLocale) : ICommand, IRequest { [JsonIgnore] // Removes this property from the API contract - public EmailConfirmationId EmailConfirmationId { get; init; } = null!; + public EmailLoginId EmailLoginId { get; init; } = null!; } -public sealed class CompleteSignupHandler( +public sealed class CompleteEmailSignupHandler( IUserRepository userRepository, ISessionRepository sessionRepository, UserInfoFactory userInfoFactory, AuthenticationTokenService authenticationTokenService, IHttpContextAccessor httpContextAccessor, IExecutionContext executionContext, + CompleteEmailConfirmation completeEmailConfirmation, IMediator mediator, ITelemetryEventsCollector events, TimeProvider timeProvider -) : IRequestHandler +) : IRequestHandler { - public async Task Handle(CompleteSignupCommand command, CancellationToken cancellationToken) + public async Task Handle(CompleteEmailSignupCommand command, CancellationToken cancellationToken) { - var completeEmailConfirmationResult = await mediator.Send( - new CompleteEmailConfirmationCommand(command.EmailConfirmationId, command.OneTimePassword), - cancellationToken + var completeEmailConfirmationResult = await completeEmailConfirmation.CompleteAsync( + command.EmailLoginId, command.OneTimePassword, cancellationToken ); if (!completeEmailConfirmationResult.IsSuccess) return Result.From(completeEmailConfirmationResult); @@ -53,7 +53,7 @@ public async Task Handle(CompleteSignupCommand command, CancellationToke var userAgent = httpContextAccessor.HttpContext?.Request.Headers.UserAgent.ToString() ?? string.Empty; var ipAddress = executionContext.ClientIpAddress; - var session = Session.Create(user!.TenantId, user.Id, userAgent, ipAddress); + var session = Session.Create(user!.TenantId, user.Id, LoginMethod.OneTimePassword, userAgent, ipAddress); await sessionRepository.AddAsync(session, cancellationToken); user.UpdateLastSeen(timeProvider.GetUtcNow()); diff --git a/application/account-management/Core/Features/EmailAuthentication/Commands/ResendEmailLoginCode.cs b/application/account-management/Core/Features/EmailAuthentication/Commands/ResendEmailLoginCode.cs new file mode 100644 index 000000000..a29b9eb74 --- /dev/null +++ b/application/account-management/Core/Features/EmailAuthentication/Commands/ResendEmailLoginCode.cs @@ -0,0 +1,67 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.Identity; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain; +using PlatformPlatform.SharedKernel.Authentication; +using PlatformPlatform.SharedKernel.Cqrs; +using PlatformPlatform.SharedKernel.Integrations.Email; +using PlatformPlatform.SharedKernel.Telemetry; + +namespace PlatformPlatform.AccountManagement.Features.EmailAuthentication.Commands; + +[PublicAPI] +public sealed record ResendEmailLoginCodeCommand : ICommand, IRequest> +{ + [JsonIgnore] // Removes this property from the API contract + public EmailLoginId Id { get; init; } = null!; +} + +[PublicAPI] +public sealed record ResendEmailLoginCodeResponse(int ValidForSeconds); + +public sealed class ResendEmailLoginCodeHandler( + IEmailLoginRepository emailLoginRepository, + IEmailClient emailClient, + IPasswordHasher passwordHasher, + ITelemetryEventsCollector events, + TimeProvider timeProvider, + ILogger logger +) : IRequestHandler> +{ + public async Task> Handle(ResendEmailLoginCodeCommand codeCommand, CancellationToken cancellationToken) + { + var emailLogin = await emailLoginRepository.GetByIdAsync(codeCommand.Id, cancellationToken); + if (emailLogin is null) return Result.NotFound($"Email login with id '{codeCommand.Id}' not found."); + + if (emailLogin.Completed) + { + logger.LogWarning("Email login with id '{EmailLoginId}' has already been completed", emailLogin.Id); + return Result.BadRequest($"The email login with id '{emailLogin.Id}' has already been completed."); + } + + if (emailLogin.ResendCount >= EmailLogin.MaxResends) + { + events.CollectEvent(new EmailLoginCodeResendBlocked(emailLogin.Id, emailLogin.Type, emailLogin.RetryCount)); + return Result.Forbidden("Too many attempts, please request a new code.", true); + } + + var oneTimePassword = OneTimePasswordHelper.GenerateOneTimePassword(6); + var oneTimePasswordHash = passwordHasher.HashPassword(this, oneTimePassword); + emailLogin.UpdateVerificationCode(oneTimePasswordHash, timeProvider.GetUtcNow()); + emailLoginRepository.Update(emailLogin); + + var secondsSinceStarted = (timeProvider.GetUtcNow() - emailLogin.CreatedAt).TotalSeconds; + events.CollectEvent(new EmailLoginCodeResend((int)secondsSinceStarted)); + + await emailClient.SendAsync(emailLogin.Email, "Your verification code (resend)", + $""" +

Here's your new verification code

+

We're sending this code again as you requested.

+

{oneTimePassword}

+

This code will expire in a few minutes.

+ """, + cancellationToken + ); + + return new ResendEmailLoginCodeResponse(EmailLogin.ValidForSeconds); + } +} diff --git a/application/account-management/Core/Features/Authentication/Commands/StartLogin.cs b/application/account-management/Core/Features/EmailAuthentication/Commands/StartEmailLogin.cs similarity index 51% rename from application/account-management/Core/Features/Authentication/Commands/StartLogin.cs rename to application/account-management/Core/Features/EmailAuthentication/Commands/StartEmailLogin.cs index fa779e73c..e385aed45 100644 --- a/application/account-management/Core/Features/Authentication/Commands/StartLogin.cs +++ b/application/account-management/Core/Features/EmailAuthentication/Commands/StartEmailLogin.cs @@ -1,40 +1,38 @@ using FluentValidation; using JetBrains.Annotations; -using PlatformPlatform.AccountManagement.Features.Authentication.Domain; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Commands; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Shared; using PlatformPlatform.AccountManagement.Features.Users.Domain; using PlatformPlatform.SharedKernel.Cqrs; using PlatformPlatform.SharedKernel.Integrations.Email; using PlatformPlatform.SharedKernel.Telemetry; using PlatformPlatform.SharedKernel.Validation; -namespace PlatformPlatform.AccountManagement.Features.Authentication.Commands; +namespace PlatformPlatform.AccountManagement.Features.EmailAuthentication.Commands; [PublicAPI] -public sealed record StartLoginCommand(string Email) : ICommand, IRequest> +public sealed record StartEmailLoginCommand(string Email) : ICommand, IRequest> { public string Email { get; init; } = Email.Trim().ToLower(); } [PublicAPI] -public sealed record StartLoginResponse(LoginId LoginId, EmailConfirmationId EmailConfirmationId, int ValidForSeconds); +public sealed record StartEmailLoginResponse(EmailLoginId EmailLoginId, int ValidForSeconds); -public sealed class StartLoginValidator : AbstractValidator +public sealed class StartEmailLoginValidator : AbstractValidator { - public StartLoginValidator() + public StartEmailLoginValidator() { RuleFor(x => x.Email).SetValidator(new SharedValidations.Email()); } } -public sealed class StartLoginHandler( +public sealed class StartEmailLoginHandler( IUserRepository userRepository, - ILoginRepository loginRepository, IEmailClient emailClient, - IMediator mediator, + StartEmailConfirmation startEmailConfirmation, ITelemetryEventsCollector events -) : IRequestHandler> +) : IRequestHandler> { private const string UnknownUserEmailTemplate = """ @@ -50,7 +48,7 @@ ITelemetryEventsCollector events

{oneTimePassword}

"""; - public async Task> Handle(StartLoginCommand command, CancellationToken cancellationToken) + public async Task> Handle(StartEmailLoginCommand command, CancellationToken cancellationToken) { var user = await userRepository.GetUserByEmailUnfilteredAsync(command.Email, cancellationToken); @@ -61,26 +59,17 @@ await emailClient.SendAsync(command.Email.ToLower(), "Unknown user tried to logi cancellationToken ); - // Return a fake login process id to the client, so an attacker can't guess if the email is valid or not - return new StartLoginResponse(LoginId.NewId(), EmailConfirmationId.NewId(), EmailConfirmation.ValidForSeconds); + return new StartEmailLoginResponse(EmailLoginId.NewId(), EmailLogin.ValidForSeconds); } - var result = await mediator.Send( - new StartEmailConfirmationCommand( - user.Email, - "PlatformPlatform login verification code", - LoginEmailTemplate, - EmailConfirmationType.Login - ), - cancellationToken + var result = await startEmailConfirmation.StartAsync( + user.Email, "PlatformPlatform login verification code", LoginEmailTemplate, EmailLoginType.Login, cancellationToken ); - if (!result.IsSuccess) return Result.From(result); + if (!result.IsSuccess) return Result.From(result); - var login = Login.Create(user, result.Value!.EmailConfirmationId); - await loginRepository.AddAsync(login, cancellationToken); - events.CollectEvent(new LoginStarted(user.Id)); + events.CollectEvent(new EmailLoginStarted(user.Id)); - return new StartLoginResponse(login.Id, login.EmailConfirmationId, EmailConfirmation.ValidForSeconds); + return new StartEmailLoginResponse(result.Value!, EmailLogin.ValidForSeconds); } } diff --git a/application/account-management/Core/Features/EmailAuthentication/Commands/StartEmailSignup.cs b/application/account-management/Core/Features/EmailAuthentication/Commands/StartEmailSignup.cs new file mode 100644 index 000000000..2e27eba82 --- /dev/null +++ b/application/account-management/Core/Features/EmailAuthentication/Commands/StartEmailSignup.cs @@ -0,0 +1,51 @@ +using FluentValidation; +using JetBrains.Annotations; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Shared; +using PlatformPlatform.SharedKernel.Cqrs; +using PlatformPlatform.SharedKernel.Telemetry; +using PlatformPlatform.SharedKernel.Validation; + +namespace PlatformPlatform.AccountManagement.Features.EmailAuthentication.Commands; + +[PublicAPI] +public sealed record StartEmailSignupCommand(string Email) : ICommand, IRequest> +{ + public string Email { get; } = Email.Trim().ToLower(); +} + +[PublicAPI] +public sealed record StartEmailSignupResponse(EmailLoginId EmailLoginId, int ValidForSeconds); + +public sealed class StartEmailSignupValidator : AbstractValidator +{ + public StartEmailSignupValidator() + { + RuleFor(x => x.Email).SetValidator(new SharedValidations.Email()); + } +} + +public sealed class StartEmailSignupHandler(StartEmailConfirmation startEmailConfirmation, ITelemetryEventsCollector events) + : IRequestHandler> +{ + public async Task> Handle(StartEmailSignupCommand command, CancellationToken cancellationToken) + { + var result = await startEmailConfirmation.StartAsync( + command.Email, + "Confirm your email address", + """ +

Your confirmation code is below

+

Enter it in your open browser window. It is only valid for a few minutes.

+

{oneTimePassword}

+ """, + EmailLoginType.Signup, + cancellationToken + ); + + if (!result.IsSuccess) return Result.From(result); + + events.CollectEvent(new SignupStarted()); + + return Result.Success(new StartEmailSignupResponse(result.Value!, EmailLogin.ValidForSeconds)); + } +} diff --git a/application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmation.cs b/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLogin.cs similarity index 52% rename from application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmation.cs rename to application/account-management/Core/Features/EmailAuthentication/Domain/EmailLogin.cs index 9739cdd50..b21f4a4d7 100644 --- a/application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmation.cs +++ b/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLogin.cs @@ -1,47 +1,51 @@ +using System.Security; using JetBrains.Annotations; using PlatformPlatform.SharedKernel.Domain; using PlatformPlatform.SharedKernel.StronglyTypedIds; -namespace PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; +namespace PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain; -public sealed class EmailConfirmation : AggregateRoot +public sealed class EmailLogin : AggregateRoot { public const int MaxAttempts = 3; public const int MaxResends = 1; public const int ValidForSeconds = 300; - private EmailConfirmation(string email, EmailConfirmationType type, string oneTimePasswordHash) - : base(EmailConfirmationId.NewId()) + private EmailLogin(string email, EmailLoginType type, string oneTimePasswordHash) + : base(EmailLoginId.NewId()) { Email = email; Type = type; OneTimePasswordHash = oneTimePasswordHash; - ValidUntil = CreatedAt.AddSeconds(ValidForSeconds); } public string Email { get; private set; } - public EmailConfirmationType Type { get; private set; } + public EmailLoginType Type { get; private set; } public string OneTimePasswordHash { get; private set; } - [UsedImplicitly] - public DateTimeOffset ValidUntil { get; private set; } - public int RetryCount { get; private set; } public int ResendCount { get; private set; } public bool Completed { get; private set; } - public bool HasExpired(DateTimeOffset now) + public bool IsExpired(DateTimeOffset now) { - return ValidUntil < now; + if (CreatedAt > now) + { + throw new SecurityException($"EmailLogin '{Id}' has CreatedAt in the future. Possible data tampering."); + } + + if (CreatedAt.AddSeconds(ValidForSeconds * (MaxResends + 1)) < now) return true; + if ((ModifiedAt ?? CreatedAt).AddSeconds(ValidForSeconds) < now) return true; + return false; } - public static EmailConfirmation Create(string email, string oneTimePasswordHash, EmailConfirmationType type) + public static EmailLogin Create(string email, string oneTimePasswordHash, EmailLoginType type) { - return new EmailConfirmation(email.ToLowerInvariant(), type, oneTimePasswordHash); + return new EmailLogin(email.ToLowerInvariant(), type, oneTimePasswordHash); } public void RegisterInvalidPasswordAttempt() @@ -51,12 +55,12 @@ public void RegisterInvalidPasswordAttempt() public void MarkAsCompleted(DateTimeOffset now) { - if (HasExpired(now) || RetryCount >= MaxAttempts) + if (IsExpired(now) || RetryCount >= MaxAttempts) { - throw new UnreachableException("This email confirmation has expired."); + throw new UnreachableException("This email login has expired."); } - if (Completed) throw new UnreachableException("The email has already been confirmed."); + if (Completed) throw new UnreachableException("The email login has already been completed."); Completed = true; } @@ -65,24 +69,23 @@ public void UpdateVerificationCode(string oneTimePasswordHash, DateTimeOffset no { if (Completed) { - throw new UnreachableException("Cannot regenerate verification code for completed email confirmation"); + throw new UnreachableException("Cannot regenerate verification code for completed email login."); } if (ResendCount >= MaxResends) { - throw new UnreachableException("Cannot regenerate verification code for email confirmation that has been resent too many times."); + throw new UnreachableException("Cannot regenerate verification code for email login that has been resent too many times."); } - ValidUntil = now.AddSeconds(ValidForSeconds); OneTimePasswordHash = oneTimePasswordHash; ResendCount++; } } [PublicAPI] -[IdPrefix("econf")] -[JsonConverter(typeof(StronglyTypedIdJsonConverter))] -public sealed record EmailConfirmationId(string Value) : StronglyTypedUlid(Value) +[IdPrefix("emlog")] +[JsonConverter(typeof(StronglyTypedIdJsonConverter))] +public sealed record EmailLoginId(string Value) : StronglyTypedUlid(Value) { public override string ToString() { diff --git a/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginConfiguration.cs b/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginConfiguration.cs new file mode 100644 index 000000000..b278b1a79 --- /dev/null +++ b/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginConfiguration.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PlatformPlatform.SharedKernel.EntityFramework; + +namespace PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain; + +public sealed class EmailLoginConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("EmailLogins"); + builder.MapStronglyTypedUuid(el => el.Id); + } +} diff --git a/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginRepository.cs b/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginRepository.cs new file mode 100644 index 000000000..4399e73af --- /dev/null +++ b/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginRepository.cs @@ -0,0 +1,24 @@ +using PlatformPlatform.AccountManagement.Database; +using PlatformPlatform.SharedKernel.Domain; +using PlatformPlatform.SharedKernel.Persistence; + +namespace PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain; + +public interface IEmailLoginRepository : IAppendRepository +{ + void Update(EmailLogin aggregate); + + EmailLogin[] GetByEmail(string email); +} + +public sealed class EmailLoginRepository(AccountManagementDbContext accountManagementDbContext) + : RepositoryBase(accountManagementDbContext), IEmailLoginRepository +{ + public EmailLogin[] GetByEmail(string email) + { + return DbSet + .Where(el => !el.Completed) + .Where(el => el.Email == email.ToLowerInvariant()) + .ToArray(); + } +} diff --git a/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginTypes.cs b/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginTypes.cs new file mode 100644 index 000000000..41f82eb80 --- /dev/null +++ b/application/account-management/Core/Features/EmailAuthentication/Domain/EmailLoginTypes.cs @@ -0,0 +1,7 @@ +namespace PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain; + +public enum EmailLoginType +{ + Login, + Signup +} diff --git a/application/account-management/Core/Features/EmailAuthentication/Shared/CompleteEmailConfirmation.cs b/application/account-management/Core/Features/EmailAuthentication/Shared/CompleteEmailConfirmation.cs new file mode 100644 index 000000000..abb4626ef --- /dev/null +++ b/application/account-management/Core/Features/EmailAuthentication/Shared/CompleteEmailConfirmation.cs @@ -0,0 +1,61 @@ +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain; +using PlatformPlatform.SharedKernel.Authentication; +using PlatformPlatform.SharedKernel.Cqrs; +using PlatformPlatform.SharedKernel.Telemetry; + +namespace PlatformPlatform.AccountManagement.Features.EmailAuthentication.Shared; + +public sealed record CompleteEmailConfirmationResponse(string Email, int ConfirmationTimeInSeconds); + +public sealed class CompleteEmailConfirmation( + IEmailLoginRepository emailLoginRepository, + OneTimePasswordHelper oneTimePasswordHelper, + ITelemetryEventsCollector events, + TimeProvider timeProvider, + ILogger logger +) +{ + public async Task> CompleteAsync(EmailLoginId id, string oneTimePassword, CancellationToken cancellationToken) + { + var emailLogin = await emailLoginRepository.GetByIdAsync(id, cancellationToken); + + if (emailLogin is null) + { + return Result.NotFound($"Email login with id '{id}' not found."); + } + + if (emailLogin.Completed) + { + logger.LogWarning("Email login with id '{EmailLoginId}' has already been completed", emailLogin.Id); + return Result.BadRequest($"Email login with id '{emailLogin.Id}' has already been completed."); + } + + if (emailLogin.RetryCount >= EmailLogin.MaxAttempts) + { + emailLogin.RegisterInvalidPasswordAttempt(); + emailLoginRepository.Update(emailLogin); + events.CollectEvent(new EmailLoginCodeBlocked(emailLogin.Id, emailLogin.Type, emailLogin.RetryCount)); + return Result.Forbidden("Too many attempts, please request a new code.", true); + } + + if (oneTimePasswordHelper.Validate(emailLogin.OneTimePasswordHash, oneTimePassword)) + { + emailLogin.RegisterInvalidPasswordAttempt(); + emailLoginRepository.Update(emailLogin); + events.CollectEvent(new EmailLoginCodeFailed(emailLogin.Id, emailLogin.Type, emailLogin.RetryCount)); + return Result.BadRequest("The code is wrong or no longer valid.", true); + } + + var confirmationTimeInSeconds = (int)(timeProvider.GetUtcNow() - emailLogin.CreatedAt).TotalSeconds; + if (emailLogin.IsExpired(timeProvider.GetUtcNow())) + { + events.CollectEvent(new EmailLoginCodeExpired(emailLogin.Id, emailLogin.Type, confirmationTimeInSeconds)); + return Result.BadRequest("The code is no longer valid, please request a new code.", true); + } + + emailLogin.MarkAsCompleted(timeProvider.GetUtcNow()); + emailLoginRepository.Update(emailLogin); + + return new CompleteEmailConfirmationResponse(emailLogin.Email, confirmationTimeInSeconds); + } +} diff --git a/application/account-management/Core/Features/EmailAuthentication/Shared/StartEmailConfirmation.cs b/application/account-management/Core/Features/EmailAuthentication/Shared/StartEmailConfirmation.cs new file mode 100644 index 000000000..623579a4b --- /dev/null +++ b/application/account-management/Core/Features/EmailAuthentication/Shared/StartEmailConfirmation.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Identity; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain; +using PlatformPlatform.SharedKernel.Authentication; +using PlatformPlatform.SharedKernel.Cqrs; +using PlatformPlatform.SharedKernel.Integrations.Email; + +namespace PlatformPlatform.AccountManagement.Features.EmailAuthentication.Shared; + +public sealed class StartEmailConfirmation( + IEmailLoginRepository emailLoginRepository, + IEmailClient emailClient, + IPasswordHasher passwordHasher, + TimeProvider timeProvider +) +{ + public async Task> StartAsync( + string email, + string emailSubject, + string emailBody, + EmailLoginType type, + CancellationToken cancellationToken + ) + { + ArgumentException.ThrowIfNullOrEmpty(emailSubject); + if (!emailBody.Contains("{oneTimePassword}")) + { + throw new ArgumentException("Email body must contain {oneTimePassword} placeholder.", nameof(emailBody)); + } + + var existingLogins = emailLoginRepository.GetByEmail(email).ToArray(); + + var lockoutMinutes = type == EmailLoginType.Signup ? -60 : -15; + if (existingLogins.Count(r => r.CreatedAt > timeProvider.GetUtcNow().AddMinutes(lockoutMinutes)) >= 3) + { + return Result.TooManyRequests("Too many attempts to confirm this email address. Please try again later."); + } + + var oneTimePassword = OneTimePasswordHelper.GenerateOneTimePassword(6); + var oneTimePasswordHash = passwordHasher.HashPassword(this, oneTimePassword); + var emailLogin = EmailLogin.Create(email, oneTimePasswordHash, type); + + await emailLoginRepository.AddAsync(emailLogin, cancellationToken); + + var htmlContent = emailBody.Replace("{oneTimePassword}", oneTimePassword); + await emailClient.SendAsync(emailLogin.Email, emailSubject, htmlContent, cancellationToken); + + return emailLogin.Id; + } +} diff --git a/application/account-management/Core/Features/EmailConfirmations/Commands/CompleteEmailConfirmation.cs b/application/account-management/Core/Features/EmailConfirmations/Commands/CompleteEmailConfirmation.cs deleted file mode 100644 index f9f779f2b..000000000 --- a/application/account-management/Core/Features/EmailConfirmations/Commands/CompleteEmailConfirmation.cs +++ /dev/null @@ -1,67 +0,0 @@ -using JetBrains.Annotations; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; -using PlatformPlatform.SharedKernel.Authentication; -using PlatformPlatform.SharedKernel.Cqrs; -using PlatformPlatform.SharedKernel.Telemetry; - -namespace PlatformPlatform.AccountManagement.Features.EmailConfirmations.Commands; - -[PublicAPI] -public sealed record CompleteEmailConfirmationCommand(EmailConfirmationId Id, string OneTimePassword) - : ICommand, IRequest>; - -[PublicAPI] -public sealed record CompleteEmailConfirmationResponse(string Email, int ConfirmationTimeInSeconds); - -public sealed class CompleteEmailConfirmationHandler( - IEmailConfirmationRepository emailConfirmationRepository, - OneTimePasswordHelper oneTimePasswordHelper, - ITelemetryEventsCollector events, - TimeProvider timeProvider, - ILogger logger -) : IRequestHandler> -{ - public async Task> Handle(CompleteEmailConfirmationCommand command, CancellationToken cancellationToken) - { - var emailConfirmation = await emailConfirmationRepository.GetByIdAsync(command.Id, cancellationToken); - - if (emailConfirmation is null) - { - return Result.NotFound($"Email confirmation with id '{command.Id}' not found."); - } - - if (emailConfirmation.Completed) - { - logger.LogWarning("Email confirmation with id '{EmailConfirmationId}' has already been completed", emailConfirmation.Id); - return Result.BadRequest($"Email confirmation with id {emailConfirmation.Id} has already been completed."); - } - - if (emailConfirmation.RetryCount >= EmailConfirmation.MaxAttempts) - { - emailConfirmation.RegisterInvalidPasswordAttempt(); - emailConfirmationRepository.Update(emailConfirmation); - events.CollectEvent(new EmailConfirmationBlocked(emailConfirmation.Id, emailConfirmation.Type, emailConfirmation.RetryCount)); - return Result.Forbidden("Too many attempts, please request a new code.", true); - } - - if (oneTimePasswordHelper.Validate(emailConfirmation.OneTimePasswordHash, command.OneTimePassword)) - { - emailConfirmation.RegisterInvalidPasswordAttempt(); - emailConfirmationRepository.Update(emailConfirmation); - events.CollectEvent(new EmailConfirmationFailed(emailConfirmation.Id, emailConfirmation.Type, emailConfirmation.RetryCount)); - return Result.BadRequest("The code is wrong or no longer valid.", true); - } - - var confirmationTimeInSeconds = (int)(timeProvider.GetUtcNow() - emailConfirmation.CreatedAt).TotalSeconds; - if (emailConfirmation.HasExpired(timeProvider.GetUtcNow())) - { - events.CollectEvent(new EmailConfirmationExpired(emailConfirmation.Id, emailConfirmation.Type, confirmationTimeInSeconds)); - return Result.BadRequest("The code is no longer valid, please request a new code.", true); - } - - emailConfirmation.MarkAsCompleted(timeProvider.GetUtcNow()); - emailConfirmationRepository.Update(emailConfirmation); - - return new CompleteEmailConfirmationResponse(emailConfirmation.Email, confirmationTimeInSeconds); - } -} diff --git a/application/account-management/Core/Features/EmailConfirmations/Commands/ResendEmailConfirmationCode.cs b/application/account-management/Core/Features/EmailConfirmations/Commands/ResendEmailConfirmationCode.cs deleted file mode 100644 index 31ae6ee6f..000000000 --- a/application/account-management/Core/Features/EmailConfirmations/Commands/ResendEmailConfirmationCode.cs +++ /dev/null @@ -1,67 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.AspNetCore.Identity; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; -using PlatformPlatform.SharedKernel.Authentication; -using PlatformPlatform.SharedKernel.Cqrs; -using PlatformPlatform.SharedKernel.Integrations.Email; -using PlatformPlatform.SharedKernel.Telemetry; - -namespace PlatformPlatform.AccountManagement.Features.EmailConfirmations.Commands; - -[PublicAPI] -public sealed record ResendEmailConfirmationCodeCommand : ICommand, IRequest> -{ - [JsonIgnore] // Removes this property from the API contract - public EmailConfirmationId Id { get; init; } = null!; -} - -[PublicAPI] -public sealed record ResendEmailConfirmationCodeResponse(int ValidForSeconds); - -public sealed class ResendEmailConfirmationCodeHandler( - IEmailConfirmationRepository emailConfirmationRepository, - IEmailClient emailClient, - IPasswordHasher passwordHasher, - ITelemetryEventsCollector events, - TimeProvider timeProvider, - ILogger logger -) : IRequestHandler> -{ - public async Task> Handle(ResendEmailConfirmationCodeCommand codeCommand, CancellationToken cancellationToken) - { - var emailConfirmation = await emailConfirmationRepository.GetByIdAsync(codeCommand.Id, cancellationToken); - if (emailConfirmation is null) return Result.NotFound($"EmailConfirmation with id '{codeCommand.Id}' not found."); - - if (emailConfirmation.Completed) - { - logger.LogWarning("EmailConfirmation with id '{EmailConfirmationId}' has already been completed", emailConfirmation.Id); - return Result.BadRequest($"The email confirmation with id {emailConfirmation.Id} has already been completed."); - } - - if (emailConfirmation.ResendCount >= EmailConfirmation.MaxResends) - { - events.CollectEvent(new EmailConfirmationResendBlocked(emailConfirmation.Id, emailConfirmation.Type, emailConfirmation.RetryCount)); - return Result.Forbidden("Too many attempts, please request a new code.", true); - } - - var oneTimePassword = OneTimePasswordHelper.GenerateOneTimePassword(6); - var oneTimePasswordHash = passwordHasher.HashPassword(this, oneTimePassword); - emailConfirmation.UpdateVerificationCode(oneTimePasswordHash, timeProvider.GetUtcNow()); - emailConfirmationRepository.Update(emailConfirmation); - - var secondsSinceSignupStarted = (timeProvider.GetUtcNow() - emailConfirmation.CreatedAt).TotalSeconds; - events.CollectEvent(new EmailConfirmationResend((int)secondsSinceSignupStarted)); - - await emailClient.SendAsync(emailConfirmation.Email, "Your verification code (resend)", - $""" -

Here's your new verification code

-

We're sending this code again as you requested.

-

{oneTimePassword}

-

This code will expire in a few minutes.

- """, - cancellationToken - ); - - return new ResendEmailConfirmationCodeResponse(EmailConfirmation.ValidForSeconds); - } -} diff --git a/application/account-management/Core/Features/EmailConfirmations/Commands/StartEmailConfirmation.cs b/application/account-management/Core/Features/EmailConfirmations/Commands/StartEmailConfirmation.cs deleted file mode 100644 index 167ea385c..000000000 --- a/application/account-management/Core/Features/EmailConfirmations/Commands/StartEmailConfirmation.cs +++ /dev/null @@ -1,57 +0,0 @@ -using FluentValidation; -using JetBrains.Annotations; -using Microsoft.AspNetCore.Identity; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; -using PlatformPlatform.SharedKernel.Authentication; -using PlatformPlatform.SharedKernel.Cqrs; -using PlatformPlatform.SharedKernel.Integrations.Email; -using PlatformPlatform.SharedKernel.Validation; - -namespace PlatformPlatform.AccountManagement.Features.EmailConfirmations.Commands; - -[PublicAPI] -public sealed record StartEmailConfirmationCommand(string Email, string EmailSubject, string EmailBody, EmailConfirmationType Type) - : ICommand, IRequest>; - -[PublicAPI] -public sealed record StartEmailConfirmationResponse(EmailConfirmationId EmailConfirmationId, int ValidForSeconds); - -public sealed class StartEmailConfirmationValidator : AbstractValidator -{ - public StartEmailConfirmationValidator() - { - RuleFor(x => x.Email).SetValidator(new SharedValidations.Email()); - RuleFor(x => x.EmailSubject).Length(1, 100).WithMessage("Email subject must be between 1 and 100 characters."); - RuleFor(x => x.EmailBody.Contains("{oneTimePassword}")).Equal(true).WithMessage("Email body must contain {oneTimePassword} placeholder."); - } -} - -public sealed class StartEmailConfirmationHandler( - IEmailConfirmationRepository emailConfirmationRepository, - IEmailClient emailClient, - IPasswordHasher passwordHasher, - TimeProvider timeProvider -) : IRequestHandler> -{ - public async Task> Handle(StartEmailConfirmationCommand command, CancellationToken cancellationToken) - { - var existingConfirmations = emailConfirmationRepository.GetByEmail(command.Email).ToArray(); - - var lockoutMinutes = command.Type == EmailConfirmationType.Signup ? -60 : -15; - if (existingConfirmations.Count(r => r.CreatedAt > timeProvider.GetUtcNow().AddMinutes(lockoutMinutes)) >= 3) - { - return Result.TooManyRequests("Too many attempts to confirm this email address. Please try again later."); - } - - var oneTimePassword = OneTimePasswordHelper.GenerateOneTimePassword(6); - var oneTimePasswordHash = passwordHasher.HashPassword(this, oneTimePassword); - var emailConfirmation = EmailConfirmation.Create(command.Email, oneTimePasswordHash, command.Type); - - await emailConfirmationRepository.AddAsync(emailConfirmation, cancellationToken); - - var htmlContent = command.EmailBody.Replace("{oneTimePassword}", oneTimePassword); - await emailClient.SendAsync(emailConfirmation.Email, command.EmailSubject, htmlContent, cancellationToken); - - return new StartEmailConfirmationResponse(emailConfirmation.Id, EmailConfirmation.ValidForSeconds); - } -} diff --git a/application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmationConfiguration.cs b/application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmationConfiguration.cs deleted file mode 100644 index d037b14e9..000000000 --- a/application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmationConfiguration.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using PlatformPlatform.SharedKernel.EntityFramework; - -namespace PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; - -public sealed class EmailConfirmationConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.MapStronglyTypedUuid(ec => ec.Id); - } -} diff --git a/application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmationRepository.cs b/application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmationRepository.cs deleted file mode 100644 index d4d937f7d..000000000 --- a/application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmationRepository.cs +++ /dev/null @@ -1,24 +0,0 @@ -using PlatformPlatform.AccountManagement.Database; -using PlatformPlatform.SharedKernel.Domain; -using PlatformPlatform.SharedKernel.Persistence; - -namespace PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; - -public interface IEmailConfirmationRepository : IAppendRepository -{ - void Update(EmailConfirmation aggregate); - - EmailConfirmation[] GetByEmail(string email); -} - -public sealed class EmailConfirmationRepository(AccountManagementDbContext accountManagementDbContext) - : RepositoryBase(accountManagementDbContext), IEmailConfirmationRepository -{ - public EmailConfirmation[] GetByEmail(string email) - { - return DbSet - .Where(ec => !ec.Completed) - .Where(ec => ec.Email == email.ToLowerInvariant()) - .ToArray(); - } -} diff --git a/application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmationTypes.cs b/application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmationTypes.cs deleted file mode 100644 index 8c03037f0..000000000 --- a/application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmationTypes.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; - -public enum EmailConfirmationType -{ - Login, - Signup -} diff --git a/application/account-management/Core/Features/Signups/Commands/StartSignup.cs b/application/account-management/Core/Features/Signups/Commands/StartSignup.cs deleted file mode 100644 index e1bb050e5..000000000 --- a/application/account-management/Core/Features/Signups/Commands/StartSignup.cs +++ /dev/null @@ -1,54 +0,0 @@ -using FluentValidation; -using JetBrains.Annotations; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Commands; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; -using PlatformPlatform.AccountManagement.Features.Tenants.Domain; -using PlatformPlatform.SharedKernel.Cqrs; -using PlatformPlatform.SharedKernel.Telemetry; -using PlatformPlatform.SharedKernel.Validation; - -namespace PlatformPlatform.AccountManagement.Features.Signups.Commands; - -[PublicAPI] -public sealed record StartSignupCommand(string Email) : ICommand, IRequest> -{ - public string Email { get; } = Email.Trim().ToLower(); -} - -[PublicAPI] -public sealed record StartSignupResponse(EmailConfirmationId EmailConfirmationId, int ValidForSeconds); - -public sealed class StartSignupValidator : AbstractValidator -{ - public StartSignupValidator(ITenantRepository tenantRepository) - { - RuleFor(x => x.Email).SetValidator(new SharedValidations.Email()); - } -} - -public sealed class StartSignupHandler(IMediator mediator, ITelemetryEventsCollector events) - : IRequestHandler> -{ - public async Task> Handle(StartSignupCommand command, CancellationToken cancellationToken) - { - var result = await mediator.Send( - new StartEmailConfirmationCommand( - command.Email, - "Confirm your email address", - """ -

Your confirmation code is below

-

Enter it in your open browser window. It is only valid for a few minutes.

-

{oneTimePassword}

- """, - EmailConfirmationType.Signup - ), - cancellationToken - ); - - if (!result.IsSuccess) return Result.From(result); - - events.CollectEvent(new SignupStarted()); - - return Result.Success(new StartSignupResponse(result.Value!.EmailConfirmationId, EmailConfirmation.ValidForSeconds)); - } -} diff --git a/application/account-management/Core/Features/TelemetryEvents.cs b/application/account-management/Core/Features/TelemetryEvents.cs index 75a673ee5..6ae2a1500 100644 --- a/application/account-management/Core/Features/TelemetryEvents.cs +++ b/application/account-management/Core/Features/TelemetryEvents.cs @@ -1,5 +1,5 @@ using PlatformPlatform.AccountManagement.Features.Authentication.Domain; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain; using PlatformPlatform.AccountManagement.Features.Tenants.Domain; using PlatformPlatform.AccountManagement.Features.Users.Domain; using PlatformPlatform.SharedKernel.Authentication.TokenGeneration; @@ -14,30 +14,30 @@ namespace PlatformPlatform.AccountManagement.Features; /// This particular includes the naming of the telemetry events (which should be in past tense) and the properties that /// are collected with each telemetry event. Since missing or bad data cannot be fixed, it is important to have a good /// data quality from the start. -public sealed class EmailConfirmationBlocked(EmailConfirmationId emailConfirmationId, EmailConfirmationType emailConfirmationType, int retryCount) - : TelemetryEvent(("email_confirmation_id", emailConfirmationId), ("email_confirmation_type", emailConfirmationType), ("retry_count", retryCount)); +public sealed class EmailLoginCodeBlocked(EmailLoginId emailLoginId, EmailLoginType emailLoginType, int retryCount) + : TelemetryEvent(("email_login_id", emailLoginId), ("email_login_type", emailLoginType), ("retry_count", retryCount)); -public sealed class EmailConfirmationExpired(EmailConfirmationId emailConfirmationId, EmailConfirmationType emailConfirmationType, int timeInSeconds) - : TelemetryEvent(("email_confirmation_id", emailConfirmationId), ("email_confirmation_type", emailConfirmationType), ("time_in_seconds", timeInSeconds)); +public sealed class EmailLoginCodeExpired(EmailLoginId emailLoginId, EmailLoginType emailLoginType, int timeInSeconds) + : TelemetryEvent(("email_login_id", emailLoginId), ("email_login_type", emailLoginType), ("time_in_seconds", timeInSeconds)); -public sealed class EmailConfirmationFailed(EmailConfirmationId emailConfirmationId, EmailConfirmationType emailConfirmationType, int retryCount) - : TelemetryEvent(("email_confirmation_id", emailConfirmationId), ("email_confirmation_type", emailConfirmationType), ("retry_count", retryCount)); +public sealed class EmailLoginCodeFailed(EmailLoginId emailLoginId, EmailLoginType emailLoginType, int retryCount) + : TelemetryEvent(("email_login_id", emailLoginId), ("email_login_type", emailLoginType), ("retry_count", retryCount)); -public sealed class EmailConfirmationResend(int secondsSinceSignupStarted) - : TelemetryEvent(("seconds_since_signup_started", secondsSinceSignupStarted)); +public sealed class EmailLoginCodeResend(int secondsSinceStarted) + : TelemetryEvent(("seconds_since_started", secondsSinceStarted)); -public sealed class EmailConfirmationResendBlocked(EmailConfirmationId emailConfirmationId, EmailConfirmationType emailConfirmationType, int resendCount) - : TelemetryEvent(("email_confirmation_id", emailConfirmationId), ("email_confirmation_type", emailConfirmationType), ("resend_count", resendCount)); +public sealed class EmailLoginCodeResendBlocked(EmailLoginId emailLoginId, EmailLoginType emailLoginType, int resendCount) + : TelemetryEvent(("email_login_id", emailLoginId), ("email_login_type", emailLoginType), ("resend_count", resendCount)); -public sealed class GravatarUpdated(long size) - : TelemetryEvent(("size", size)); - -public sealed class LoginCompleted(UserId userId, int loginTimeInSeconds) +public sealed class EmailLoginCompleted(UserId userId, int loginTimeInSeconds) : TelemetryEvent(("user_id", userId), ("login_time_in_seconds", loginTimeInSeconds)); -public sealed class LoginStarted(UserId userId) +public sealed class EmailLoginStarted(UserId userId) : TelemetryEvent(("user_id", userId)); +public sealed class GravatarUpdated(long size) + : TelemetryEvent(("size", size)); + public sealed class Logout : TelemetryEvent; @@ -86,9 +86,6 @@ public sealed class UserCreated(UserId userId, bool gravatarProfileFound) public sealed class UserDeleted(UserId userId, bool bulkDeletion = false) : TelemetryEvent(("user_id", userId), ("bulk_deletion", bulkDeletion)); -public sealed class UsersBulkDeleted(int count) - : TelemetryEvent(("count", count)); - public sealed class UserInviteAccepted(UserId userId, int inviteAcceptedTimeInMinutes) : TelemetryEvent(("user_id", userId), ("invite_accepted_time_in_minutes", inviteAcceptedTimeInMinutes)); @@ -112,3 +109,6 @@ public sealed class UserRoleChanged(UserId userId, UserRole fromRole, UserRole t public sealed class UserUpdated : TelemetryEvent; + +public sealed class UsersBulkDeleted(int count) + : TelemetryEvent(("count", count)); diff --git a/application/account-management/Tests/Authentication/GetUserSessionsTests.cs b/application/account-management/Tests/Authentication/GetUserSessionsTests.cs index c8fa5c3ef..cbc227c75 100644 --- a/application/account-management/Tests/Authentication/GetUserSessionsTests.cs +++ b/application/account-management/Tests/Authentication/GetUserSessionsTests.cs @@ -177,6 +177,7 @@ private string InsertSession(long tenantId, string userId, bool isRevoked = fals ("RefreshTokenJti", jti), ("PreviousRefreshTokenJti", null), ("RefreshTokenVersion", 1), + ("LoginMethod", nameof(LoginMethod.OneTimePassword)), ("DeviceType", nameof(DeviceType.Desktop)), ("UserAgent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"), ("IpAddress", "127.0.0.1"), diff --git a/application/account-management/Tests/Authentication/RefreshAuthenticationTokensTests.cs b/application/account-management/Tests/Authentication/RefreshAuthenticationTokensTests.cs index 38d0d46c1..b9a557053 100644 --- a/application/account-management/Tests/Authentication/RefreshAuthenticationTokensTests.cs +++ b/application/account-management/Tests/Authentication/RefreshAuthenticationTokensTests.cs @@ -190,6 +190,7 @@ private void InsertSession(long tenantId, string userId, SessionId sessionId, Re ("RefreshTokenJti", jti.ToString()), ("PreviousRefreshTokenJti", null), ("RefreshTokenVersion", version), + ("LoginMethod", nameof(LoginMethod.OneTimePassword)), ("DeviceType", nameof(DeviceType.Desktop)), ("UserAgent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"), ("IpAddress", "127.0.0.1"), @@ -212,6 +213,7 @@ private void InsertSessionWithGracePeriod(long tenantId, string userId, SessionI ("RefreshTokenJti", currentJti.ToString()), ("PreviousRefreshTokenJti", previousJti?.ToString()), ("RefreshTokenVersion", currentVersion), + ("LoginMethod", nameof(LoginMethod.OneTimePassword)), ("DeviceType", nameof(DeviceType.Desktop)), ("UserAgent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"), ("IpAddress", "127.0.0.1"), diff --git a/application/account-management/Tests/Authentication/RevokeSessionTests.cs b/application/account-management/Tests/Authentication/RevokeSessionTests.cs index 695ead843..46afaaa48 100644 --- a/application/account-management/Tests/Authentication/RevokeSessionTests.cs +++ b/application/account-management/Tests/Authentication/RevokeSessionTests.cs @@ -105,6 +105,7 @@ private string InsertSession(long tenantId, string userId, bool isRevoked = fals ("RefreshTokenJti", jti), ("PreviousRefreshTokenJti", null), ("RefreshTokenVersion", 1), + ("LoginMethod", nameof(LoginMethod.OneTimePassword)), ("DeviceType", nameof(DeviceType.Desktop)), ("UserAgent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"), ("IpAddress", "127.0.0.1"), diff --git a/application/account-management/Tests/DatabaseSeeder.cs b/application/account-management/Tests/DatabaseSeeder.cs index ebb22967b..78d1f4639 100644 --- a/application/account-management/Tests/DatabaseSeeder.cs +++ b/application/account-management/Tests/DatabaseSeeder.cs @@ -25,10 +25,10 @@ public DatabaseSeeder(AccountManagementDbContext accountManagementDbContext) Tenant1Member = User.Create(Tenant1.Id, "member1@tenant-1.com", UserRole.Member, true, null); accountManagementDbContext.Set().AddRange(Tenant1Member); - Tenant1OwnerSession = Session.Create(Tenant1.Id, Tenant1Owner.Id, "TestUserAgent", IPAddress.Loopback); + Tenant1OwnerSession = Session.Create(Tenant1.Id, Tenant1Owner.Id, LoginMethod.OneTimePassword, "TestUserAgent", IPAddress.Loopback); accountManagementDbContext.Set().AddRange(Tenant1OwnerSession); - Tenant1MemberSession = Session.Create(Tenant1.Id, Tenant1Member.Id, "TestUserAgent", IPAddress.Loopback); + Tenant1MemberSession = Session.Create(Tenant1.Id, Tenant1Member.Id, LoginMethod.OneTimePassword, "TestUserAgent", IPAddress.Loopback); accountManagementDbContext.Set().AddRange(Tenant1MemberSession); accountManagementDbContext.SaveChanges(); diff --git a/application/account-management/Tests/Authentication/CompleteLoginTests.cs b/application/account-management/Tests/EmailAuthentication/CompleteEmailLoginTests.cs similarity index 62% rename from application/account-management/Tests/Authentication/CompleteLoginTests.cs rename to application/account-management/Tests/EmailAuthentication/CompleteEmailLoginTests.cs index d21f4ab40..e2b0976bd 100644 --- a/application/account-management/Tests/Authentication/CompleteLoginTests.cs +++ b/application/account-management/Tests/EmailAuthentication/CompleteEmailLoginTests.cs @@ -4,9 +4,8 @@ using FluentAssertions; using Microsoft.AspNetCore.Identity; using PlatformPlatform.AccountManagement.Database; -using PlatformPlatform.AccountManagement.Features.Authentication.Commands; -using PlatformPlatform.AccountManagement.Features.Authentication.Domain; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Commands; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain; using PlatformPlatform.AccountManagement.Features.Tenants.Domain; using PlatformPlatform.AccountManagement.Features.Users.Commands; using PlatformPlatform.AccountManagement.Features.Users.Domain; @@ -15,35 +14,35 @@ using PlatformPlatform.SharedKernel.Tests.Persistence; using Xunit; -namespace PlatformPlatform.AccountManagement.Tests.Authentication; +namespace PlatformPlatform.AccountManagement.Tests.EmailAuthentication; -public sealed class CompleteLoginTests : EndpointBaseTest +public sealed class CompleteEmailLoginTests : EndpointBaseTest { private const string CorrectOneTimePassword = "UNLOCK"; // UNLOCK is a special global OTP for development and tests private const string WrongOneTimePassword = "FAULTY"; [Fact] - public async Task CompleteLogin_WhenValid_ShouldCompleteLoginAndCreateTokens() + public async Task CompleteEmailLogin_WhenValid_ShouldCompleteEmailLoginAndCreateTokens() { // Arrange - var (loginId, _) = await StartLogin(DatabaseSeeder.Tenant1Owner.Email); - var command = new CompleteLoginCommand(CorrectOneTimePassword); + var emailLoginId = await StartEmailLogin(DatabaseSeeder.Tenant1Owner.Email); + var command = new CompleteEmailLoginCommand(CorrectOneTimePassword); // Act var response = await AnonymousHttpClient - .PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command); + .PostAsJsonAsync($"/api/account-management/authentication/email/login/{emailLoginId}/complete", command); // Assert await response.ShouldBeSuccessfulPostRequest(hasLocation: false); - var updatedLoginCount = Connection.ExecuteScalar( - "SELECT COUNT(*) FROM Logins WHERE Id = @id AND Completed = 1", [new { id = loginId.ToString() }] + var updatedEmailLoginCount = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM EmailLogins WHERE Id = @id AND Completed = 1", [new { id = emailLoginId.ToString() }] ); - updatedLoginCount.Should().Be(1); + updatedEmailLoginCount.Should().Be(1); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(3); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("LoginStarted"); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("EmailLoginStarted"); TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("SessionCreated"); - TelemetryEventsCollectorSpy.CollectedEvents[2].GetType().Name.Should().Be("LoginCompleted"); + TelemetryEventsCollectorSpy.CollectedEvents[2].GetType().Name.Should().Be("EmailLoginCompleted"); TelemetryEventsCollectorSpy.CollectedEvents[2].Properties["event.user_id"].Should().Be(DatabaseSeeder.Tenant1Owner.Id); TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); @@ -52,149 +51,128 @@ public async Task CompleteLogin_WhenValid_ShouldCompleteLoginAndCreateTokens() } [Fact] - public async Task CompleteLogin_WhenLoginNotFound_ShouldReturnBadRequest() + public async Task CompleteEmailLogin_WhenEmailLoginNotFound_ShouldReturnBadRequest() { // Arrange - var invalidLoginId = LoginId.NewId(); - var command = new CompleteLoginCommand(CorrectOneTimePassword); + var invalidEmailLoginId = EmailLoginId.NewId(); + var command = new CompleteEmailLoginCommand(CorrectOneTimePassword); // Act - var response = await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/login/{invalidLoginId}/complete", command); + var response = await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/email/login/{invalidEmailLoginId}/complete", command); // Assert - await response.ShouldHaveErrorStatusCode(HttpStatusCode.BadRequest, "The code is wrong or no longer valid."); + await response.ShouldHaveErrorStatusCode(HttpStatusCode.NotFound, $"Email login with id '{invalidEmailLoginId}' not found."); TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); } [Fact] - public async Task CompleteLogin_WhenInvalidOneTimePassword_ShouldReturnBadRequest() + public async Task CompleteEmailLogin_WhenInvalidOneTimePassword_ShouldReturnBadRequest() { // Arrange - var (loginId, emailConfirmationId) = await StartLogin(DatabaseSeeder.Tenant1Owner.Email); - var command = new CompleteLoginCommand(WrongOneTimePassword); + var emailLoginId = await StartEmailLogin(DatabaseSeeder.Tenant1Owner.Email); + var command = new CompleteEmailLoginCommand(WrongOneTimePassword); // Act var response = await AnonymousHttpClient - .PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command); + .PostAsJsonAsync($"/api/account-management/authentication/email/login/{emailLoginId}/complete", command); // Assert await response.ShouldHaveErrorStatusCode(HttpStatusCode.BadRequest, "The code is wrong or no longer valid."); - // Verify retry count increment and event collection - var loginCompleted = Connection.ExecuteScalar("SELECT Completed FROM Logins WHERE Id = @id", [new { id = loginId.ToString() }]); - loginCompleted.Should().Be(0); - var updatedRetryCount = Connection.ExecuteScalar("SELECT RetryCount FROM EmailConfirmations WHERE Id = @id", [new { id = emailConfirmationId.ToString() }]); + var updatedRetryCount = Connection.ExecuteScalar("SELECT RetryCount FROM EmailLogins WHERE Id = @id", [new { id = emailLoginId.ToString() }]); updatedRetryCount.Should().Be(1); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("LoginStarted"); - TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("EmailConfirmationFailed"); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("EmailLoginStarted"); + TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("EmailLoginCodeFailed"); TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); } [Fact] - public async Task CompleteLogin_WhenLoginAlreadyCompleted_ShouldReturnBadRequest() + public async Task CompleteEmailLogin_WhenEmailLoginAlreadyCompleted_ShouldReturnBadRequest() { // Arrange - var (loginId, _) = await StartLogin(DatabaseSeeder.Tenant1Owner.Email); - var command = new CompleteLoginCommand(CorrectOneTimePassword); - await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command); + var emailLoginId = await StartEmailLogin(DatabaseSeeder.Tenant1Owner.Email); + var command = new CompleteEmailLoginCommand(CorrectOneTimePassword); + await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/email/login/{emailLoginId}/complete", command); // Act var response = await AnonymousHttpClient - .PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command); + .PostAsJsonAsync($"/api/account-management/authentication/email/login/{emailLoginId}/complete", command); // Assert await response.ShouldHaveErrorStatusCode( - HttpStatusCode.BadRequest, $"The login process '{loginId}' for user '{DatabaseSeeder.Tenant1Owner.Id}' has already been completed." + HttpStatusCode.BadRequest, $"Email login with id '{emailLoginId}' has already been completed." ); } [Fact] - public async Task CompleteLogin_WhenRetryCountExceeded_ShouldReturnForbidden() + public async Task CompleteEmailLogin_WhenRetryCountExceeded_ShouldReturnForbidden() { // Arrange - var (loginId, emailConfirmationId) = await StartLogin(DatabaseSeeder.Tenant1Owner.Email); - var command = new CompleteLoginCommand(WrongOneTimePassword); - await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command); - await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command); - await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command); + var emailLoginId = await StartEmailLogin(DatabaseSeeder.Tenant1Owner.Email); + var command = new CompleteEmailLoginCommand(WrongOneTimePassword); + await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/email/login/{emailLoginId}/complete", command); + await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/email/login/{emailLoginId}/complete", command); + await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/email/login/{emailLoginId}/complete", command); // Act var response = await AnonymousHttpClient - .PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command); + .PostAsJsonAsync($"/api/account-management/authentication/email/login/{emailLoginId}/complete", command); // Assert await response.ShouldHaveErrorStatusCode(HttpStatusCode.Forbidden, "Too many attempts, please request a new code."); - // Verify retry count increment and event collection - var loginCompleted = Connection.ExecuteScalar( - "SELECT Completed FROM Logins WHERE Id = @id", [new { id = loginId.ToString() }] - ); - loginCompleted.Should().Be(0); var updatedRetryCount = Connection.ExecuteScalar( - "SELECT RetryCount FROM EmailConfirmations WHERE Id = @id", [new { id = emailConfirmationId.ToString() }] + "SELECT RetryCount FROM EmailLogins WHERE Id = @id", [new { id = emailLoginId.ToString() }] ); updatedRetryCount.Should().Be(4); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(5); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("LoginStarted"); - TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("EmailConfirmationFailed"); - TelemetryEventsCollectorSpy.CollectedEvents[2].GetType().Name.Should().Be("EmailConfirmationFailed"); - TelemetryEventsCollectorSpy.CollectedEvents[3].GetType().Name.Should().Be("EmailConfirmationFailed"); - TelemetryEventsCollectorSpy.CollectedEvents[4].GetType().Name.Should().Be("EmailConfirmationBlocked"); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("EmailLoginStarted"); + TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("EmailLoginCodeFailed"); + TelemetryEventsCollectorSpy.CollectedEvents[2].GetType().Name.Should().Be("EmailLoginCodeFailed"); + TelemetryEventsCollectorSpy.CollectedEvents[3].GetType().Name.Should().Be("EmailLoginCodeFailed"); + TelemetryEventsCollectorSpy.CollectedEvents[4].GetType().Name.Should().Be("EmailLoginCodeBlocked"); TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); } [Fact] - public async Task CompleteLogin_WhenLoginExpired_ShouldReturnBadRequest() + public async Task CompleteEmailLogin_WhenEmailLoginExpired_ShouldReturnBadRequest() { // Arrange - var loginId = LoginId.NewId(); - var emailConfirmationId = EmailConfirmationId.NewId(); - - // Insert expired login - Connection.Insert("Logins", [ - ("TenantId", DatabaseSeeder.Tenant1Owner.TenantId.ToString()), - ("UserId", DatabaseSeeder.Tenant1Owner.Id.ToString()), - ("Id", loginId.ToString()), - ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)), - ("ModifiedAt", null), - ("EmailConfirmationId", emailConfirmationId.ToString()), - ("Completed", false) - ] - ); - Connection.Insert("EmailConfirmations", [ - ("Id", emailConfirmationId.ToString()), + var emailLoginId = EmailLoginId.NewId(); + + Connection.Insert("EmailLogins", [ + ("Id", emailLoginId.ToString()), ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)), ("ModifiedAt", null), ("Email", DatabaseSeeder.Tenant1Owner.Email), - ("Type", EmailConfirmationType.Signup), + ("Type", nameof(EmailLoginType.Login)), ("OneTimePasswordHash", new PasswordHasher().HashPassword(this, CorrectOneTimePassword)), - ("ValidUntil", TimeProvider.GetUtcNow().AddMinutes(-10)), ("RetryCount", 0), ("ResendCount", 0), ("Completed", false) ] ); - var command = new CompleteLoginCommand(CorrectOneTimePassword); + var command = new CompleteEmailLoginCommand(CorrectOneTimePassword); // Act - var response = await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command); + var response = await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/email/login/{emailLoginId}/complete", command); // Assert await response.ShouldHaveErrorStatusCode(HttpStatusCode.BadRequest, "The code is no longer valid, please request a new code."); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("EmailConfirmationExpired"); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("EmailLoginCodeExpired"); TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); } [Fact] - public async Task CompleteLogin_WhenUserInviteCompleted_ShouldTrackUserInviteAcceptedEvent() + public async Task CompleteEmailLogin_WhenUserInviteCompleted_ShouldTrackUserInviteAcceptedEvent() { // Arrange Connection.Update("Tenants", "Id", DatabaseSeeder.Tenant1.Id.ToString(), [("Name", "Test Company")]); @@ -204,12 +182,12 @@ public async Task CompleteLogin_WhenUserInviteCompleted_ShouldTrackUserInviteAcc await AuthenticatedOwnerHttpClient.PostAsJsonAsync("/api/account-management/users/invite", inviteUserCommand); TelemetryEventsCollectorSpy.Reset(); - var (loginId, _) = await StartLogin(email); - var command = new CompleteLoginCommand(CorrectOneTimePassword); + var emailLoginId = await StartEmailLogin(email); + var command = new CompleteEmailLoginCommand(CorrectOneTimePassword); // Act var response = await AnonymousHttpClient - .PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command); + .PostAsJsonAsync($"/api/account-management/authentication/email/login/{emailLoginId}/complete", command); // Assert await response.ShouldBeSuccessfulPostRequest(hasLocation: false); @@ -219,15 +197,15 @@ public async Task CompleteLogin_WhenUserInviteCompleted_ShouldTrackUserInviteAcc ).Should().Be(1); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(4); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("LoginStarted"); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("EmailLoginStarted"); TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("UserInviteAccepted"); TelemetryEventsCollectorSpy.CollectedEvents[2].GetType().Name.Should().Be("SessionCreated"); - TelemetryEventsCollectorSpy.CollectedEvents[3].GetType().Name.Should().Be("LoginCompleted"); + TelemetryEventsCollectorSpy.CollectedEvents[3].GetType().Name.Should().Be("EmailLoginCompleted"); TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); } [Fact] - public async Task CompleteLogin_WithValidPreferredTenant_ShouldLoginToPreferredTenant() + public async Task CompleteEmailLogin_WithValidPreferredTenant_ShouldLoginToPreferredTenant() { // Arrange var tenant2Id = TenantId.NewId(); @@ -259,13 +237,13 @@ public async Task CompleteLogin_WithValidPreferredTenant_ShouldLoginToPreferredT ] ); - var (loginId, _) = await StartLogin(DatabaseSeeder.Tenant1Owner.Email); - var command = new CompleteLoginCommand(CorrectOneTimePassword, tenant2Id); + var emailLoginId = await StartEmailLogin(DatabaseSeeder.Tenant1Owner.Email); + var command = new CompleteEmailLoginCommand(CorrectOneTimePassword, tenant2Id); TelemetryEventsCollectorSpy.Reset(); // Act var response = await AnonymousHttpClient - .PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command); + .PostAsJsonAsync($"/api/account-management/authentication/email/login/{emailLoginId}/complete", command); // Assert await response.ShouldBeSuccessfulPostRequest(hasLocation: false); @@ -274,22 +252,22 @@ public async Task CompleteLogin_WithValidPreferredTenant_ShouldLoginToPreferredT TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("SessionCreated"); - TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("LoginCompleted"); + TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("EmailLoginCompleted"); TelemetryEventsCollectorSpy.CollectedEvents[1].Properties["event.user_id"].Should().Be(user2Id); } [Fact] - public async Task CompleteLogin_WithInvalidPreferredTenant_ShouldLoginToDefaultTenant() + public async Task CompleteEmailLogin_WithInvalidPreferredTenant_ShouldLoginToDefaultTenant() { // Arrange var invalidTenantId = TenantId.NewId(); - var (loginId, _) = await StartLogin(DatabaseSeeder.Tenant1Owner.Email); - var command = new CompleteLoginCommand(CorrectOneTimePassword, invalidTenantId); + var emailLoginId = await StartEmailLogin(DatabaseSeeder.Tenant1Owner.Email); + var command = new CompleteEmailLoginCommand(CorrectOneTimePassword, invalidTenantId); TelemetryEventsCollectorSpy.Reset(); // Act var response = await AnonymousHttpClient - .PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command); + .PostAsJsonAsync($"/api/account-management/authentication/email/login/{emailLoginId}/complete", command); // Assert await response.ShouldBeSuccessfulPostRequest(hasLocation: false); @@ -298,12 +276,12 @@ public async Task CompleteLogin_WithInvalidPreferredTenant_ShouldLoginToDefaultT TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("SessionCreated"); - TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("LoginCompleted"); + TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("EmailLoginCompleted"); TelemetryEventsCollectorSpy.CollectedEvents[1].Properties["event.user_id"].Should().Be(DatabaseSeeder.Tenant1Owner.Id); } [Fact] - public async Task CompleteLogin_WithPreferredTenantUserDoesNotHaveAccess_ShouldLoginToDefaultTenant() + public async Task CompleteEmailLogin_WithPreferredTenantUserDoesNotHaveAccess_ShouldLoginToDefaultTenant() { // Arrange var tenant2Id = TenantId.NewId(); @@ -318,28 +296,28 @@ public async Task CompleteLogin_WithPreferredTenantUserDoesNotHaveAccess_ShouldL ] ); - var (loginId, _) = await StartLogin(DatabaseSeeder.Tenant1Owner.Email); - var command = new CompleteLoginCommand(CorrectOneTimePassword, tenant2Id); + var emailLoginId = await StartEmailLogin(DatabaseSeeder.Tenant1Owner.Email); + var command = new CompleteEmailLoginCommand(CorrectOneTimePassword, tenant2Id); TelemetryEventsCollectorSpy.Reset(); // Act var response = await AnonymousHttpClient - .PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command); + .PostAsJsonAsync($"/api/account-management/authentication/email/login/{emailLoginId}/complete", command); // Assert await response.ShouldBeSuccessfulPostRequest(hasLocation: false); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("SessionCreated"); - TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("LoginCompleted"); + TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("EmailLoginCompleted"); TelemetryEventsCollectorSpy.CollectedEvents[1].Properties["event.user_id"].Should().Be(DatabaseSeeder.Tenant1Owner.Id); } - private async Task<(LoginId LoginId, EmailConfirmationId emailConfirmationId)> StartLogin(string email) + private async Task StartEmailLogin(string email) { - var command = new StartLoginCommand(email); - var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/login/start", command); - var responseBody = await response.DeserializeResponse(); - return (responseBody!.LoginId, responseBody.EmailConfirmationId); + var command = new StartEmailLoginCommand(email); + var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/email/login/start", command); + var responseBody = await response.DeserializeResponse(); + return responseBody!.EmailLoginId; } } diff --git a/application/account-management/Tests/Authentication/StartLoginTests.cs b/application/account-management/Tests/EmailAuthentication/StartEmailLoginTests.cs similarity index 77% rename from application/account-management/Tests/Authentication/StartLoginTests.cs rename to application/account-management/Tests/EmailAuthentication/StartEmailLoginTests.cs index 94b1388f9..863e263f1 100644 --- a/application/account-management/Tests/Authentication/StartLoginTests.cs +++ b/application/account-management/Tests/EmailAuthentication/StartEmailLoginTests.cs @@ -5,8 +5,8 @@ using Microsoft.AspNetCore.Identity; using NSubstitute; using PlatformPlatform.AccountManagement.Database; -using PlatformPlatform.AccountManagement.Features.Authentication.Commands; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Commands; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain; using PlatformPlatform.AccountManagement.Features.Users.Domain; using PlatformPlatform.SharedKernel.Authentication; using PlatformPlatform.SharedKernel.Domain; @@ -15,28 +15,28 @@ using PlatformPlatform.SharedKernel.Validation; using Xunit; -namespace PlatformPlatform.AccountManagement.Tests.Authentication; +namespace PlatformPlatform.AccountManagement.Tests.EmailAuthentication; -public sealed class StartLoginTests : EndpointBaseTest +public sealed class StartEmailLoginTests : EndpointBaseTest { [Fact] - public async Task StartLogin_WhenValidEmailAndUserExists_ShouldReturnSuccess() + public async Task StartEmailLogin_WhenValidEmailAndUserExists_ShouldReturnSuccess() { // Arrange var email = DatabaseSeeder.Tenant1Owner.Email; - var command = new StartLoginCommand(email); + var command = new StartEmailLoginCommand(email); // Act - var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/login/start", command); + var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/email/login/start", command); // Assert response.EnsureSuccessStatusCode(); - var responseBody = await response.DeserializeResponse(); + var responseBody = await response.DeserializeResponse(); responseBody.Should().NotBeNull(); responseBody.ValidForSeconds.Should().Be(300); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("LoginStarted"); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("EmailLoginStarted"); TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.user_id"].Should().Be(DatabaseSeeder.Tenant1Owner.Id); TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); @@ -49,13 +49,13 @@ await EmailClient.Received(1).SendAsync( } [Fact] - public async Task StartLoginCommand_WhenEmailIsEmpty_ShouldFail() + public async Task StartEmailLoginCommand_WhenEmailIsEmpty_ShouldFail() { // Arrange - var command = new StartLoginCommand(""); + var command = new StartEmailLoginCommand(""); // Act - var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/login/start", command); + var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/email/login/start", command); // Assert var expectedErrors = new[] @@ -74,13 +74,13 @@ public async Task StartLoginCommand_WhenEmailIsEmpty_ShouldFail() [InlineData("Double Dots In Domain", "neo@gmail..com")] [InlineData("Comma Instead Of Dot", "q@q,com")] [InlineData("Space In Domain", "tje@mentum .dk")] - public async Task StartLoginCommand_WhenEmailInvalid_ShouldFail(string scenario, string invalidEmail) + public async Task StartEmailLoginCommand_WhenEmailInvalid_ShouldFail(string scenario, string invalidEmail) { // Arrange - var command = new StartLoginCommand(invalidEmail); + var command = new StartEmailLoginCommand(invalidEmail); // Act - var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/login/start", command); + var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/email/login/start", command); // Assert var expectedErrors = new[] @@ -94,18 +94,18 @@ public async Task StartLoginCommand_WhenEmailInvalid_ShouldFail(string scenario, } [Fact] - public async Task StartLoginCommand_WhenUserDoesNotExist_ShouldReturnFakeLoginId() + public async Task StartEmailLoginCommand_WhenUserDoesNotExist_ShouldReturnFakeEmailLoginId() { // Arrange var email = Faker.Internet.UniqueEmail(); - var command = new StartLoginCommand(email); + var command = new StartEmailLoginCommand(email); // Act - var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/login/start", command); + var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/email/login/start", command); // Assert response.EnsureSuccessStatusCode(); - var responseBody = await response.DeserializeResponse(); + var responseBody = await response.DeserializeResponse(); responseBody.Should().NotBeNull(); responseBody.ValidForSeconds.Should().Be(300); @@ -120,7 +120,7 @@ await EmailClient.Received(1).SendAsync( } [Fact] - public async Task StartLogin_WhenTooManyAttempts_ShouldReturnTooManyRequests() + public async Task StartEmailLogin_WhenTooManyAttempts_ShouldReturnTooManyRequests() { // Arrange var email = DatabaseSeeder.Tenant1Owner.Email; @@ -128,14 +128,13 @@ public async Task StartLogin_WhenTooManyAttempts_ShouldReturnTooManyRequests() for (var i = 1; i <= 4; i++) { var oneTimePasswordHash = new PasswordHasher().HashPassword(this, OneTimePasswordHelper.GenerateOneTimePassword(6)); - Connection.Insert("EmailConfirmations", [ - ("Id", EmailConfirmationId.NewId().ToString()), - ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-i)), + Connection.Insert("EmailLogins", [ + ("Id", EmailLoginId.NewId().ToString()), + ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)), ("ModifiedAt", null), ("Email", email.ToLower()), - ("Type", nameof(EmailConfirmationType.Login)), + ("Type", nameof(EmailLoginType.Login)), ("OneTimePasswordHash", oneTimePasswordHash), - ("ValidUntil", TimeProvider.GetUtcNow().AddMinutes(-i - 1)), // All should be expired ("RetryCount", 0), ("ResendCount", 0), ("Completed", false) @@ -143,10 +142,10 @@ public async Task StartLogin_WhenTooManyAttempts_ShouldReturnTooManyRequests() ); } - var command = new StartLoginCommand(email); + var command = new StartEmailLoginCommand(email); // Act - var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/login/start", command); + var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/email/login/start", command); // Assert await response.ShouldHaveErrorStatusCode(HttpStatusCode.TooManyRequests, "Too many attempts to confirm this email address. Please try again later."); @@ -156,7 +155,7 @@ public async Task StartLogin_WhenTooManyAttempts_ShouldReturnTooManyRequests() } [Fact] - public async Task StartLogin_WhenUserIsSoftDeleted_ShouldReturnFakeLoginIdAndSendUnknownUserEmail() + public async Task StartEmailLogin_WhenUserIsSoftDeleted_ShouldReturnFakeEmailLoginIdAndSendUnknownUserEmail() { // Arrange var email = Faker.Internet.UniqueEmail(); @@ -177,14 +176,14 @@ public async Task StartLogin_WhenUserIsSoftDeleted_ShouldReturnFakeLoginIdAndSen ] ); - var command = new StartLoginCommand(email); + var command = new StartEmailLoginCommand(email); // Act - var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/login/start", command); + var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/email/login/start", command); // Assert response.EnsureSuccessStatusCode(); - var responseBody = await response.DeserializeResponse(); + var responseBody = await response.DeserializeResponse(); responseBody.Should().NotBeNull(); responseBody.ValidForSeconds.Should().Be(300); diff --git a/application/account-management/Tests/Signups/CompleteSignupTests.cs b/application/account-management/Tests/Signups/CompleteEmailSignupTests.cs similarity index 64% rename from application/account-management/Tests/Signups/CompleteSignupTests.cs rename to application/account-management/Tests/Signups/CompleteEmailSignupTests.cs index 6b2ab87b1..978e8b52d 100644 --- a/application/account-management/Tests/Signups/CompleteSignupTests.cs +++ b/application/account-management/Tests/Signups/CompleteEmailSignupTests.cs @@ -5,8 +5,8 @@ using Microsoft.Extensions.DependencyInjection; using NSubstitute; using PlatformPlatform.AccountManagement.Database; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; -using PlatformPlatform.AccountManagement.Features.Signups.Commands; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Commands; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain; using PlatformPlatform.AccountManagement.Features.Tenants.EventHandlers; using PlatformPlatform.SharedKernel.Tests; using PlatformPlatform.SharedKernel.Tests.Persistence; @@ -14,7 +14,7 @@ namespace PlatformPlatform.AccountManagement.Tests.Signups; -public sealed class CompleteSignupTests : EndpointBaseTest +public sealed class CompleteEmailSignupTests : EndpointBaseTest { private const string CorrectOneTimePassword = "UNLOCK"; // UNLOCK is a special global OTP for development and tests private const string WrongOneTimePassword = "FAULTY"; @@ -30,13 +30,13 @@ public async Task CompleteSignup_WhenValid_ShouldCreateTenantAndOwnerUser() { // Arrange var email = Faker.Internet.UniqueEmail(); - var emailConfirmationId = await StartSignup(email); + var emailLoginId = await StartSignup(email); - var command = new CompleteSignupCommand(CorrectOneTimePassword, "en-US"); + var command = new CompleteEmailSignupCommand(CorrectOneTimePassword, "en-US"); // Act var response = await AnonymousHttpClient - .PostAsJsonAsync($"/api/account-management/signups/{emailConfirmationId}/complete", command); + .PostAsJsonAsync($"/api/account-management/authentication/email/signup/{emailLoginId}/complete", command); // Assert await response.ShouldBeSuccessfulPostRequest(hasLocation: false); @@ -57,15 +57,15 @@ public async Task CompleteSignup_WhenValid_ShouldCreateTenantAndOwnerUser() public async Task CompleteSignup_WhenSignupNotFound_ShouldReturnNotFound() { // Arrange - var invalidEmailConfirmationId = EmailConfirmationId.NewId(); - var command = new CompleteSignupCommand(CorrectOneTimePassword, "en-US"); + var invalidEmailLoginId = EmailLoginId.NewId(); + var command = new CompleteEmailSignupCommand(CorrectOneTimePassword, "en-US"); // Act var response = await AnonymousHttpClient - .PostAsJsonAsync($"/api/account-management/signups/{invalidEmailConfirmationId}/complete", command); + .PostAsJsonAsync($"/api/account-management/authentication/email/signup/{invalidEmailLoginId}/complete", command); // Assert - var expectedDetail = $"Email confirmation with id '{invalidEmailConfirmationId}' not found."; + var expectedDetail = $"Email login with id '{invalidEmailLoginId}' not found."; await response.ShouldHaveErrorStatusCode(HttpStatusCode.NotFound, expectedDetail); } @@ -73,20 +73,20 @@ public async Task CompleteSignup_WhenSignupNotFound_ShouldReturnNotFound() public async Task CompleteSignup_WhenInvalidOneTimePassword_ShouldReturnBadRequest() { // Arrange - var emailConfirmationId = await StartSignup(Faker.Internet.UniqueEmail()); + var emailLoginId = await StartSignup(Faker.Internet.UniqueEmail()); - var command = new CompleteSignupCommand(WrongOneTimePassword, "en-US"); + var command = new CompleteEmailSignupCommand(WrongOneTimePassword, "en-US"); // Act var response = await AnonymousHttpClient - .PostAsJsonAsync($"/api/account-management/signups/{emailConfirmationId}/complete", command); + .PostAsJsonAsync($"/api/account-management/authentication/email/signup/{emailLoginId}/complete", command); // Assert await response.ShouldHaveErrorStatusCode(HttpStatusCode.BadRequest, "The code is wrong or no longer valid."); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("SignupStarted"); - TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("EmailConfirmationFailed"); + TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("EmailLoginCodeFailed"); TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); } @@ -94,43 +94,43 @@ public async Task CompleteSignup_WhenInvalidOneTimePassword_ShouldReturnBadReque public async Task CompleteSignup_WhenSignupAlreadyCompleted_ShouldReturnBadRequest() { // Arrange - var emailConfirmationId = await StartSignup(Faker.Internet.UniqueEmail()); + var emailLoginId = await StartSignup(Faker.Internet.UniqueEmail()); - var command = new CompleteSignupCommand(CorrectOneTimePassword, "en-US") { EmailConfirmationId = emailConfirmationId }; - await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/signups/{emailConfirmationId}/complete", command); + var command = new CompleteEmailSignupCommand(CorrectOneTimePassword, "en-US") { EmailLoginId = emailLoginId }; + await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/email/signup/{emailLoginId}/complete", command); // Act var response = await AnonymousHttpClient - .PostAsJsonAsync($"/api/account-management/signups/{emailConfirmationId}/complete", command); + .PostAsJsonAsync($"/api/account-management/authentication/email/signup/{emailLoginId}/complete", command); // Assert - await response.ShouldHaveErrorStatusCode(HttpStatusCode.BadRequest, $"Email confirmation with id {emailConfirmationId} has already been completed."); + await response.ShouldHaveErrorStatusCode(HttpStatusCode.BadRequest, $"Email login with id '{emailLoginId}' has already been completed."); } [Fact] public async Task CompleteSignup_WhenRetryCountExceeded_ShouldReturnForbidden() { // Arrange - var emailConfirmationId = await StartSignup(Faker.Internet.UniqueEmail()); + var emailLoginId = await StartSignup(Faker.Internet.UniqueEmail()); - var command = new CompleteSignupCommand(WrongOneTimePassword, "en-US"); - await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/signups/{emailConfirmationId}/complete", command); - await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/signups/{emailConfirmationId}/complete", command); - await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/signups/{emailConfirmationId}/complete", command); + var command = new CompleteEmailSignupCommand(WrongOneTimePassword, "en-US"); + await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/email/signup/{emailLoginId}/complete", command); + await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/email/signup/{emailLoginId}/complete", command); + await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/email/signup/{emailLoginId}/complete", command); // Act var response = await AnonymousHttpClient - .PostAsJsonAsync($"/api/account-management/signups/{emailConfirmationId}/complete", command); + .PostAsJsonAsync($"/api/account-management/authentication/email/signup/{emailLoginId}/complete", command); // Assert await response.ShouldHaveErrorStatusCode(HttpStatusCode.Forbidden, "Too many attempts, please request a new code."); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(5); TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("SignupStarted"); - TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("EmailConfirmationFailed"); - TelemetryEventsCollectorSpy.CollectedEvents[2].GetType().Name.Should().Be("EmailConfirmationFailed"); - TelemetryEventsCollectorSpy.CollectedEvents[3].GetType().Name.Should().Be("EmailConfirmationFailed"); - TelemetryEventsCollectorSpy.CollectedEvents[4].GetType().Name.Should().Be("EmailConfirmationBlocked"); + TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("EmailLoginCodeFailed"); + TelemetryEventsCollectorSpy.CollectedEvents[2].GetType().Name.Should().Be("EmailLoginCodeFailed"); + TelemetryEventsCollectorSpy.CollectedEvents[3].GetType().Name.Should().Be("EmailLoginCodeFailed"); + TelemetryEventsCollectorSpy.CollectedEvents[4].GetType().Name.Should().Be("EmailLoginCodeBlocked"); TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); } @@ -140,40 +140,39 @@ public async Task CompleteSignup_WhenSignupExpired_ShouldReturnBadRequest() // Arrange var email = Faker.Internet.UniqueEmail(); - var emailConfirmationId = EmailConfirmationId.NewId(); - Connection.Insert("EmailConfirmations", [ - ("Id", emailConfirmationId.ToString()), + var emailLoginId = EmailLoginId.NewId(); + Connection.Insert("EmailLogins", [ + ("Id", emailLoginId.ToString()), ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)), ("ModifiedAt", null), ("Email", email), - ("Type", EmailConfirmationType.Signup), + ("Type", nameof(EmailLoginType.Signup)), ("OneTimePasswordHash", new PasswordHasher().HashPassword(this, CorrectOneTimePassword)), - ("ValidUntil", TimeProvider.GetUtcNow().AddMinutes(-5)), ("RetryCount", 0), ("ResendCount", 0), ("Completed", false) ] ); - var command = new CompleteSignupCommand(CorrectOneTimePassword, "en-US") { EmailConfirmationId = emailConfirmationId }; + var command = new CompleteEmailSignupCommand(CorrectOneTimePassword, "en-US") { EmailLoginId = emailLoginId }; // Act var response = await AnonymousHttpClient - .PostAsJsonAsync($"/api/account-management/signups/{emailConfirmationId}/complete", command); + .PostAsJsonAsync($"/api/account-management/authentication/email/signup/{emailLoginId}/complete", command); // Assert await response.ShouldHaveErrorStatusCode(HttpStatusCode.BadRequest, "The code is no longer valid, please request a new code."); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("EmailConfirmationExpired"); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("EmailLoginCodeExpired"); TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); } - private async Task StartSignup(string email) + private async Task StartSignup(string email) { - var command = new StartSignupCommand(email); - var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/signups/start", command); - var responseBody = await response.DeserializeResponse(); - return responseBody!.EmailConfirmationId; + var command = new StartEmailSignupCommand(email); + var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/email/signup/start", command); + var responseBody = await response.DeserializeResponse(); + return responseBody!.EmailLoginId; } } diff --git a/application/account-management/Tests/Signups/StartSignupTests.cs b/application/account-management/Tests/Signups/StartEmailSignupTests.cs similarity index 78% rename from application/account-management/Tests/Signups/StartSignupTests.cs rename to application/account-management/Tests/Signups/StartEmailSignupTests.cs index 7cb6e9978..b43eb513a 100644 --- a/application/account-management/Tests/Signups/StartSignupTests.cs +++ b/application/account-management/Tests/Signups/StartEmailSignupTests.cs @@ -4,8 +4,8 @@ using Microsoft.AspNetCore.Identity; using NSubstitute; using PlatformPlatform.AccountManagement.Database; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; -using PlatformPlatform.AccountManagement.Features.Signups.Commands; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Commands; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain; using PlatformPlatform.SharedKernel.Authentication; using PlatformPlatform.SharedKernel.Tests; using PlatformPlatform.SharedKernel.Tests.Persistence; @@ -14,23 +14,23 @@ namespace PlatformPlatform.AccountManagement.Tests.Signups; -public sealed class StartSignupTests : EndpointBaseTest +public sealed class StartEmailSignupTests : EndpointBaseTest { [Fact] public async Task StartSignup_WhenEmailIsValid_ShouldReturnSuccess() { // Arrange var email = Faker.Internet.UniqueEmail(); - var command = new StartSignupCommand(email); + var command = new StartEmailSignupCommand(email); // Act - var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/signups/start", command); + var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/email/signup/start", command); // Assert response.EnsureSuccessStatusCode(); - var responseBody = await response.DeserializeResponse(); + var responseBody = await response.DeserializeResponse(); responseBody.Should().NotBeNull(); - responseBody.EmailConfirmationId.ToString().Should().NotBeNullOrEmpty(); + responseBody.EmailLoginId.ToString().Should().NotBeNullOrEmpty(); responseBody.ValidForSeconds.Should().Be(300); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); @@ -50,10 +50,10 @@ public async Task StartSignup_WhenInvalidEmail_ShouldReturnBadRequest() { // Arrange var invalidEmail = "invalid email"; - var command = new StartSignupCommand(invalidEmail); + var command = new StartEmailSignupCommand(invalidEmail); // Act - var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/signups/start", command); + var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/email/signup/start", command); // Assert var expectedErrors = new[] @@ -76,14 +76,13 @@ public async Task StartSignup_WhenTooManyAttempts_ShouldReturnTooManyRequests() for (var i = 1; i <= 4; i++) { var oneTimePasswordHash = new PasswordHasher().HashPassword(this, OneTimePasswordHelper.GenerateOneTimePassword(6)); - Connection.Insert("EmailConfirmations", [ - ("Id", EmailConfirmationId.NewId().ToString()), - ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-i)), + Connection.Insert("EmailLogins", [ + ("Id", EmailLoginId.NewId().ToString()), + ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)), ("ModifiedAt", null), ("Email", email), - ("Type", nameof(EmailConfirmationType.Signup)), + ("Type", nameof(EmailLoginType.Signup)), ("OneTimePasswordHash", oneTimePasswordHash), - ("ValidUntil", TimeProvider.GetUtcNow().AddMinutes(-i - 1)), // All should be expired ("RetryCount", 0), ("ResendCount", 0), ("Completed", false) @@ -91,10 +90,10 @@ public async Task StartSignup_WhenTooManyAttempts_ShouldReturnTooManyRequests() ); } - var command = new StartSignupCommand(email); + var command = new StartEmailSignupCommand(email); // Act - var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/signups/start", command); + var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/email/signup/start", command); // Assert await response.ShouldHaveErrorStatusCode(HttpStatusCode.TooManyRequests, "Too many attempts to confirm this email address. Please try again later."); diff --git a/application/account-management/Tests/Users/DeleteUserTests.cs b/application/account-management/Tests/Users/DeleteUserTests.cs index e19d5fda1..2789f2593 100644 --- a/application/account-management/Tests/Users/DeleteUserTests.cs +++ b/application/account-management/Tests/Users/DeleteUserTests.cs @@ -2,8 +2,7 @@ using System.Text.Json; using FluentAssertions; using PlatformPlatform.AccountManagement.Database; -using PlatformPlatform.AccountManagement.Features.Authentication.Domain; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain; using PlatformPlatform.AccountManagement.Features.Users.Domain; using PlatformPlatform.SharedKernel.Domain; using PlatformPlatform.SharedKernel.Tests; @@ -73,7 +72,7 @@ public async Task DeleteUser_WhenDeletingOwnUSer_ShouldGetForbidden() } [Fact] - public async Task DeleteUser_WhenUserHasLoginHistory_ShouldSoftDeleteUserAndKeepLogins() + public async Task DeleteUser_WhenUserHasEmailLoginHistory_ShouldSoftDeleteUserAndKeepEmailLogins() { // Arrange var userId = UserId.NewId(); @@ -94,15 +93,17 @@ public async Task DeleteUser_WhenUserHasLoginHistory_ShouldSoftDeleteUserAndKeep ] ); - var emailConfirmationId = EmailConfirmationId.NewId(); - var loginId = LoginId.NewId(); - Connection.Insert("Logins", [ - ("TenantId", DatabaseSeeder.Tenant1.Id.ToString()), - ("Id", loginId.ToString()), - ("UserId", userId.ToString()), + var email = Connection.ExecuteScalar("SELECT Email FROM Users WHERE Id = @id", [new { id = userId.ToString() }]); + var emailLoginId = EmailLoginId.NewId(); + Connection.Insert("EmailLogins", [ + ("Id", emailLoginId.ToString()), ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-5)), ("ModifiedAt", null), - ("EmailConfirmationId", emailConfirmationId.ToString()), + ("Email", email), + ("Type", nameof(EmailLoginType.Login)), + ("OneTimePasswordHash", "hash"), + ("RetryCount", 0), + ("ResendCount", 0), ("Completed", true) ] ); @@ -115,7 +116,7 @@ public async Task DeleteUser_WhenUserHasLoginHistory_ShouldSoftDeleteUserAndKeep Connection.RowExists("Users", userId.ToString()).Should().BeTrue(); var deletedAt = Connection.ExecuteScalar("SELECT DeletedAt FROM Users WHERE Id = @id", [new { id = userId.ToString() }]); deletedAt.Should().NotBeNullOrEmpty(); - Connection.RowExists("Logins", loginId.ToString()).Should().BeTrue(); + Connection.RowExists("EmailLogins", emailLoginId.ToString()).Should().BeTrue(); } [Fact] diff --git a/application/account-management/WebApp/routes/login/-shared/loginState.ts b/application/account-management/WebApp/routes/login/-shared/loginState.ts index 9004e2e18..4ab01c2fe 100644 --- a/application/account-management/WebApp/routes/login/-shared/loginState.ts +++ b/application/account-management/WebApp/routes/login/-shared/loginState.ts @@ -1,8 +1,7 @@ import type { Schemas } from "@/shared/lib/api/client"; interface LoginState { - loginId: Schemas["LoginId"]; - emailConfirmationId: Schemas["EmailConfirmationId"]; + emailLoginId: Schemas["EmailLoginId"]; email: string; expireAt: Date; codeCount: number; diff --git a/application/account-management/WebApp/routes/login/index.tsx b/application/account-management/WebApp/routes/login/index.tsx index a101e2dd4..3110f1f44 100644 --- a/application/account-management/WebApp/routes/login/index.tsx +++ b/application/account-management/WebApp/routes/login/index.tsx @@ -47,15 +47,14 @@ export function LoginForm() { const [email, setEmail] = useState(savedEmail || signupEmail || ""); const { returnPath } = Route.useSearch(); - const startLoginMutation = api.useMutation("post", "/api/account-management/authentication/login/start"); + const startLoginMutation = api.useMutation("post", "/api/account-management/authentication/email/login/start"); if (startLoginMutation.isSuccess) { - const { loginId, emailConfirmationId, validForSeconds } = startLoginMutation.data; + const { emailLoginId, validForSeconds } = startLoginMutation.data; clearLoginState(); setLoginState({ - loginId, - emailConfirmationId, + emailLoginId, email, expireAt: new Date(Date.now() + validForSeconds * 1000) }); diff --git a/application/account-management/WebApp/routes/login/verify.tsx b/application/account-management/WebApp/routes/login/verify.tsx index 867419b0a..3af9b860d 100644 --- a/application/account-management/WebApp/routes/login/verify.tsx +++ b/application/account-management/WebApp/routes/login/verify.tsx @@ -87,7 +87,7 @@ function useCountdown(expireAt: Date) { export function CompleteLoginForm() { const initialState = getLoginState(); - const { email = "", emailConfirmationId = "", loginId } = initialState; + const { email = "", emailLoginId } = initialState; const initialExpireAt = initialState.expireAt ? new Date(initialState.expireAt) : new Date(); const [expireAt, setExpireAt] = useState(initialExpireAt); const secondsRemaining = useCountdown(expireAt); @@ -135,7 +135,7 @@ export function CompleteLoginForm() { }, 100); }, []); - const completeLoginMutation = api.useMutation("post", "/api/account-management/authentication/login/{id}/complete", { + const completeLoginMutation = api.useMutation("post", "/api/account-management/authentication/email/login/{id}/complete", { onSuccess: () => { // Broadcast login event to other tabs // Since the API returns 204 No Content, we don't have the user ID yet @@ -154,7 +154,7 @@ export function CompleteLoginForm() { const resendLoginCodeMutation = api.useMutation( "post", - "/api/account-management/authentication/login/{emailConfirmationId}/resend-code", + "/api/account-management/authentication/email/login/{id}/resend-code", { onSuccess: (data) => { if (data) { @@ -188,7 +188,7 @@ export function CompleteLoginForm() { const expiresInString = `${Math.floor(secondsRemaining / 60)}:${String(secondsRemaining % 60).padStart(2, "0")}`; - if (!loginId) { + if (!emailLoginId) { return null; } @@ -203,7 +203,7 @@ export function CompleteLoginForm() { completeLoginMutation.mutate({ params: { - path: { id: loginId } + path: { id: emailLoginId } }, body: { oneTimePassword: otpValue, @@ -214,8 +214,7 @@ export function CompleteLoginForm() { validationErrors={completeLoginMutation.error?.errors} validationBehavior="aria" > - - +
@@ -296,7 +295,7 @@ export function CompleteLoginForm() { ) : (
{ - mutationSubmitter(resendLoginCodeMutation, { path: { emailConfirmationId } })(e); + mutationSubmitter(resendLoginCodeMutation, { path: { id: emailLoginId } })(e); }} validationErrors={resendLoginCodeMutation.error?.errors} className="inline" diff --git a/application/account-management/WebApp/routes/signup/-shared/signupState.ts b/application/account-management/WebApp/routes/signup/-shared/signupState.ts index 7971c769c..af9bcd35d 100644 --- a/application/account-management/WebApp/routes/signup/-shared/signupState.ts +++ b/application/account-management/WebApp/routes/signup/-shared/signupState.ts @@ -1,7 +1,7 @@ import type { Schemas } from "@/shared/lib/api/client"; interface SignupState { - emailConfirmationId: Schemas["EmailConfirmationId"]; + emailLoginId: Schemas["EmailLoginId"]; email: string; expireAt: Date; codeCount: number; diff --git a/application/account-management/WebApp/routes/signup/index.tsx b/application/account-management/WebApp/routes/signup/index.tsx index 55b53e03a..6034f582e 100644 --- a/application/account-management/WebApp/routes/signup/index.tsx +++ b/application/account-management/WebApp/routes/signup/index.tsx @@ -43,14 +43,14 @@ export function StartSignupForm() { const { email: loginEmail } = getLoginState(); // Prefill from login page if user navigated here const [email, setEmail] = useState(savedEmail || loginEmail || ""); - const startSignupMutation = api.useMutation("post", "/api/account-management/signups/start"); + const startSignupMutation = api.useMutation("post", "/api/account-management/authentication/email/signup/start"); if (startSignupMutation.isSuccess) { - const { emailConfirmationId, validForSeconds } = startSignupMutation.data; + const { emailLoginId, validForSeconds } = startSignupMutation.data; clearSignupState(); setSignupState({ - emailConfirmationId, + emailLoginId, email, expireAt: new Date(Date.now() + validForSeconds * 1000) }); diff --git a/application/account-management/WebApp/routes/signup/verify.tsx b/application/account-management/WebApp/routes/signup/verify.tsx index edb906b63..2d5de1d83 100644 --- a/application/account-management/WebApp/routes/signup/verify.tsx +++ b/application/account-management/WebApp/routes/signup/verify.tsx @@ -80,7 +80,7 @@ function useCountdown(expireAt: Date) { export function CompleteSignupForm() { const initialState = getSignupState(); - const { email = "", emailConfirmationId = "" } = initialState; + const { email = "", emailLoginId = "" } = initialState; const initialExpireAt = initialState.expireAt ? new Date(initialState.expireAt) : new Date(); const [expireAt, setExpireAt] = useState(initialExpireAt); const secondsRemaining = useCountdown(expireAt); @@ -119,7 +119,7 @@ export function CompleteSignupForm() { const completeSignupMutation = api.useMutation( "post", - "/api/account-management/signups/{emailConfirmationId}/complete", + "/api/account-management/authentication/email/signup/{id}/complete", { onSuccess: () => { clearSignupState(); @@ -130,7 +130,7 @@ export function CompleteSignupForm() { const resendSignupCodeMutation = api.useMutation( "post", - "/api/account-management/signups/{emailConfirmationId}/resend-code", + "/api/account-management/authentication/email/signup/{id}/resend-code", { onSuccess: (data) => { if (data) { @@ -175,7 +175,7 @@ export function CompleteSignupForm() { completeSignupMutation.mutate({ params: { - path: { emailConfirmationId } + path: { id: emailLoginId } }, body: { oneTimePassword: otpValue, @@ -266,7 +266,7 @@ export function CompleteSignupForm() { ) : ( { - mutationSubmitter(resendSignupCodeMutation, { path: { emailConfirmationId } })(e); + mutationSubmitter(resendSignupCodeMutation, { path: { id: emailLoginId } })(e); }} validationErrors={resendSignupCodeMutation.error?.errors} className="inline" diff --git a/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json index 14e9bc742..7e93fcde2 100644 --- a/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json +++ b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json @@ -6,24 +6,12 @@ "version": "v1" }, "paths": { - "/api/account-management/authentication/login/start": { + "/api/account-management/authentication/logout": { "post": { "tags": [ "Authentication" ], - "operationId": "PostApiAccountManagementAuthenticationLoginStart", - "requestBody": { - "x-name": "command", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StartLoginCommand" - } - } - }, - "required": true, - "x-position": 1 - }, + "operationId": "PostApiAccountManagementAuthenticationLogout", "responses": { "400": { "description": "", @@ -34,48 +22,27 @@ } } } - }, - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StartLoginResponse" - } - } - } } } } }, - "/api/account-management/authentication/login/{id}/complete": { + "/api/account-management/authentication/switch-tenant": { "post": { "tags": [ "Authentication" ], - "operationId": "PostApiAccountManagementAuthenticationLoginComplete", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/LoginId" - }, - "x-position": 1 - } - ], + "operationId": "PostApiAccountManagementAuthenticationSwitchTenant", "requestBody": { "x-name": "command", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CompleteLoginCommand" + "$ref": "#/components/schemas/SwitchTenantCommand" } } }, "required": true, - "x-position": 2 + "x-position": 1 }, "responses": { "400": { @@ -91,23 +58,12 @@ } } }, - "/api/account-management/authentication/login/{emailConfirmationId}/resend-code": { - "post": { + "/api/account-management/authentication/sessions": { + "get": { "tags": [ "Authentication" ], - "operationId": "PostApiAccountManagementAuthenticationLoginResendCode", - "parameters": [ - { - "name": "emailConfirmationId", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/EmailConfirmationId" - }, - "x-position": 1 - } - ], + "operationId": "GetApiAccountManagementAuthenticationSessions", "responses": { "400": { "description": "", @@ -124,7 +80,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResendEmailConfirmationCodeResponse" + "$ref": "#/components/schemas/UserSessionsResponse" } } } @@ -132,12 +88,23 @@ } } }, - "/api/account-management/authentication/logout": { - "post": { + "/api/account-management/authentication/sessions/{id}": { + "delete": { "tags": [ "Authentication" ], - "operationId": "PostApiAccountManagementAuthenticationLogout", + "operationId": "DeleteApiAccountManagementAuthenticationSessions", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/SessionId" + }, + "x-position": 1 + } + ], "responses": { "400": { "description": "", @@ -152,44 +119,55 @@ } } }, - "/api/account-management/authentication/switch-tenant": { + "/internal-api/account-management/authentication/refresh-authentication-tokens": { + "post": { + "operationId": "PostInternalApiAccountManagementAuthenticationRefreshAuthenticationTokens", + "responses": { + "200": { + "description": "" + } + } + } + }, + "/internal-api/account-management/tenants/{id}": { + "delete": { + "operationId": "DeleteInternalApiAccountManagementTenants", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/TenantId" + }, + "x-position": 1 + } + ], + "responses": { + "200": { + "description": "" + } + } + } + }, + "/api/account-management/authentication/email/login/start": { "post": { "tags": [ - "Authentication" + "EmailAuthentication" ], - "operationId": "PostApiAccountManagementAuthenticationSwitchTenant", + "operationId": "PostApiAccountManagementAuthenticationEmailLoginStart", "requestBody": { "x-name": "command", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SwitchTenantCommand" + "$ref": "#/components/schemas/StartEmailLoginCommand" } } }, "required": true, "x-position": 1 }, - "responses": { - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HttpValidationProblemDetails" - } - } - } - } - } - } - }, - "/api/account-management/authentication/sessions": { - "get": { - "tags": [ - "Authentication" - ], - "operationId": "GetApiAccountManagementAuthenticationSessions", "responses": { "400": { "description": "", @@ -206,7 +184,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserSessionsResponse" + "$ref": "#/components/schemas/StartEmailLoginResponse" } } } @@ -214,23 +192,35 @@ } } }, - "/api/account-management/authentication/sessions/{id}": { - "delete": { + "/api/account-management/authentication/email/login/{id}/complete": { + "post": { "tags": [ - "Authentication" + "EmailAuthentication" ], - "operationId": "DeleteApiAccountManagementAuthenticationSessions", + "operationId": "PostApiAccountManagementAuthenticationEmailLoginComplete", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { - "$ref": "#/components/schemas/SessionId" + "$ref": "#/components/schemas/EmailLoginId" }, "x-position": 1 } ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompleteEmailLoginCommand" + } + } + }, + "required": true, + "x-position": 2 + }, "responses": { "400": { "description": "", @@ -245,49 +235,59 @@ } } }, - "/internal-api/account-management/authentication/refresh-authentication-tokens": { + "/api/account-management/authentication/email/login/{id}/resend-code": { "post": { - "operationId": "PostInternalApiAccountManagementAuthenticationRefreshAuthenticationTokens", - "responses": { - "200": { - "description": "" - } - } - } - }, - "/internal-api/account-management/tenants/{id}": { - "delete": { - "operationId": "DeleteInternalApiAccountManagementTenants", + "tags": [ + "EmailAuthentication" + ], + "operationId": "PostApiAccountManagementAuthenticationEmailLoginResendCode", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { - "$ref": "#/components/schemas/TenantId" + "$ref": "#/components/schemas/EmailLoginId" }, "x-position": 1 } ], "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + }, "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResendEmailLoginCodeResponse" + } + } + } } } } }, - "/api/account-management/signups/start": { + "/api/account-management/authentication/email/signup/start": { "post": { "tags": [ - "Signups" + "EmailAuthentication" ], - "operationId": "PostApiAccountManagementSignupsStart", + "operationId": "PostApiAccountManagementAuthenticationEmailSignupStart", "requestBody": { "x-name": "command", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StartSignupCommand" + "$ref": "#/components/schemas/StartEmailSignupCommand" } } }, @@ -310,7 +310,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StartSignupResponse" + "$ref": "#/components/schemas/StartEmailSignupResponse" } } } @@ -318,19 +318,19 @@ } } }, - "/api/account-management/signups/{emailConfirmationId}/complete": { + "/api/account-management/authentication/email/signup/{id}/complete": { "post": { "tags": [ - "Signups" + "EmailAuthentication" ], - "operationId": "PostApiAccountManagementSignupsComplete", + "operationId": "PostApiAccountManagementAuthenticationEmailSignupComplete", "parameters": [ { - "name": "emailConfirmationId", + "name": "id", "in": "path", "required": true, "schema": { - "$ref": "#/components/schemas/EmailConfirmationId" + "$ref": "#/components/schemas/EmailLoginId" }, "x-position": 1 } @@ -340,7 +340,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CompleteSignupCommand" + "$ref": "#/components/schemas/CompleteEmailSignupCommand" } } }, @@ -361,19 +361,19 @@ } } }, - "/api/account-management/signups/{emailConfirmationId}/resend-code": { + "/api/account-management/authentication/email/signup/{id}/resend-code": { "post": { "tags": [ - "Signups" + "EmailAuthentication" ], - "operationId": "PostApiAccountManagementSignupsResendCode", + "operationId": "PostApiAccountManagementAuthenticationEmailSignupResendCode", "parameters": [ { - "name": "emailConfirmationId", + "name": "id", "in": "path", "required": true, "schema": { - "$ref": "#/components/schemas/EmailConfirmationId" + "$ref": "#/components/schemas/EmailLoginId" }, "x-position": 1 } @@ -394,7 +394,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResendEmailConfirmationCodeResponse" + "$ref": "#/components/schemas/ResendEmailLoginCodeResponse" } } } @@ -1289,99 +1289,12 @@ } } }, - "StartLoginResponse": { - "type": "object", - "additionalProperties": false, - "properties": { - "loginId": { - "$ref": "#/components/schemas/LoginId" - }, - "emailConfirmationId": { - "$ref": "#/components/schemas/EmailConfirmationId" - }, - "validForSeconds": { - "type": "integer", - "format": "int32" - } - } - }, - "LoginId": { - "type": "string", - "format": "login_{string}" - }, - "StronglyTypedUlidOfLoginId": { - "allOf": [ - { - "$ref": "#/components/schemas/StronglyTypedIdOfStringAndLoginId" - }, - { - "type": "object", - "x-abstract": true, - "additionalProperties": false - } - ] - }, - "StronglyTypedIdOfStringAndLoginId": { - "type": "object", - "x-abstract": true, - "additionalProperties": false, - "properties": { - "value": { - "type": "string", - "nullable": true - } - } - }, - "EmailConfirmationId": { - "type": "string", - "format": "econf_{string}" - }, - "StronglyTypedUlidOfEmailConfirmationId": { - "allOf": [ - { - "$ref": "#/components/schemas/StronglyTypedIdOfStringAndEmailConfirmationId" - }, - { - "type": "object", - "x-abstract": true, - "additionalProperties": false - } - ] - }, - "StronglyTypedIdOfStringAndEmailConfirmationId": { - "type": "object", - "x-abstract": true, - "additionalProperties": false, - "properties": { - "value": { - "type": "string", - "nullable": true - } - } - }, - "StartLoginCommand": { - "type": "object", - "additionalProperties": false, - "properties": { - "email": { - "type": "string" - } - } - }, - "CompleteLoginCommand": { + "SwitchTenantCommand": { "type": "object", "additionalProperties": false, "properties": { - "oneTimePassword": { - "type": "string" - }, - "preferredTenantId": { - "nullable": true, - "oneOf": [ - { - "$ref": "#/components/schemas/TenantId" - } - ] + "tenantId": { + "$ref": "#/components/schemas/TenantId" } } }, @@ -1411,25 +1324,6 @@ } } }, - "ResendEmailConfirmationCodeResponse": { - "type": "object", - "additionalProperties": false, - "properties": { - "validForSeconds": { - "type": "integer", - "format": "int32" - } - } - }, - "SwitchTenantCommand": { - "type": "object", - "additionalProperties": false, - "properties": { - "tenantId": { - "$ref": "#/components/schemas/TenantId" - } - } - }, "UserSessionsResponse": { "type": "object", "additionalProperties": false, @@ -1453,6 +1347,9 @@ "type": "string", "format": "date-time" }, + "loginMethod": { + "$ref": "#/components/schemas/LoginMethod" + }, "deviceType": { "$ref": "#/components/schemas/DeviceType" }, @@ -1501,6 +1398,16 @@ } } }, + "LoginMethod": { + "type": "string", + "description": "", + "x-enumNames": [ + "OneTimePassword" + ], + "enum": [ + "OneTimePassword" + ] + }, "DeviceType": { "type": "string", "description": "", @@ -1517,12 +1424,88 @@ "Tablet" ] }, - "StartSignupResponse": { + "StartEmailLoginResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "emailLoginId": { + "$ref": "#/components/schemas/EmailLoginId" + }, + "validForSeconds": { + "type": "integer", + "format": "int32" + } + } + }, + "EmailLoginId": { + "type": "string", + "format": "emlog_{string}" + }, + "StronglyTypedUlidOfEmailLoginId": { + "allOf": [ + { + "$ref": "#/components/schemas/StronglyTypedIdOfStringAndEmailLoginId" + }, + { + "type": "object", + "x-abstract": true, + "additionalProperties": false + } + ] + }, + "StronglyTypedIdOfStringAndEmailLoginId": { + "type": "object", + "x-abstract": true, + "additionalProperties": false, + "properties": { + "value": { + "type": "string", + "nullable": true + } + } + }, + "StartEmailLoginCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "email": { + "type": "string" + } + } + }, + "CompleteEmailLoginCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "oneTimePassword": { + "type": "string" + }, + "preferredTenantId": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/TenantId" + } + ] + } + } + }, + "ResendEmailLoginCodeResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "validForSeconds": { + "type": "integer", + "format": "int32" + } + } + }, + "StartEmailSignupResponse": { "type": "object", "additionalProperties": false, "properties": { - "emailConfirmationId": { - "$ref": "#/components/schemas/EmailConfirmationId" + "emailLoginId": { + "$ref": "#/components/schemas/EmailLoginId" }, "validForSeconds": { "type": "integer", @@ -1530,7 +1513,7 @@ } } }, - "StartSignupCommand": { + "StartEmailSignupCommand": { "type": "object", "additionalProperties": false, "properties": { @@ -1539,7 +1522,7 @@ } } }, - "CompleteSignupCommand": { + "CompleteEmailSignupCommand": { "type": "object", "additionalProperties": false, "properties": { From 6f544435f83bf4ae23b2670b9a525cbd03ad5d04 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 10 Feb 2026 02:55:28 +0100 Subject: [PATCH 03/13] Fix frontend accessibility and React patterns in auth pages --- .../WebApp/routes/error.tsx | 8 +- .../WebApp/routes/login/index.tsx | 5 +- .../WebApp/routes/login/verify.tsx | 105 ++++++++++-------- .../WebApp/routes/signup/index.tsx | 5 +- .../WebApp/routes/signup/verify.tsx | 62 ++++++----- .../WebApp/shared/hooks/useSwitchTenant.tsx | 4 +- .../shared/translations/locale/da-DK.po | 4 +- .../shared/translations/locale/en-US.po | 4 +- .../auth/AuthenticationMiddleware.ts | 7 +- .../shared-webapp/infrastructure/auth/util.ts | 38 +++++++ 10 files changed, 151 insertions(+), 91 deletions(-) diff --git a/application/account-management/WebApp/routes/error.tsx b/application/account-management/WebApp/routes/error.tsx index 84a05fab6..dd8d9c0f1 100644 --- a/application/account-management/WebApp/routes/error.tsx +++ b/application/account-management/WebApp/routes/error.tsx @@ -1,6 +1,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { ErrorCode } from "@repo/infrastructure/auth/AuthenticationMiddleware"; +import { isValidReturnPath } from "@repo/infrastructure/auth/util"; import { Button } from "@repo/ui/components/Button"; import { Link } from "@repo/ui/components/Link"; import { createFileRoute, Navigate, useNavigate } from "@tanstack/react-router"; @@ -17,7 +18,7 @@ export const Route = createFileRoute("/error")({ const params = search as { error?: string; returnPath?: string }; return { error: params.error, - returnPath: params.returnPath?.startsWith("/") ? params.returnPath : undefined + returnPath: params.returnPath && isValidReturnPath(params.returnPath) ? params.returnPath : undefined }; }, component: ErrorPage @@ -61,6 +62,7 @@ function getErrorDisplay(error: string): { }; case ErrorCode.SessionNotFound: + case ErrorCode.SessionExpired: return { icon: , iconBackground: "bg-muted", @@ -120,10 +122,10 @@ function ErrorPage() { }; return ( -
+
-
+
{errorDisplay.icon} diff --git a/application/account-management/WebApp/routes/login/index.tsx b/application/account-management/WebApp/routes/login/index.tsx index 3110f1f44..6f95a4510 100644 --- a/application/account-management/WebApp/routes/login/index.tsx +++ b/application/account-management/WebApp/routes/login/index.tsx @@ -2,6 +2,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { loggedInPath, signUpPath } from "@repo/infrastructure/auth/constants"; import { useIsAuthenticated } from "@repo/infrastructure/auth/hooks"; +import { isValidReturnPath } from "@repo/infrastructure/auth/util"; import { Button } from "@repo/ui/components/Button"; import { Form } from "@repo/ui/components/Form"; import { Link } from "@repo/ui/components/Link"; @@ -20,9 +21,8 @@ import { clearLoginState, getLoginState, setLoginState } from "./-shared/loginSt export const Route = createFileRoute("/login/")({ validateSearch: (search) => { const returnPath = search.returnPath as string | undefined; - // Only allow paths starting with / to prevent open redirect attacks to external domains return { - returnPath: returnPath?.startsWith("/") ? returnPath : undefined + returnPath: returnPath && isValidReturnPath(returnPath) ? returnPath : undefined }; }, component: function LoginRoute() { @@ -89,6 +89,7 @@ export function LoginForm() { autoComplete="email webauthn" placeholder={t`yourname@example.com`} className="flex w-full flex-col" + isDisabled={startLoginMutation.isPending} />
{ - document.querySelector("form")?.requestSubmit(); - }, 10); + submitVerification(upperValue); } }} disabled={isExpired || resendLoginCodeMutation.isPending} @@ -261,15 +272,17 @@ export function CompleteLoginForm() { - {!isExpired ? ( -

- Your verification code is valid for {expiresInString} -

- ) : ( -

- Your verification code has expired -

- )} +
+ {!isExpired ? ( +

+ Your verification code is valid for {expiresInString} +

+ ) : ( +

+ Your verification code has expired +

+ )} +
+ )}
); } +type ActionButtonProps = { + action: ErrorAction; + variant: "default" | "outline"; + onLogIn: () => void; + onSignUp: () => void; +}; + +function ActionButton({ action, variant, onLogIn, onSignUp }: ActionButtonProps) { + switch (action) { + case "signup": + return ( + + ); + case "contact": + return ( + + ); + default: + return ( + + ); + } +} + function ErrorPage() { - const { error, returnPath } = Route.useSearch(); + const { error, returnPath, id } = Route.useSearch(); const navigate = useNavigate(); if (!error) { @@ -121,6 +269,10 @@ function ErrorPage() { navigate({ to: "/login", search: { returnPath } }); }; + const handleSignUp = () => { + navigate({ to: signUpPath }); + }; + return (
@@ -136,12 +288,28 @@ function ErrorPage() {

{errorDisplay.message}

-
- +
+ + {errorDisplay.secondaryAction && ( + + )}
+ + {id && ( +

+ Reference ID: {id} +

+ )}
diff --git a/application/account-management/WebApp/routes/login/index.tsx b/application/account-management/WebApp/routes/login/index.tsx index 6f95a4510..362be6b2c 100644 --- a/application/account-management/WebApp/routes/login/index.tsx +++ b/application/account-management/WebApp/routes/login/index.tsx @@ -11,6 +11,7 @@ import { mutationSubmitter } from "@repo/ui/forms/mutationSubmitter"; import { createFileRoute, Navigate } from "@tanstack/react-router"; import { useState } from "react"; import FederatedErrorPage from "@/federated-modules/errorPages/FederatedErrorPage"; +import googleIconUrl from "@/shared/images/google-icon.svg"; import logoMarkUrl from "@/shared/images/logo-mark.svg"; import logoWrapUrl from "@/shared/images/logo-wrap.svg"; import { HorizontalHeroLayout } from "@/shared/layouts/HorizontalHeroLayout"; @@ -48,6 +49,25 @@ export function LoginForm() { const { returnPath } = Route.useSearch(); const startLoginMutation = api.useMutation("post", "/api/account-management/authentication/email/login/start"); + const [isGoogleLoginPending, setIsGoogleLoginPending] = useState(false); + + const handleGoogleLogin = () => { + setIsGoogleLoginPending(true); + const params = new URLSearchParams(); + if (returnPath) { + params.set("ReturnPath", returnPath); + } + try { + const preferredTenantId = localStorage.getItem("preferred-tenant"); + if (preferredTenantId) { + params.set("PreferredTenantId", preferredTenantId); + } + } catch { + // Ignore localStorage errors + } + const queryString = params.toString(); + window.location.href = `/api/account-management/authentication/Google/login/start${queryString ? `?${queryString}` : ""}`; + }; if (startLoginMutation.isSuccess) { const { emailLoginId, validForSeconds } = startLoginMutation.data; @@ -62,12 +82,14 @@ export function LoginForm() { return ; } + const isPending = startLoginMutation.isPending || isGoogleLoginPending; + return ( {t`Logo`} @@ -89,10 +111,28 @@ export function LoginForm() { autoComplete="email webauthn" placeholder={t`yourname@example.com`} className="flex w-full flex-col" - isDisabled={startLoginMutation.isPending} + isDisabled={isPending} /> - +
+
+ + or + +
+
+

diff --git a/application/account-management/WebApp/routes/login/verify.tsx b/application/account-management/WebApp/routes/login/verify.tsx index 8a13c26b1..69828c4be 100644 --- a/application/account-management/WebApp/routes/login/verify.tsx +++ b/application/account-management/WebApp/routes/login/verify.tsx @@ -215,7 +215,7 @@ export function CompleteLoginForm() { } return ( -

+
{ event.preventDefault(); diff --git a/application/account-management/WebApp/routes/signup/index.tsx b/application/account-management/WebApp/routes/signup/index.tsx index 4a848e658..3893eb476 100644 --- a/application/account-management/WebApp/routes/signup/index.tsx +++ b/application/account-management/WebApp/routes/signup/index.tsx @@ -2,6 +2,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { loggedInPath, loginPath } from "@repo/infrastructure/auth/constants"; import { useIsAuthenticated } from "@repo/infrastructure/auth/hooks"; +import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; import { Button } from "@repo/ui/components/Button"; import { Field, FieldDescription, FieldLabel } from "@repo/ui/components/Field"; import { Form } from "@repo/ui/components/Form"; @@ -14,6 +15,7 @@ import { createFileRoute, Navigate } from "@tanstack/react-router"; import { DotIcon } from "lucide-react"; import { useState } from "react"; import FederatedErrorPage from "@/federated-modules/errorPages/FederatedErrorPage"; +import googleIconUrl from "@/shared/images/google-icon.svg"; import logoMarkUrl from "@/shared/images/logo-mark.svg"; import logoWrapUrl from "@/shared/images/logo-wrap.svg"; import { HorizontalHeroLayout } from "@/shared/layouts/HorizontalHeroLayout"; @@ -44,6 +46,18 @@ export function StartSignupForm() { const [email, setEmail] = useState(savedEmail || loginEmail || ""); const startSignupMutation = api.useMutation("post", "/api/account-management/authentication/email/signup/start"); + const [isGoogleSignupPending, setIsGoogleSignupPending] = useState(false); + + const handleGoogleSignup = () => { + setIsGoogleSignupPending(true); + const locale = localStorage.getItem(preferredLocaleKey); + const params = new URLSearchParams(); + if (locale) { + params.set("Locale", locale); + } + const queryString = params.toString(); + window.location.href = `/api/account-management/authentication/Google/signup/start${queryString ? `?${queryString}` : ""}`; + }; if (startSignupMutation.isSuccess) { const { emailLoginId, validForSeconds } = startSignupMutation.data; @@ -58,12 +72,14 @@ export function StartSignupForm() { return ; } + const isPending = startSignupMutation.isPending || isGoogleSignupPending; + return ( {t`Logo`} @@ -72,21 +88,8 @@ export function StartSignupForm() { Create your account
- Sign up in seconds to start building on PlatformPlatform - just like thousands of others. + Sign up in seconds to start building on PlatformPlatform – just like thousands of others.
- - {() => Europe} @@ -107,13 +110,45 @@ export function StartSignupForm() { {t`This is the region where your data is stored`} - +
+
+ + or + +
+
+

Do you already have an account?{" "} diff --git a/application/account-management/WebApp/routes/signup/verify.tsx b/application/account-management/WebApp/routes/signup/verify.tsx index f9a80a7a6..7f8886ee5 100644 --- a/application/account-management/WebApp/routes/signup/verify.tsx +++ b/application/account-management/WebApp/routes/signup/verify.tsx @@ -180,7 +180,7 @@ export function CompleteSignupForm() { ); return ( -

+
{ event.preventDefault(); diff --git a/application/account-management/WebApp/shared/images/google-icon.svg b/application/account-management/WebApp/shared/images/google-icon.svg new file mode 100644 index 000000000..5afab8ab1 --- /dev/null +++ b/application/account-management/WebApp/shared/images/google-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json index 7e93fcde2..fa841450b 100644 --- a/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json +++ b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json @@ -402,6 +402,217 @@ } } }, + "/api/account-management/authentication/{provider}/login/start": { + "get": { + "tags": [ + "ExternalAuthentication" + ], + "operationId": "GetApiAccountManagementAuthenticationLoginStart", + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/ExternalProviderType" + }, + "x-position": 1 + }, + { + "name": "PreferredTenantId", + "in": "query", + "schema": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/TenantId" + } + ] + }, + "x-position": 2 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + } + } + } + }, + "/api/account-management/authentication/{provider}/login/callback": { + "get": { + "tags": [ + "ExternalAuthentication" + ], + "operationId": "GetApiAccountManagementAuthenticationLoginCallback", + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/ExternalProviderType" + }, + "x-position": 1 + }, + { + "name": "code", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 2 + }, + { + "name": "state", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 3 + }, + { + "name": "error", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 4 + }, + { + "name": "error_description", + "x-originalName": "errorDescription", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 5 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + } + } + } + }, + "/api/account-management/authentication/{provider}/signup/start": { + "get": { + "tags": [ + "ExternalAuthentication" + ], + "operationId": "GetApiAccountManagementAuthenticationSignupStart", + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/ExternalProviderType" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + } + } + } + }, + "/api/account-management/authentication/{provider}/signup/callback": { + "get": { + "tags": [ + "ExternalAuthentication" + ], + "operationId": "GetApiAccountManagementAuthenticationSignupCallback", + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/ExternalProviderType" + }, + "x-position": 1 + }, + { + "name": "code", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 2 + }, + { + "name": "state", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 3 + }, + { + "name": "error", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 4 + }, + { + "name": "error_description", + "x-originalName": "errorDescription", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 5 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + } + } + } + }, "/api/account-management/tenants/current": { "get": { "tags": [ @@ -1402,10 +1613,12 @@ "type": "string", "description": "", "x-enumNames": [ - "OneTimePassword" + "OneTimePassword", + "Google" ], "enum": [ - "OneTimePassword" + "OneTimePassword", + "Google" ] }, "DeviceType": { @@ -1534,6 +1747,16 @@ } } }, + "ExternalProviderType": { + "type": "string", + "description": "", + "x-enumNames": [ + "Google" + ], + "enum": [ + "Google" + ] + }, "TenantResponse": { "type": "object", "additionalProperties": false, @@ -1979,6 +2202,32 @@ } } }, + "ExternalLoginType": { + "type": "string", + "enum": [ + "Login", + "Signup" + ] + }, + "ExternalLoginResult": { + "type": "string", + "enum": [ + "Success", + "IdentityProviderError", + "InvalidState", + "LoginReplayDetected", + "SessionNotFound", + "FlowIdMismatch", + "SessionHijackingDetected", + "LoginExpired", + "LoginAlreadyCompleted", + "CodeExchangeFailed", + "NonceMismatch", + "IdentityMismatch", + "UserNotFound", + "AccountAlreadyExists" + ] + }, "SessionRevokedReason": { "type": "string", "enum": [ diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index 2668a36e7..19cc64b17 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -69,6 +69,9 @@ msgstr "Adgang nægtet" msgid "Account" msgstr "Konto" +msgid "Account already exists" +msgstr "Konto findes allerede" + msgid "Account information" msgstr "Kontoinformation" @@ -78,6 +81,9 @@ msgstr "Kontonavn" msgid "Account name updated successfully" msgstr "Kontonavn opdateret succesfuldt" +msgid "Account not found" +msgstr "Konto ikke fundet" + msgid "Account settings" msgstr "Kontoindstillinger" @@ -114,6 +120,9 @@ msgstr "Admin" msgid "All users" msgstr "Alle brugere" +msgid "An account with this email already exists. Please log in instead." +msgstr "Der findes allerede en konto med denne e-mailadresse. Log ind i stedet." + msgid "An email with login instructions will be sent to the user." msgstr "En e-mail med login-instruktioner vil blive sendt til brugeren." @@ -152,12 +161,18 @@ msgstr "Er du sikker på, at du vil tilbagekalde denne session? Enheden vil bliv msgid "Audit reports and assessments" msgstr "Revisions rapporter og vurderinger" +msgid "Authentication failed" +msgstr "Godkendelse mislykkedes" + msgid "Authentication is shared across all tabs." msgstr "Autentificering deles på tværs af alle faner." msgid "Authentication is shared across all tabs. To use multiple accounts simultaneously, please use different browsers." msgstr "Autentificering deles på tværs af alle faner. For at bruge flere konti samtidigt, brug venligst forskellige browsere." +msgid "Authentication was cancelled or denied. Please try again if you want to continue." +msgstr "Godkendelse blev annulleret eller afvist. Prøv venligst igen, hvis du vil fortsætte." + msgid "Azure Compliance" msgstr "Azure Overholdelse" @@ -237,9 +252,6 @@ msgstr "Kontakt support" msgid "Contact your administrator if you believe this is a mistake." msgstr "Kontakt din administrator, hvis du mener, dette er en fejl." -msgid "Continue" -msgstr "Fortsæt" - msgid "Create your account" msgstr "Opret din konto" @@ -421,6 +433,9 @@ msgstr "Gå til app" msgid "Go to home" msgstr "Gå til forsiden" +msgid "Google" +msgstr "Google" + msgid "Help your team recognize your invites" msgstr "Hjælp dit team med at genkende dine invitationer" @@ -439,9 +454,15 @@ msgstr "Hjem" msgid "How we collect, use, and protect your personal data in compliance with GDPR." msgstr "Hvordan vi indsamler, bruger og beskytter dine personlige data i overensstemmelse med GDPR." +msgid "Identity mismatch" +msgstr "Identitetsuoverensstemmelse" + msgid "Image must be smaller than 1 MB." msgstr "Billedet skal være mindre end 1 MB." +msgid "Invalid request" +msgstr "Ugyldig anmodning" + msgid "Invitation pending" msgstr "Invitation afventer" @@ -484,12 +505,21 @@ msgstr "LinkedIn" msgid "Log in" msgstr "Log ind" +msgid "Log in with email" +msgstr "Log ind med e-mail" + +msgid "Log in with Google" +msgstr "Log ind med Google" + msgid "Log out" msgstr "Log ud" msgid "Logged out" msgstr "Logget ud" +msgid "Login method:" +msgstr "Login-metode:" + msgid "Login verification code" msgstr "Login-bekræftelseskode" @@ -553,6 +583,9 @@ msgstr "Har du brug for hjælp? Vores supportteam er klart til at hjælpe." msgid "Next" msgstr "Næste" +msgid "No account found for this email address. Please sign up to create an account." +msgstr "Ingen konto fundet for denne e-mailadresse. Tilmeld dig venligst for at oprette en konto." + msgid "No active sessions" msgstr "Ingen aktive sessioner" @@ -562,6 +595,9 @@ msgstr "Ingen brugere fundet" msgid "OK" msgstr "OK" +msgid "One-time password" +msgstr "Engangskode" + msgid "Only account owners can modify the account name" msgstr "Kun kontoejere kan ændre kontonavnet" @@ -574,6 +610,9 @@ msgstr "Kun betroet personale får adgang til data gennem Azure AD autentificeri msgid "Open navigation menu" msgstr "Åbn navigationsmenu" +msgid "or" +msgstr "eller" + msgid "Organization" msgstr "Organisation" @@ -662,6 +701,12 @@ msgstr "Papirkurv" msgid "Recycle bin is empty" msgstr "Papirkurven er tom" +msgid "Redirecting..." +msgstr "Omdirigerer..." + +msgid "Reference ID: {id}" +msgstr "Reference-ID: {id}" + msgid "Region" msgstr "Region" @@ -770,8 +815,14 @@ msgstr "Vis filtre" msgid "Sign up" msgstr "Tilmeld dig" -msgid "Sign up in seconds to start building on PlatformPlatform - just like thousands of others." -msgstr "Tilmeld dig på sekunder for at bygge på PlatformPlatform - ligesom tusinder andre." +msgid "Sign up in seconds to start building on PlatformPlatform – just like thousands of others." +msgstr "Tilmeld dig på sekunder og begynd at bygge på PlatformPlatform – ligesom tusindvis af andre." + +msgid "Sign up with email" +msgstr "Tilmeld dig med e-mail" + +msgid "Sign up with Google" +msgstr "Tilmeld dig med Google" msgid "Signup verification code" msgstr "Tilmeldingsbekræftelseskode" @@ -815,6 +866,9 @@ msgstr "Brugsvilkår" msgid "The agreement governing your use of our Service, including acceptable use and liability." msgstr "Aftalen der styrer din brug af vores service, inklusive acceptabel brug og ansvar." +msgid "The authentication request was invalid. Please try again." +msgstr "Godkendelsesanmodningen var ugyldig. Prøv venligst igen." + msgid "The name of your account, shown to users and in email notifications" msgstr "Navnet på din konto, vist til brugere og i e-mail-notifikationer" @@ -824,9 +878,15 @@ msgstr "Siden du leder efter findes ikke eller er blevet flyttet." msgid "Theme" msgstr "Tema" +msgid "This account is linked to a different Google identity." +msgstr "Denne konto er knyttet til en anden Google-identitet." + msgid "This action cannot be undone." msgstr "Denne handling kan ikke fortrydes." +msgid "This can happen when email ownership has changed. Contact your account administrator." +msgstr "Dette kan ske, når e-mailejerskabet er ændret. Kontakt din kontoadministrator." + msgid "This is the region where your data is stored" msgstr "Dette er den region, hvor dine data er lagret" @@ -965,6 +1025,9 @@ msgstr "Se profil" msgid "View users" msgstr "Se brugere" +msgid "We detected a security issue with your login attempt. Please try again." +msgstr "Vi opdagede et sikkerhedsproblem med dit loginforsøg. Prøv venligst igen." + msgid "We detected suspicious activity on your account. Someone may have attempted to take over your session." msgstr "Vi opdagede mistænkelig aktivitet på din konto. Nogen kan have forsøgt at overtage din session." diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index 063abb721..010abbe8c 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -69,6 +69,9 @@ msgstr "Access denied" msgid "Account" msgstr "Account" +msgid "Account already exists" +msgstr "Account already exists" + msgid "Account information" msgstr "Account information" @@ -78,6 +81,9 @@ msgstr "Account name" msgid "Account name updated successfully" msgstr "Account name updated successfully" +msgid "Account not found" +msgstr "Account not found" + msgid "Account settings" msgstr "Account settings" @@ -114,6 +120,9 @@ msgstr "Admin" msgid "All users" msgstr "All users" +msgid "An account with this email already exists. Please log in instead." +msgstr "An account with this email already exists. Please log in instead." + msgid "An email with login instructions will be sent to the user." msgstr "An email with login instructions will be sent to the user." @@ -152,12 +161,18 @@ msgstr "Are you sure you want to revoke this session? The device will be signed msgid "Audit reports and assessments" msgstr "Audit reports and assessments" +msgid "Authentication failed" +msgstr "Authentication failed" + msgid "Authentication is shared across all tabs." msgstr "Authentication is shared across all tabs." msgid "Authentication is shared across all tabs. To use multiple accounts simultaneously, please use different browsers." msgstr "Authentication is shared across all tabs. To use multiple accounts simultaneously, please use different browsers." +msgid "Authentication was cancelled or denied. Please try again if you want to continue." +msgstr "Authentication was cancelled or denied. Please try again if you want to continue." + msgid "Azure Compliance" msgstr "Azure Compliance" @@ -237,9 +252,6 @@ msgstr "Contact support" msgid "Contact your administrator if you believe this is a mistake." msgstr "Contact your administrator if you believe this is a mistake." -msgid "Continue" -msgstr "Continue" - msgid "Create your account" msgstr "Create your account" @@ -421,6 +433,9 @@ msgstr "Go to app" msgid "Go to home" msgstr "Go to home" +msgid "Google" +msgstr "Google" + msgid "Help your team recognize your invites" msgstr "Help your team recognize your invites" @@ -439,9 +454,15 @@ msgstr "Home" msgid "How we collect, use, and protect your personal data in compliance with GDPR." msgstr "How we collect, use, and protect your personal data in compliance with GDPR." +msgid "Identity mismatch" +msgstr "Identity mismatch" + msgid "Image must be smaller than 1 MB." msgstr "Image must be smaller than 1 MB." +msgid "Invalid request" +msgstr "Invalid request" + msgid "Invitation pending" msgstr "Invitation pending" @@ -484,12 +505,21 @@ msgstr "LinkedIn" msgid "Log in" msgstr "Log in" +msgid "Log in with email" +msgstr "Log in with email" + +msgid "Log in with Google" +msgstr "Log in with Google" + msgid "Log out" msgstr "Log out" msgid "Logged out" msgstr "Logged out" +msgid "Login method:" +msgstr "Login method:" + msgid "Login verification code" msgstr "Login verification code" @@ -553,6 +583,9 @@ msgstr "Need help? Our support team is here to assist you." msgid "Next" msgstr "Next" +msgid "No account found for this email address. Please sign up to create an account." +msgstr "No account found for this email address. Please sign up to create an account." + msgid "No active sessions" msgstr "No active sessions" @@ -562,6 +595,9 @@ msgstr "No users found" msgid "OK" msgstr "OK" +msgid "One-time password" +msgstr "One-time password" + msgid "Only account owners can modify the account name" msgstr "Only account owners can modify the account name" @@ -574,6 +610,9 @@ msgstr "Only trusted personnel access data through Azure AD authentication" msgid "Open navigation menu" msgstr "Open navigation menu" +msgid "or" +msgstr "or" + msgid "Organization" msgstr "Organization" @@ -662,6 +701,12 @@ msgstr "Recycle bin" msgid "Recycle bin is empty" msgstr "Recycle bin is empty" +msgid "Redirecting..." +msgstr "Redirecting..." + +msgid "Reference ID: {id}" +msgstr "Reference ID: {id}" + msgid "Region" msgstr "Region" @@ -770,8 +815,14 @@ msgstr "Show filters" msgid "Sign up" msgstr "Sign up" -msgid "Sign up in seconds to start building on PlatformPlatform - just like thousands of others." -msgstr "Sign up in seconds to start building on PlatformPlatform - just like thousands of others." +msgid "Sign up in seconds to start building on PlatformPlatform – just like thousands of others." +msgstr "Sign up in seconds to start building on PlatformPlatform – just like thousands of others." + +msgid "Sign up with email" +msgstr "Sign up with email" + +msgid "Sign up with Google" +msgstr "Sign up with Google" msgid "Signup verification code" msgstr "Signup verification code" @@ -815,6 +866,9 @@ msgstr "Terms of use" msgid "The agreement governing your use of our Service, including acceptable use and liability." msgstr "The agreement governing your use of our Service, including acceptable use and liability." +msgid "The authentication request was invalid. Please try again." +msgstr "The authentication request was invalid. Please try again." + msgid "The name of your account, shown to users and in email notifications" msgstr "The name of your account, shown to users and in email notifications" @@ -824,9 +878,15 @@ msgstr "The page you are looking for does not exist or was moved." msgid "Theme" msgstr "Theme" +msgid "This account is linked to a different Google identity." +msgstr "This account is linked to a different Google identity." + msgid "This action cannot be undone." msgstr "This action cannot be undone." +msgid "This can happen when email ownership has changed. Contact your account administrator." +msgstr "This can happen when email ownership has changed. Contact your account administrator." + msgid "This is the region where your data is stored" msgstr "This is the region where your data is stored" @@ -965,6 +1025,9 @@ msgstr "View profile" msgid "View users" msgstr "View users" +msgid "We detected a security issue with your login attempt. Please try again." +msgstr "We detected a security issue with your login attempt. Please try again." + msgid "We detected suspicious activity on your account. Someone may have attempted to take over your session." msgstr "We detected suspicious activity on your account. Someone may have attempted to take over your session." diff --git a/application/account-management/WebApp/tests/e2e/global-ui-flows.spec.ts b/application/account-management/WebApp/tests/e2e/global-ui-flows.spec.ts index 3379f3d95..76ec60ee5 100644 --- a/application/account-management/WebApp/tests/e2e/global-ui-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/global-ui-flows.spec.ts @@ -209,7 +209,7 @@ test.describe("@comprehensive", () => { await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); await page.getByRole("textbox", { name: "Email" }).fill(existingUser.email); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Log in with email" }).click(); await expect(page).toHaveURL("/login/verify"); await typeOneTimeCode(page, getVerificationCode()); @@ -256,7 +256,7 @@ test.describe("@comprehensive", () => { await step("Log back in & verify theme remains dark after authentication")(async () => { await page.getByRole("textbox", { name: "Email" }).fill(existingUser.email); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Log in with email" }).click(); await expect(page).toHaveURL("/login/verify?returnPath=%2Fadmin"); await typeOneTimeCode(page, getVerificationCode()); diff --git a/application/account-management/WebApp/tests/e2e/localization-flows.spec.ts b/application/account-management/WebApp/tests/e2e/localization-flows.spec.ts index 0c47d9714..b23f0cb56 100644 --- a/application/account-management/WebApp/tests/e2e/localization-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/localization-flows.spec.ts @@ -36,7 +36,7 @@ test.describe("@comprehensive", () => { await step("Complete signup with Danish interface & verify language persists through flow")(async () => { await page.getByRole("textbox", { name: "E-mail" }).fill(user.email); - await page.getByRole("button", { name: "Opret din konto" }).click(); + await page.getByRole("button", { name: "Tilmeld dig med e-mail" }).click(); await expect(page).toHaveURL("/signup/verify"); await expect(page.getByRole("heading", { name: "Indtast din bekræftelseskode" })).toBeVisible(); @@ -101,7 +101,7 @@ test.describe("@comprehensive", () => { await step("Login with English interface & verify language resets to saved preference")(async () => { await page.getByRole("textbox", { name: "Email" }).fill(user.email); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Log in with email" }).click(); await expect(page).toHaveURL("/login/verify?returnPath=%2Fadmin"); await expect(page.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); @@ -165,7 +165,7 @@ test.describe("@comprehensive", () => { // Complete signup flow await page1.getByRole("textbox", { name: "E-mail" }).fill(user1.email); - await page1.getByRole("button", { name: "Opret din konto" }).click(); + await page1.getByRole("button", { name: "Tilmeld dig med e-mail" }).click(); await expect(page1).toHaveURL("/signup/verify"); await typeOneTimeCode(page1, getVerificationCode()); @@ -197,7 +197,7 @@ test.describe("@comprehensive", () => { // Login with English interface await newPage1.goto("/login"); await newPage1.getByRole("textbox", { name: "Email" }).fill(user1.email); - await newPage1.getByRole("button", { name: "Continue", exact: true }).click(); + await newPage1.getByRole("button", { name: "Log in with email" }).click(); await expect(newPage1).toHaveURL("/login/verify"); await typeOneTimeCode(newPage1, getVerificationCode()); @@ -215,7 +215,7 @@ test.describe("@comprehensive", () => { // Login with English interface await newPage2.goto("/login"); await newPage2.getByRole("textbox", { name: "Email" }).fill(user2.email); - await newPage2.getByRole("button", { name: "Continue", exact: true }).click(); + await newPage2.getByRole("button", { name: "Log in with email" }).click(); await expect(newPage2).toHaveURL("/login/verify"); await typeOneTimeCode(newPage2, getVerificationCode()); diff --git a/application/account-management/WebApp/tests/e2e/login-flows.spec.ts b/application/account-management/WebApp/tests/e2e/login-flows.spec.ts index a8eff20bc..54c0af517 100644 --- a/application/account-management/WebApp/tests/e2e/login-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/login-flows.spec.ts @@ -29,7 +29,7 @@ test.describe("@smoke", () => { await step("Enter valid email & verify navigation to verification page")(async () => { await page.getByRole("textbox", { name: "Email" }).fill(existingUser.email); await blurActiveElement(page); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Log in with email" }).click(); // Verify verification page state await expect(page).toHaveURL("/login/verify"); @@ -98,7 +98,7 @@ test.describe("@smoke", () => { await step("Complete login after security check & verify authentication works")(async () => { await page.getByRole("textbox", { name: "Email" }).fill(existingUser.email); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Log in with email" }).click(); await expect(page).toHaveURL("/login/verify"); await typeOneTimeCode(page, getVerificationCode()); @@ -120,7 +120,7 @@ test.describe("@comprehensive", () => { await step("Navigate to login and submit email & verify navigation to verification page")(async () => { await page.goto("/login"); await page.getByRole("textbox", { name: "Email" }).fill(user.email); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Log in with email" }).click(); // Verify initial verification page state await expect(page).toHaveURL("/login/verify"); @@ -180,7 +180,7 @@ test.describe("@comprehensive", () => { await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); await page.getByRole("textbox", { name: "Email" }).fill(user.email); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Log in with email" }).click(); await expect(page).toHaveURL("/login/verify"); } @@ -191,7 +191,7 @@ test.describe("@comprehensive", () => { await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); await page.getByRole("textbox", { name: "Email" }).fill(user.email); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Log in with email" }).click(); // Verify rate limiting prevents navigation await expect(page).toHaveURL("/login"); @@ -219,7 +219,7 @@ test.describe("@slow", () => { await completeSignupFlow(page, expect, user, context, false); await page.goto("/login"); await page.getByRole("textbox", { name: "Email" }).fill(user.email); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Log in with email" }).click(); // Verify initial state await expect(page).toHaveURL("/login/verify"); diff --git a/application/account-management/WebApp/tests/e2e/permission-based-ui-flows.spec.ts b/application/account-management/WebApp/tests/e2e/permission-based-ui-flows.spec.ts index 97392e9d6..eb50a978a 100644 --- a/application/account-management/WebApp/tests/e2e/permission-based-ui-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/permission-based-ui-flows.spec.ts @@ -144,7 +144,7 @@ test.describe("@smoke", () => { // Login as member await page.getByRole("textbox", { name: "Email" }).fill(member.email); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Log in with email" }).click(); await expect(page.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); await typeOneTimeCode(page, getVerificationCode()); @@ -363,7 +363,7 @@ test.describe("@smoke", () => { // Login as member await page.getByRole("textbox", { name: "Email" }).fill(member.email); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Log in with email" }).click(); await expect(page.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); await typeOneTimeCode(page, getVerificationCode()); diff --git a/application/account-management/WebApp/tests/e2e/session-management-flows.spec.ts b/application/account-management/WebApp/tests/e2e/session-management-flows.spec.ts index f24e6e379..a6dfc7a13 100644 --- a/application/account-management/WebApp/tests/e2e/session-management-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/session-management-flows.spec.ts @@ -101,7 +101,7 @@ test.describe("@smoke", () => { await expect(secondPage.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); await secondPage.getByRole("textbox", { name: "Email" }).fill(owner.email); - await secondPage.getByRole("button", { name: "Continue", exact: true }).click(); + await secondPage.getByRole("button", { name: "Log in with email" }).click(); await expect(secondPage).toHaveURL("/login/verify"); await typeOneTimeCode(secondPage, getVerificationCode()); @@ -209,7 +209,7 @@ test.describe("@comprehensive", () => { await expect(secondPage.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); await secondPage.getByRole("textbox", { name: "Email" }).fill(owner.email); - await secondPage.getByRole("button", { name: "Continue", exact: true }).click(); + await secondPage.getByRole("button", { name: "Log in with email" }).click(); await expect(secondPage).toHaveURL("/login/verify"); await typeOneTimeCode(secondPage, getVerificationCode()); diff --git a/application/account-management/WebApp/tests/e2e/signup-flows.spec.ts b/application/account-management/WebApp/tests/e2e/signup-flows.spec.ts index 14be157dc..2605129fb 100644 --- a/application/account-management/WebApp/tests/e2e/signup-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/signup-flows.spec.ts @@ -28,7 +28,7 @@ test.describe("@smoke", () => { // === EMAIL VALIDATION EDGE CASES === await step("Submit form with empty email & verify validation error")(async () => { - await page.getByRole("button", { name: "Create your account" }).click(); + await page.getByRole("button", { name: "Sign up with email" }).click(); await expect(page).toHaveURL("/signup"); await expect(page.getByText("Email must be in a valid format and no longer than 100 characters.")).toBeVisible(); @@ -37,7 +37,7 @@ test.describe("@smoke", () => { await step("Enter invalid email format & verify validation error")(async () => { await page.getByRole("textbox", { name: "Email" }).fill("invalid-email"); await blurActiveElement(page); - await page.getByRole("button", { name: "Create your account" }).click(); + await page.getByRole("button", { name: "Sign up with email" }).click(); await expect(page).toHaveURL("/signup"); await expect(page.getByText("Email must be in a valid format and no longer than 100 characters.")).toBeVisible(); @@ -46,7 +46,7 @@ test.describe("@smoke", () => { await step("Enter email with consecutive dots & verify validation error")(async () => { await page.getByRole("textbox", { name: "Email" }).fill("test..user@example.com"); await blurActiveElement(page); - await page.getByRole("button", { name: "Create your account" }).click(); + await page.getByRole("button", { name: "Sign up with email" }).click(); await expect(page).toHaveURL("/signup"); await expect(page.getByText("Email must be in a valid format and no longer than 100 characters.")).toBeVisible(); @@ -56,7 +56,7 @@ test.describe("@smoke", () => { const longEmail = `${"a".repeat(90)}@example.com`; // 101 characters total await page.getByRole("textbox", { name: "Email" }).fill(longEmail); await blurActiveElement(page); - await page.getByRole("button", { name: "Create your account" }).click(); + await page.getByRole("button", { name: "Sign up with email" }).click(); await expect(page).toHaveURL("/signup"); await expect(page.getByText("Email must be in a valid format and no longer than 100 characters.")).toBeVisible(); @@ -67,7 +67,7 @@ test.describe("@smoke", () => { await page.getByRole("textbox", { name: "Email" }).fill(user.email); await blurActiveElement(page); await expect(page.getByText("Europe")).toBeVisible(); - await page.getByRole("button", { name: "Create your account" }).click(); + await page.getByRole("button", { name: "Sign up with email" }).click(); // Verify verification page state await expect(page).toHaveURL("/signup/verify"); @@ -236,7 +236,7 @@ test.describe("@comprehensive", () => { await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); await page.getByRole("textbox", { name: "Email" }).fill(testEmail); - await page.getByRole("button", { name: "Create your account" }).click(); + await page.getByRole("button", { name: "Sign up with email" }).click(); await expect(page).toHaveURL("/signup/verify"); } @@ -247,7 +247,7 @@ test.describe("@comprehensive", () => { await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); await page.getByRole("textbox", { name: "Email" }).fill(testEmail); - await page.getByRole("button", { name: "Create your account" }).click(); + await page.getByRole("button", { name: "Sign up with email" }).click(); // Verify rate limiting prevents navigation await expect(page).toHaveURL("/signup"); @@ -274,7 +274,7 @@ test.describe("@slow", () => { await page.goto("/signup"); await page.getByRole("textbox", { name: "Email" }).fill(user.email); await blurActiveElement(page); - await page.getByRole("button", { name: "Create your account" }).click(); + await page.getByRole("button", { name: "Sign up with email" }).click(); await expect(page).toHaveURL("/signup/verify"); await expect(page.getByText("Can't find your code? Check your spam folder.").first()).toBeVisible(); diff --git a/application/account-management/WebApp/tests/e2e/tenant-switching-flows.spec.ts b/application/account-management/WebApp/tests/e2e/tenant-switching-flows.spec.ts index 00cede11f..aafe2c574 100644 --- a/application/account-management/WebApp/tests/e2e/tenant-switching-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/tenant-switching-flows.spec.ts @@ -159,7 +159,7 @@ test.describe("@comprehensive", () => { await step("Login with multiple tenants & verify tenant switching UI displays correctly")(async () => { // Login await page1.getByRole("textbox", { name: "Email" }).fill(user.email); - await page1.getByRole("button", { name: "Continue", exact: true }).click(); + await page1.getByRole("button", { name: "Log in with email" }).click(); await expect(page1.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); await typeOneTimeCode(page1, getVerificationCode()); // Wait for navigation to complete - could be Users or Home page @@ -288,7 +288,7 @@ test.describe("@comprehensive", () => { // Login again await page1.getByRole("textbox", { name: "Email" }).fill(user.email); - await page1.getByRole("button", { name: "Continue", exact: true }).click(); + await page1.getByRole("button", { name: "Log in with email" }).click(); await expect(page1.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); await typeOneTimeCode(page1, getVerificationCode()); @@ -381,7 +381,7 @@ test.describe("@comprehensive", () => { // Login as different user await page1.getByRole("textbox", { name: "Email" }).fill(secondUser.email); - await page1.getByRole("button", { name: "Continue", exact: true }).click(); + await page1.getByRole("button", { name: "Log in with email" }).click(); await expect(page1.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); await typeOneTimeCode(page1, getVerificationCode()); await expect(page1.locator('nav[aria-label="Main navigation"]')).toBeVisible(); @@ -416,7 +416,7 @@ test.describe("@comprehensive", () => { // Login as original user (who has access to multiple tenants) await page1.getByRole("textbox", { name: "Email" }).fill(user.email); - await page1.getByRole("button", { name: "Continue", exact: true }).click(); + await page1.getByRole("button", { name: "Log in with email" }).click(); await expect(page1.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); await typeOneTimeCode(page1, getVerificationCode()); await expect(page1.locator('nav[aria-label="Main navigation"]')).toBeVisible(); @@ -461,7 +461,7 @@ test.describe("@comprehensive", () => { // Login again in tab 1 await page1.getByRole("textbox", { name: "Email" }).fill(user.email); - await page1.getByRole("button", { name: "Continue", exact: true }).click(); + await page1.getByRole("button", { name: "Log in with email" }).click(); await expect(page1.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); await typeOneTimeCode(page1, getVerificationCode()); await expect(page1.locator('nav[aria-label="Main navigation"]')).toBeVisible(); @@ -563,7 +563,7 @@ test.describe("@comprehensive", () => { // Login as the invited user in page2 await page2.getByRole("textbox", { name: "Email" }).fill(user.email); - await page2.getByRole("button", { name: "Continue", exact: true }).click(); + await page2.getByRole("button", { name: "Log in with email" }).click(); await expect(page2.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); await typeOneTimeCode(page2, getVerificationCode()); await expect(page2.locator('nav[aria-label="Main navigation"]')).toBeVisible(); diff --git a/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts b/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts index 7319a01b8..17ad0dc19 100644 --- a/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts @@ -307,7 +307,7 @@ test.describe("@smoke", () => { await step("Login as deletable user & verify unsaved changes warning on profile Escape")(async () => { await page.getByRole("textbox", { name: "Email" }).fill(deletableUser.email); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Log in with email" }).click(); await expect(page.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); await typeOneTimeCode(page, getVerificationCode()); @@ -343,7 +343,7 @@ test.describe("@smoke", () => { await expect(page.getByRole("textbox", { name: "Email" })).toBeVisible(); await page.getByRole("textbox", { name: "Email" }).fill(owner.email); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Log in with email" }).click(); await expect(page.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); await typeOneTimeCode(page, getVerificationCode()); @@ -407,7 +407,7 @@ test.describe("@smoke", () => { await step("Login as admin user & verify successful authentication")(async () => { await page.getByRole("textbox", { name: "Email" }).fill(adminUser.email); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Log in with email" }).click(); await expect(page).toHaveURL("/login/verify?returnPath=%2Fadmin"); await typeOneTimeCode(page, getVerificationCode()); @@ -460,7 +460,7 @@ test.describe("@smoke", () => { // === MEMBER PERMISSION CHECK SECTION === await step("Login as member user & verify access denied on recycle-bin")(async () => { await page.getByRole("textbox", { name: "Email" }).fill(memberUser.email); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Log in with email" }).click(); await expect(page).toHaveURL("/login/verify?returnPath=%2Fadmin%2Fusers%2Frecycle-bin"); await typeOneTimeCode(page, getVerificationCode()); @@ -650,7 +650,7 @@ test.describe("@comprehensive", () => { await expect(page.getByRole("textbox", { name: "Email" })).toBeVisible(); await page.getByRole("textbox", { name: "Email" }).fill(user1.email); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Log in with email" }).click(); await expect(page.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); await typeOneTimeCode(page, getVerificationCode()); @@ -676,7 +676,7 @@ test.describe("@comprehensive", () => { await expect(page.getByRole("textbox", { name: "Email" })).toBeVisible(); await page.getByRole("textbox", { name: "Email" }).fill(user2.email); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Log in with email" }).click(); await expect(page.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); await typeOneTimeCode(page, getVerificationCode()); @@ -703,7 +703,7 @@ test.describe("@comprehensive", () => { await expect(page.getByRole("textbox", { name: "Email" })).toBeVisible(); await page.getByRole("textbox", { name: "Email" }).fill(owner.email); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Log in with email" }).click(); await expect(page.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); await typeOneTimeCode(page, getVerificationCode()); diff --git a/application/shared-webapp/infrastructure/auth/AuthenticationMiddleware.ts b/application/shared-webapp/infrastructure/auth/AuthenticationMiddleware.ts index 2048d1a97..41ad6cb68 100644 --- a/application/shared-webapp/infrastructure/auth/AuthenticationMiddleware.ts +++ b/application/shared-webapp/infrastructure/auth/AuthenticationMiddleware.ts @@ -18,7 +18,13 @@ export const ErrorCode = { ReplayAttack: "replay_attack", SessionRevoked: "session_revoked", SessionNotFound: "session_not_found", - SessionExpired: "session_expired" + UserNotFound: "user_not_found", + IdentityMismatch: "identity_mismatch", + SessionExpired: "session_expired", + AuthenticationFailed: "authentication_failed", + InvalidRequest: "invalid_request", + AccessDenied: "access_denied", + AccountAlreadyExists: "account_already_exists" } as const; const unauthorizedReasonHeaderKey = "x-unauthorized-reason"; diff --git a/application/shared-webapp/tests/e2e/utils/test-data.ts b/application/shared-webapp/tests/e2e/utils/test-data.ts index e5a11d691..78504e8f6 100644 --- a/application/shared-webapp/tests/e2e/utils/test-data.ts +++ b/application/shared-webapp/tests/e2e/utils/test-data.ts @@ -147,7 +147,7 @@ export async function completeSignupFlow( // Step 2: Enter email and submit await page.getByRole("textbox", { name: "Email" }).fill(user.email); - await page.getByRole("button", { name: "Create your account" }).click(); + await page.getByRole("button", { name: "Sign up with email" }).click(); await expect(page).toHaveURL("/signup/verify"); // Step 3: Enter verification code (auto-submits after 6 characters) From c18aad8eb0040bccc2478d020e6ad8a9a361884d Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 10 Feb 2026 03:18:06 +0100 Subject: [PATCH 10/13] Add tests for Google OAuth flows --- .../CompleteExternalLoginTests.cs | 584 ++++++++++++++++++ .../CompleteExternalSignupTests.cs | 319 ++++++++++ .../Domain/ExternalLoginTests.cs | 263 ++++++++ .../ExternalAuthenticationServiceTests.cs | 287 +++++++++ .../ExternalAuthenticationTestBase.cs | 314 ++++++++++ .../ExternalAvatarClientTests.cs | 174 ++++++ .../GoogleOAuthAtHashTests.cs | 90 +++ .../MockOAuthProviderEnforcementTests.cs | 113 ++++ .../StartExternalLoginTests.cs | 56 ++ .../StartExternalSignupTests.cs | 56 ++ .../tests/e2e/google-oauth-flows.spec.ts | 323 ++++++++++ 11 files changed, 2579 insertions(+) create mode 100644 application/account-management/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs create mode 100644 application/account-management/Tests/ExternalAuthentication/CompleteExternalSignupTests.cs create mode 100644 application/account-management/Tests/ExternalAuthentication/Domain/ExternalLoginTests.cs create mode 100644 application/account-management/Tests/ExternalAuthentication/ExternalAuthenticationServiceTests.cs create mode 100644 application/account-management/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs create mode 100644 application/account-management/Tests/ExternalAuthentication/ExternalAvatarClientTests.cs create mode 100644 application/account-management/Tests/ExternalAuthentication/GoogleOAuthAtHashTests.cs create mode 100644 application/account-management/Tests/ExternalAuthentication/MockOAuthProviderEnforcementTests.cs create mode 100644 application/account-management/Tests/ExternalAuthentication/StartExternalLoginTests.cs create mode 100644 application/account-management/Tests/ExternalAuthentication/StartExternalSignupTests.cs create mode 100644 application/account-management/WebApp/tests/e2e/google-oauth-flows.spec.ts diff --git a/application/account-management/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs b/application/account-management/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs new file mode 100644 index 000000000..ac530a26c --- /dev/null +++ b/application/account-management/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs @@ -0,0 +1,584 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; +using PlatformPlatform.AccountManagement.Features.Tenants.Domain; +using PlatformPlatform.AccountManagement.Features.Users.Domain; +using PlatformPlatform.AccountManagement.Integrations.OAuth.Mock; +using PlatformPlatform.SharedKernel.Domain; +using PlatformPlatform.SharedKernel.Tests.Persistence; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.ExternalAuthentication; + +public sealed class CompleteExternalLoginTests : ExternalAuthenticationTestBase +{ + [Fact] + public async Task CompleteExternalLogin_WhenValid_ShouldCreateSessionAndRedirect() + { + // Arrange + InsertUserWithExternalIdentity(MockOAuthProvider.MockEmail, ExternalProviderType.Google, MockOAuthProvider.MockProviderUserId); + var (callbackUrl, cookies) = await StartLoginFlow("/dashboard"); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, cookies); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Be("/dashboard"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("SessionCreated"); + TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("ExternalLoginCompleted"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task CompleteExternalLogin_WhenUserNotFound_ShouldRedirectToErrorPage() + { + // Arrange + var (callbackUrl, cookies) = await StartLoginFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, cookies); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error?error=user_not_found"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalLoginFailed"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task CompleteExternalLogin_WhenIdentityMismatch_ShouldRedirectToErrorPage() + { + // Arrange + InsertUserWithExternalIdentity(MockOAuthProvider.MockEmail, ExternalProviderType.Google, "different-provider-user-id"); + var (callbackUrl, cookies) = await StartLoginFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, cookies); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error?error=authentication_failed"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalLoginFailed"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task CompleteExternalLogin_WhenOAuthError_ShouldRedirectToErrorPage() + { + // Arrange + var (callbackUrl, cookies) = await StartLoginFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallbackWithError(callbackUrl, cookies, "access_denied", "User denied access"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error?error=access_denied"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalLoginFailed"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task CompleteExternalLogin_WhenMissingCode_ShouldRedirectToErrorPage() + { + // Arrange + var (callbackUrl, cookies) = await StartLoginFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallbackWithoutCode(callbackUrl, cookies); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error?error=authentication_failed"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalLoginFailed"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task CompleteExternalLogin_WhenMissingState_ShouldRedirectToErrorPage() + { + // Act + var response = await NoRedirectHttpClient.GetAsync("/api/account-management/authentication/Google/login/callback"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error?error=invalid_request"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalLoginFailed"); + } + + [Fact] + public async Task CompleteExternalLogin_WhenFlowAlreadyCompleted_ShouldRedirectToErrorPage() + { + // Arrange + InsertUserWithExternalIdentity(MockOAuthProvider.MockEmail, ExternalProviderType.Google, MockOAuthProvider.MockProviderUserId); + var (callbackUrl, cookies) = await StartLoginFlow(); + await CallCallback(callbackUrl, cookies); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, cookies); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error?error=authentication_failed"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalLoginFailed"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task CompleteExternalLogin_WhenExpired_ShouldRedirectToErrorPage() + { + // Arrange + var (callbackUrl, cookies) = await StartLoginFlow(); + var externalLoginId = GetExternalLoginIdFromUrl(callbackUrl); + ExpireExternalLogin(externalLoginId); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, cookies); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error?error=session_expired"); + + var loginResult = Connection.ExecuteScalar( + "SELECT LoginResult FROM ExternalLogins WHERE Id = @id", [new { id = externalLoginId }] + ); + loginResult.Should().Be(nameof(ExternalLoginResult.LoginExpired)); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalLoginFailed"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task CompleteExternalLogin_WhenNonceMismatch_ShouldRedirectToErrorPage() + { + // Arrange + InsertUserWithExternalIdentity(MockOAuthProvider.MockEmail, ExternalProviderType.Google, MockOAuthProvider.MockProviderUserId); + var (callbackUrl, cookies) = await StartLoginFlow(); + var externalLoginId = GetExternalLoginIdFromUrl(callbackUrl); + TamperWithNonce(externalLoginId); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, cookies); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error?error=authentication_failed"); + + var loginResult = Connection.ExecuteScalar( + "SELECT LoginResult FROM ExternalLogins WHERE Id = @id", [new { id = externalLoginId }] + ); + loginResult.Should().Be(nameof(ExternalLoginResult.NonceMismatch)); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalLoginFailed"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task CompleteExternalLogin_WhenUserHasNoExternalIdentity_ShouldLinkIdentityAndCreateSession() + { + // Arrange + Connection.Insert("Users", [ + ("TenantId", DatabaseSeeder.Tenant1.Id.ToString()), + ("Id", UserId.NewId().ToString()), + ("CreatedAt", TimeProvider.GetUtcNow()), + ("ModifiedAt", null), + ("Email", MockOAuthProvider.MockEmail), + ("EmailConfirmed", true), + ("FirstName", Faker.Name.FirstName()), + ("LastName", Faker.Name.LastName()), + ("Title", null), + ("Avatar", JsonSerializer.Serialize(new Avatar())), + ("Role", nameof(UserRole.Member)), + ("Locale", "en-US"), + ("ExternalIdentities", "[]") + ] + ); + var (callbackUrl, cookies) = await StartLoginFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, cookies); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Be("/"); + + var externalIdentities = Connection.ExecuteScalar( + "SELECT ExternalIdentities FROM Users WHERE Email = @email", [new { email = MockOAuthProvider.MockEmail }] + ); + externalIdentities.Should().Contain(MockOAuthProvider.MockProviderUserId); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("SessionCreated"); + TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("ExternalLoginCompleted"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task CompleteExternalLogin_WhenInvitedUserHasNoName_ShouldUpdateNameFromGoogleProfile() + { + // Arrange + Connection.Insert("Users", [ + ("TenantId", DatabaseSeeder.Tenant1.Id.ToString()), + ("Id", UserId.NewId().ToString()), + ("CreatedAt", TimeProvider.GetUtcNow()), + ("ModifiedAt", null), + ("Email", MockOAuthProvider.MockEmail), + ("EmailConfirmed", false), + ("FirstName", null), + ("LastName", null), + ("Title", null), + ("Avatar", JsonSerializer.Serialize(new Avatar())), + ("Role", nameof(UserRole.Member)), + ("Locale", "en-US"), + ("ExternalIdentities", "[]") + ] + ); + var (callbackUrl, cookies) = await StartLoginFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, cookies); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Be("/"); + + var firstName = Connection.ExecuteScalar( + "SELECT FirstName FROM Users WHERE Email = @email", [new { email = MockOAuthProvider.MockEmail }] + ); + var lastName = Connection.ExecuteScalar( + "SELECT LastName FROM Users WHERE Email = @email", [new { email = MockOAuthProvider.MockEmail }] + ); + firstName.Should().Be(MockOAuthProvider.MockFirstName); + lastName.Should().Be(MockOAuthProvider.MockLastName); + } + + [Fact] + public async Task CompleteExternalLogin_WhenUserAlreadyHasName_ShouldNotOverwriteFromGoogleProfile() + { + // Arrange + var existingFirstName = Faker.Name.FirstName(); + var existingLastName = Faker.Name.LastName(); + Connection.Insert("Users", [ + ("TenantId", DatabaseSeeder.Tenant1.Id.ToString()), + ("Id", UserId.NewId().ToString()), + ("CreatedAt", TimeProvider.GetUtcNow()), + ("ModifiedAt", null), + ("Email", MockOAuthProvider.MockEmail), + ("EmailConfirmed", true), + ("FirstName", existingFirstName), + ("LastName", existingLastName), + ("Title", null), + ("Avatar", JsonSerializer.Serialize(new Avatar())), + ("Role", nameof(UserRole.Member)), + ("Locale", "en-US"), + ("ExternalIdentities", "[]") + ] + ); + var (callbackUrl, cookies) = await StartLoginFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, cookies); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Be("/"); + + var firstName = Connection.ExecuteScalar( + "SELECT FirstName FROM Users WHERE Email = @email", [new { email = MockOAuthProvider.MockEmail }] + ); + var lastName = Connection.ExecuteScalar( + "SELECT LastName FROM Users WHERE Email = @email", [new { email = MockOAuthProvider.MockEmail }] + ); + firstName.Should().Be(existingFirstName); + lastName.Should().Be(existingLastName); + } + + [Fact] + public async Task CompleteExternalLogin_WhenNoCookie_ShouldRedirectToErrorPage() + { + // Arrange + var (callbackUrl, _) = await StartLoginFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, []); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalLoginFailed"); + } + + [Fact] + public async Task CompleteExternalLogin_WhenDefaultReturnPath_ShouldRedirectToRoot() + { + // Arrange + InsertUserWithExternalIdentity(MockOAuthProvider.MockEmail, ExternalProviderType.Google, MockOAuthProvider.MockProviderUserId); + var (callbackUrl, cookies) = await StartLoginFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, cookies); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Be("/"); + } + + [Fact] + public async Task CompleteExternalLogin_WhenValid_ShouldMarkCompletedInDatabase() + { + // Arrange + InsertUserWithExternalIdentity(MockOAuthProvider.MockEmail, ExternalProviderType.Google, MockOAuthProvider.MockProviderUserId); + var (callbackUrl, cookies) = await StartLoginFlow(); + var externalLoginId = GetExternalLoginIdFromUrl(callbackUrl); + TelemetryEventsCollectorSpy.Reset(); + + // Act + await CallCallback(callbackUrl, cookies); + + // Assert + var loginResult = Connection.ExecuteScalar( + "SELECT LoginResult FROM ExternalLogins WHERE Id = @id", [new { id = externalLoginId }] + ); + loginResult.Should().Be(nameof(ExternalLoginResult.Success)); + } + + [Fact] + public async Task CompleteExternalLogin_WhenUserNotFound_ShouldMarkFailedInDatabase() + { + // Arrange + var (callbackUrl, cookies) = await StartLoginFlow(); + var externalLoginId = GetExternalLoginIdFromUrl(callbackUrl); + TelemetryEventsCollectorSpy.Reset(); + + // Act + await CallCallback(callbackUrl, cookies); + + // Assert + var loginResult = Connection.ExecuteScalar( + "SELECT LoginResult FROM ExternalLogins WHERE Id = @id", [new { id = externalLoginId }] + ); + loginResult.Should().Be(nameof(ExternalLoginResult.UserNotFound)); + } + + [Fact] + public async Task CompleteExternalLogin_WithValidPreferredTenant_ShouldLoginToPreferredTenant() + { + // Arrange + var tenant2Id = TenantId.NewId(); + var user2Id = UserId.NewId(); + + Connection.Insert("Tenants", [ + ("Id", tenant2Id.Value), + ("CreatedAt", TimeProvider.GetUtcNow()), + ("ModifiedAt", null), + ("Name", Faker.Company.CompanyName()), + ("State", nameof(TenantState.Active)), + ("Logo", """{"Url":null,"Version":0}""") + ] + ); + + var identities = JsonSerializer.Serialize(new[] { new { Provider = nameof(ExternalProviderType.Google), ProviderUserId = MockOAuthProvider.MockProviderUserId } }); + Connection.Insert("Users", [ + ("TenantId", DatabaseSeeder.Tenant1.Id.ToString()), + ("Id", UserId.NewId().ToString()), + ("CreatedAt", TimeProvider.GetUtcNow()), + ("ModifiedAt", null), + ("Email", MockOAuthProvider.MockEmail), + ("EmailConfirmed", true), + ("FirstName", Faker.Name.FirstName()), + ("LastName", Faker.Name.LastName()), + ("Title", null), + ("Avatar", JsonSerializer.Serialize(new Avatar())), + ("Role", nameof(UserRole.Member)), + ("Locale", "en-US"), + ("ExternalIdentities", identities) + ] + ); + + Connection.Insert("Users", [ + ("TenantId", tenant2Id.Value), + ("Id", user2Id.ToString()), + ("CreatedAt", TimeProvider.GetUtcNow()), + ("ModifiedAt", null), + ("Email", MockOAuthProvider.MockEmail), + ("EmailConfirmed", true), + ("FirstName", Faker.Name.FirstName()), + ("LastName", Faker.Name.LastName()), + ("Title", null), + ("Avatar", JsonSerializer.Serialize(new Avatar())), + ("Role", nameof(UserRole.Owner)), + ("Locale", "en-US"), + ("ExternalIdentities", identities) + ] + ); + + var (callbackUrl, cookies) = await StartLoginFlow(preferredTenantId: tenant2Id); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, cookies); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Be("/"); + + var sessionTenantId = Connection.ExecuteScalar( + "SELECT TenantId FROM Sessions WHERE UserId = @userId ORDER BY CreatedAt DESC LIMIT 1", [new { userId = user2Id.ToString() }] + ); + sessionTenantId.Should().Be(tenant2Id.Value); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("SessionCreated"); + TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("ExternalLoginCompleted"); + TelemetryEventsCollectorSpy.CollectedEvents[1].Properties["event.user_id"].Should().Be(user2Id); + } + + [Fact] + public async Task CompleteExternalLogin_WhenTamperedState_ShouldRedirectToErrorPage() + { + // Arrange + var (callbackUrl, cookies) = await StartLoginFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallbackWithTamperedState(callbackUrl, cookies); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error?error=invalid_request"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalLoginFailed"); + } + + [Fact] + public async Task CompleteExternalLogin_WhenFlowIdMismatch_ShouldRedirectToErrorPage() + { + // Arrange + var (callbackUrl1, _) = await StartLoginFlow(); + var (_, cookies2) = await StartLoginFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallbackWithCrossedFlows(callbackUrl1, cookies2); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error?error=authentication_failed"); + + var externalLoginId = GetExternalLoginIdFromUrl(callbackUrl1); + var loginResult = Connection.ExecuteScalar( + "SELECT LoginResult FROM ExternalLogins WHERE Id = @id", [new { id = externalLoginId }] + ); + loginResult.Should().Be(nameof(ExternalLoginResult.FlowIdMismatch)); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalLoginFailed"); + } + + [Fact] + public async Task CompleteExternalLogin_WhenTamperedCookie_ShouldRedirectToErrorPage() + { + // Arrange + var (callbackUrl, _) = await StartLoginFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallbackWithTamperedCookie(callbackUrl, "corrupted-encrypted-data"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalLoginFailed"); + } + + [Fact] + public async Task CompleteExternalLogin_WithInvalidPreferredTenant_ShouldLoginToDefaultTenant() + { + // Arrange + var invalidTenantId = TenantId.NewId(); + var userId = InsertUserWithExternalIdentity(MockOAuthProvider.MockEmail, ExternalProviderType.Google, MockOAuthProvider.MockProviderUserId); + var (callbackUrl, cookies) = await StartLoginFlow(preferredTenantId: invalidTenantId); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, cookies); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Be("/"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("SessionCreated"); + TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("ExternalLoginCompleted"); + TelemetryEventsCollectorSpy.CollectedEvents[1].Properties["event.user_id"].Should().Be(userId); + } + + [Fact] + public async Task CompleteExternalLogin_WithPreferredTenantUserDoesNotHaveAccess_ShouldLoginToDefaultTenant() + { + // Arrange + var tenant2Id = TenantId.NewId(); + + Connection.Insert("Tenants", [ + ("Id", tenant2Id.Value), + ("CreatedAt", TimeProvider.GetUtcNow()), + ("ModifiedAt", null), + ("Name", Faker.Company.CompanyName()), + ("State", nameof(TenantState.Active)), + ("Logo", """{"Url":null,"Version":0}""") + ] + ); + + var userId = InsertUserWithExternalIdentity(MockOAuthProvider.MockEmail, ExternalProviderType.Google, MockOAuthProvider.MockProviderUserId); + var (callbackUrl, cookies) = await StartLoginFlow(preferredTenantId: tenant2Id); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, cookies); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Be("/"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("SessionCreated"); + TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("ExternalLoginCompleted"); + TelemetryEventsCollectorSpy.CollectedEvents[1].Properties["event.user_id"].Should().Be(userId); + } +} diff --git a/application/account-management/Tests/ExternalAuthentication/CompleteExternalSignupTests.cs b/application/account-management/Tests/ExternalAuthentication/CompleteExternalSignupTests.cs new file mode 100644 index 000000000..22a37186f --- /dev/null +++ b/application/account-management/Tests/ExternalAuthentication/CompleteExternalSignupTests.cs @@ -0,0 +1,319 @@ +using System.Net; +using FluentAssertions; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; +using PlatformPlatform.AccountManagement.Integrations.OAuth.Mock; +using PlatformPlatform.SharedKernel.Tests.Persistence; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.ExternalAuthentication; + +public sealed class CompleteExternalSignupTests : ExternalAuthenticationTestBase +{ + [Fact] + public async Task CompleteExternalSignup_WhenValid_ShouldCreateTenantUserAndSession() + { + // Arrange + var (callbackUrl, cookies) = await StartSignupFlow("/onboarding", "en-US"); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, cookies, "signup"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Be("/onboarding"); + + var userCount = Connection.ExecuteScalar( + "SELECT COUNT(*) FROM Users WHERE Email = @email", [new { email = MockOAuthProvider.MockEmail }] + ); + userCount.Should().Be(1); + + var tenantCount = Connection.ExecuteScalar("SELECT COUNT(*) FROM Tenants", []); + tenantCount.Should().BeGreaterThan(1); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().Contain(e => e.GetType().Name == "TenantCreated"); + TelemetryEventsCollectorSpy.CollectedEvents.Should().Contain(e => e.GetType().Name == "UserCreated"); + TelemetryEventsCollectorSpy.CollectedEvents.Should().Contain(e => e.GetType().Name == "SessionCreated"); + TelemetryEventsCollectorSpy.CollectedEvents.Should().Contain(e => e.GetType().Name == "ExternalSignupCompleted"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task CompleteExternalSignup_WhenUserAlreadyExists_ShouldRedirectToErrorPage() + { + // Arrange + InsertUserWithExternalIdentity(MockOAuthProvider.MockEmail, ExternalProviderType.Google, MockOAuthProvider.MockProviderUserId); + var (callbackUrl, cookies) = await StartSignupFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, cookies, "signup"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error?error=account_already_exists"); + + var externalLoginId = GetExternalLoginIdFromUrl(callbackUrl); + var loginResult = Connection.ExecuteScalar( + "SELECT LoginResult FROM ExternalLogins WHERE Id = @id", [new { id = externalLoginId }] + ); + loginResult.Should().Be(nameof(ExternalLoginResult.AccountAlreadyExists)); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalSignupFailed"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task CompleteExternalSignup_WhenOAuthError_ShouldRedirectToErrorPage() + { + // Arrange + var (callbackUrl, cookies) = await StartSignupFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallbackWithError(callbackUrl, cookies, "access_denied", "User denied access"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error?error=access_denied"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalSignupFailed"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task CompleteExternalSignup_WhenMissingCode_ShouldRedirectToErrorPage() + { + // Arrange + var (callbackUrl, cookies) = await StartSignupFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallbackWithoutCode(callbackUrl, cookies); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error?error=authentication_failed"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalSignupFailed"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task CompleteExternalSignup_WhenMissingState_ShouldRedirectToErrorPage() + { + // Act + var response = await NoRedirectHttpClient.GetAsync("/api/account-management/authentication/Google/signup/callback"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error?error=invalid_request"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalSignupFailed"); + } + + [Fact] + public async Task CompleteExternalSignup_WhenExpired_ShouldRedirectToErrorPage() + { + // Arrange + var (callbackUrl, cookies) = await StartSignupFlow(); + var externalLoginId = GetExternalLoginIdFromUrl(callbackUrl); + ExpireExternalLogin(externalLoginId); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, cookies, "signup"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error?error=session_expired"); + + var loginResult = Connection.ExecuteScalar( + "SELECT LoginResult FROM ExternalLogins WHERE Id = @id", [new { id = externalLoginId }] + ); + loginResult.Should().Be(nameof(ExternalLoginResult.LoginExpired)); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalSignupFailed"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task CompleteExternalSignup_WhenNonceMismatch_ShouldRedirectToErrorPage() + { + // Arrange + var (callbackUrl, cookies) = await StartSignupFlow(); + var externalLoginId = GetExternalLoginIdFromUrl(callbackUrl); + TamperWithNonce(externalLoginId); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, cookies, "signup"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error?error=authentication_failed"); + + var loginResult = Connection.ExecuteScalar( + "SELECT LoginResult FROM ExternalLogins WHERE Id = @id", [new { id = externalLoginId }] + ); + loginResult.Should().Be(nameof(ExternalLoginResult.NonceMismatch)); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalSignupFailed"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task CompleteExternalSignup_WhenTamperedState_ShouldRedirectToErrorPage() + { + // Arrange + var (callbackUrl, cookies) = await StartSignupFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallbackWithTamperedState(callbackUrl, cookies); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error?error=invalid_request"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalSignupFailed"); + } + + [Fact] + public async Task CompleteExternalSignup_WhenFlowIdMismatch_ShouldRedirectToErrorPage() + { + // Arrange + var (callbackUrl1, _) = await StartSignupFlow(); + var (_, cookies2) = await StartSignupFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallbackWithCrossedFlows(callbackUrl1, cookies2); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error?error=authentication_failed"); + + var externalLoginId = GetExternalLoginIdFromUrl(callbackUrl1); + var loginResult = Connection.ExecuteScalar( + "SELECT LoginResult FROM ExternalLogins WHERE Id = @id", [new { id = externalLoginId }] + ); + loginResult.Should().Be(nameof(ExternalLoginResult.FlowIdMismatch)); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalSignupFailed"); + } + + [Fact] + public async Task CompleteExternalSignup_WhenTamperedCookie_ShouldRedirectToErrorPage() + { + // Arrange + var (callbackUrl, _) = await StartSignupFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallbackWithTamperedCookie(callbackUrl, "corrupted-encrypted-data"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalSignupFailed"); + } + + [Fact] + public async Task CompleteExternalSignup_WhenFlowAlreadyCompleted_ShouldRedirectToErrorPage() + { + // Arrange + var (callbackUrl, cookies) = await StartSignupFlow(); + await CallCallback(callbackUrl, cookies, "signup"); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, cookies, "signup"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error?error=authentication_failed"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalSignupFailed"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task CompleteExternalSignup_WhenNoCookie_ShouldRedirectToErrorPage() + { + // Arrange + var (callbackUrl, _) = await StartSignupFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, [], "signup"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Contain("/error"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalSignupFailed"); + } + + [Fact] + public async Task CompleteExternalSignup_WhenValid_ShouldMarkCompletedInDatabase() + { + // Arrange + var (callbackUrl, cookies) = await StartSignupFlow(locale: "en-US"); + var externalLoginId = GetExternalLoginIdFromUrl(callbackUrl); + TelemetryEventsCollectorSpy.Reset(); + + // Act + await CallCallback(callbackUrl, cookies, "signup"); + + // Assert + var loginResult = Connection.ExecuteScalar( + "SELECT LoginResult FROM ExternalLogins WHERE Id = @id", [new { id = externalLoginId }] + ); + loginResult.Should().Be(nameof(ExternalLoginResult.Success)); + } + + [Fact] + public async Task CompleteExternalSignup_WhenValid_ShouldLinkExternalIdentity() + { + // Arrange + var (callbackUrl, cookies) = await StartSignupFlow(locale: "en-US"); + TelemetryEventsCollectorSpy.Reset(); + + // Act + await CallCallback(callbackUrl, cookies, "signup"); + + // Assert + var externalIdentities = Connection.ExecuteScalar( + "SELECT ExternalIdentities FROM Users WHERE Email = @email", [new { email = MockOAuthProvider.MockEmail }] + ); + externalIdentities.Should().Contain(MockOAuthProvider.MockProviderUserId); + } + + [Fact] + public async Task CompleteExternalSignup_WhenDefaultReturnPath_ShouldRedirectToRoot() + { + // Arrange + var (callbackUrl, cookies) = await StartSignupFlow(); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await CallCallback(callbackUrl, cookies, "signup"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.ToString().Should().Be("/"); + } +} diff --git a/application/account-management/Tests/ExternalAuthentication/Domain/ExternalLoginTests.cs b/application/account-management/Tests/ExternalAuthentication/Domain/ExternalLoginTests.cs new file mode 100644 index 000000000..1d2df29c6 --- /dev/null +++ b/application/account-management/Tests/ExternalAuthentication/Domain/ExternalLoginTests.cs @@ -0,0 +1,263 @@ +using System.Security; +using FluentAssertions; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; +using PlatformPlatform.AccountManagement.Features.Users.Domain; +using PlatformPlatform.SharedKernel.Domain; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.ExternalAuthentication.Domain; + +public sealed class ExternalLoginTests +{ + [Fact] + public void Create_WhenCalledWithValidParameters_ShouldSetAllPropertiesCorrectly() + { + // Act + var externalLogin = ExternalLogin.Create( + ExternalProviderType.Google, + ExternalLoginType.Login, + "code-verifier-123", + "nonce-abc", + "browser-fingerprint-abc" + ); + + // Assert + externalLogin.Id.Should().NotBeNull(); + externalLogin.ProviderType.Should().Be(ExternalProviderType.Google); + externalLogin.Type.Should().Be(ExternalLoginType.Login); + externalLogin.CodeVerifier.Should().Be("code-verifier-123"); + externalLogin.Nonce.Should().Be("nonce-abc"); + externalLogin.BrowserFingerprint.Should().Be("browser-fingerprint-abc"); + externalLogin.LoginResult.Should().BeNull(); + externalLogin.IsConsumed.Should().BeFalse(); + } + + [Fact] + public void MarkCompleted_WhenNotYetConsumed_ShouldSetLoginResultToSuccess() + { + // Arrange + var externalLogin = CreateExternalLogin(); + + // Act + externalLogin.MarkCompleted(); + + // Assert + externalLogin.LoginResult.Should().Be(ExternalLoginResult.Success); + externalLogin.IsConsumed.Should().BeTrue(); + } + + [Fact] + public void MarkCompleted_WhenAlreadyCompleted_ShouldThrowUnreachableException() + { + // Arrange + var externalLogin = CreateExternalLogin(); + externalLogin.MarkCompleted(); + + // Act + var act = () => externalLogin.MarkCompleted(); + + // Assert + act.Should().Throw() + .WithMessage("The external login has already been completed."); + } + + [Fact] + public void MarkCompleted_WhenAlreadyFailed_ShouldThrowUnreachableException() + { + // Arrange + var externalLogin = CreateExternalLogin(); + externalLogin.MarkFailed(ExternalLoginResult.CodeExchangeFailed); + + // Act + var act = () => externalLogin.MarkCompleted(); + + // Assert + act.Should().Throw() + .WithMessage("The external login has already been completed."); + } + + [Fact] + public void MarkFailed_WhenNotYetConsumed_ShouldSetLoginResultToFailureReason() + { + // Arrange + var externalLogin = CreateExternalLogin(); + + // Act + externalLogin.MarkFailed(ExternalLoginResult.CodeExchangeFailed); + + // Assert + externalLogin.LoginResult.Should().Be(ExternalLoginResult.CodeExchangeFailed); + externalLogin.IsConsumed.Should().BeTrue(); + } + + [Fact] + public void MarkFailed_WhenCalledWithSuccess_ShouldThrowUnreachableException() + { + // Arrange + var externalLogin = CreateExternalLogin(); + + // Act + var act = () => externalLogin.MarkFailed(ExternalLoginResult.Success); + + // Assert + act.Should().Throw() + .WithMessage("Cannot mark a login as failed with a success result."); + } + + [Fact] + public void MarkFailed_WhenAlreadyCompleted_ShouldThrowUnreachableException() + { + // Arrange + var externalLogin = CreateExternalLogin(); + externalLogin.MarkCompleted(); + + // Act + var act = () => externalLogin.MarkFailed(ExternalLoginResult.LoginExpired); + + // Assert + act.Should().Throw() + .WithMessage("The external login has already been completed."); + } + + [Fact] + public void MarkFailed_WhenAlreadyFailed_ShouldThrowUnreachableException() + { + // Arrange + var externalLogin = CreateExternalLogin(); + externalLogin.MarkFailed(ExternalLoginResult.InvalidState); + + // Act + var act = () => externalLogin.MarkFailed(ExternalLoginResult.LoginExpired); + + // Assert + act.Should().Throw() + .WithMessage("The external login has already been completed."); + } + + [Fact] + public void IsExpired_WhenWithinValidPeriod_ShouldReturnFalse() + { + // Arrange + var externalLogin = CreateExternalLogin(); + var now = externalLogin.CreatedAt.AddSeconds(ExternalLogin.ValidForSeconds - 1); + + // Act + var isExpired = externalLogin.IsExpired(now); + + // Assert + isExpired.Should().BeFalse(); + } + + [Fact] + public void IsExpired_WhenExactlyAtExpiry_ShouldReturnFalse() + { + // Arrange + var externalLogin = CreateExternalLogin(); + var now = externalLogin.CreatedAt.AddSeconds(ExternalLogin.ValidForSeconds); + + // Act + var isExpired = externalLogin.IsExpired(now); + + // Assert + isExpired.Should().BeFalse(); + } + + [Fact] + public void IsExpired_WhenPastValidPeriod_ShouldReturnTrue() + { + // Arrange + var externalLogin = CreateExternalLogin(); + var now = externalLogin.CreatedAt.AddSeconds(ExternalLogin.ValidForSeconds + 1); + + // Act + var isExpired = externalLogin.IsExpired(now); + + // Assert + isExpired.Should().BeTrue(); + } + + [Fact] + public void IsExpired_WhenNowIsBeforeCreatedAt_ShouldThrowSecurityException() + { + // Arrange + var externalLogin = CreateExternalLogin(); + var pastTime = externalLogin.CreatedAt.AddSeconds(-1); + + // Act + var act = () => externalLogin.IsExpired(pastTime); + + // Assert + act.Should().Throw() + .WithMessage("*CreatedAt in the future*"); + } + + [Fact] + public void AddExternalIdentity_WhenNewProvider_ShouldAddIdentity() + { + // Arrange + var user = User.Create(TenantId.NewId(), "user@example.com", UserRole.Member, true, "en-US"); + + // Act + user.AddExternalIdentity(ExternalProviderType.Google, "google-user-id-123"); + + // Assert + user.ExternalIdentities.Should().HaveCount(1); + user.ExternalIdentities[0].Provider.Should().Be(ExternalProviderType.Google); + user.ExternalIdentities[0].ProviderUserId.Should().Be("google-user-id-123"); + } + + [Fact] + public void AddExternalIdentity_WhenDuplicateProvider_ShouldThrowUnreachableException() + { + // Arrange + var user = User.Create(TenantId.NewId(), "user@example.com", UserRole.Member, true, "en-US"); + user.AddExternalIdentity(ExternalProviderType.Google, "google-user-id-123"); + + // Act + var act = () => user.AddExternalIdentity(ExternalProviderType.Google, "different-google-id"); + + // Assert + act.Should().Throw() + .WithMessage("*already has an external identity*"); + } + + [Fact] + public void GetExternalIdentity_WhenProviderExists_ShouldReturnIdentity() + { + // Arrange + var user = User.Create(TenantId.NewId(), "user@example.com", UserRole.Member, true, "en-US"); + user.AddExternalIdentity(ExternalProviderType.Google, "google-user-id-123"); + + // Act + var identity = user.GetExternalIdentity(ExternalProviderType.Google); + + // Assert + identity.Should().NotBeNull(); + identity.Provider.Should().Be(ExternalProviderType.Google); + identity.ProviderUserId.Should().Be("google-user-id-123"); + } + + [Fact] + public void GetExternalIdentity_WhenProviderDoesNotExist_ShouldReturnNull() + { + // Arrange + var user = User.Create(TenantId.NewId(), "user@example.com", UserRole.Member, true, "en-US"); + + // Act + var identity = user.GetExternalIdentity(ExternalProviderType.Google); + + // Assert + identity.Should().BeNull(); + } + + private static ExternalLogin CreateExternalLogin() + { + return ExternalLogin.Create( + ExternalProviderType.Google, + ExternalLoginType.Login, + "code-verifier", + "nonce-value", + "browser-fingerprint" + ); + } +} diff --git a/application/account-management/Tests/ExternalAuthentication/ExternalAuthenticationServiceTests.cs b/application/account-management/Tests/ExternalAuthentication/ExternalAuthenticationServiceTests.cs new file mode 100644 index 000000000..242214687 --- /dev/null +++ b/application/account-management/Tests/ExternalAuthentication/ExternalAuthenticationServiceTests.cs @@ -0,0 +1,287 @@ +using System.Security.Cryptography; +using System.Text; +using FluentAssertions; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; +using PlatformPlatform.AccountManagement.Integrations.OAuth; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.ExternalAuthentication; + +public sealed class ExternalAuthenticationServiceTests +{ + private static ExternalAuthenticationService CreateService(HttpContext httpContext, bool allowMockProvider = false) + { + return CreateServiceWithProvider(httpContext, new EphemeralDataProtectionProvider(), allowMockProvider); + } + + private static ExternalAuthenticationService CreateServiceWithProvider(HttpContext httpContext, IDataProtectionProvider dataProtectionProvider, bool allowMockProvider = false) + { + var httpContextAccessor = Substitute.For(); + httpContextAccessor.HttpContext.Returns(httpContext); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["OAuth:AllowMockProvider"] = allowMockProvider.ToString().ToLowerInvariant() + } + ) + .Build(); + var oauthProviderFactory = new OAuthProviderFactory(new ServiceCollection().BuildServiceProvider(), configuration); + var logger = NullLogger.Instance; + + return new ExternalAuthenticationService(httpContextAccessor, dataProtectionProvider, oauthProviderFactory, logger); + } + + [Fact] + public void GenerateBrowserFingerprintHash_ShouldReturnSha256OfUserAgentAndAcceptLanguage() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["User-Agent"] = "TestBrowser/1.0"; + httpContext.Request.Headers["Accept-Language"] = "en-US"; + var service = CreateService(httpContext); + + var expectedFingerprint = "TestBrowser/1.0|en-US"; + var expectedHash = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(expectedFingerprint))); + + // Act + var hash = service.GenerateBrowserFingerprintHash(); + + // Assert + hash.Should().Be(expectedHash); + } + + [Fact] + public void GenerateBrowserFingerprintHash_WhenDifferentHeaders_ShouldReturnDifferentHash() + { + // Arrange + var httpContext1 = new DefaultHttpContext(); + httpContext1.Request.Headers["User-Agent"] = "Chrome/120"; + httpContext1.Request.Headers["Accept-Language"] = "en-US"; + var service1 = CreateService(httpContext1); + + var httpContext2 = new DefaultHttpContext(); + httpContext2.Request.Headers["User-Agent"] = "Firefox/121"; + httpContext2.Request.Headers["Accept-Language"] = "da-DK"; + var service2 = CreateService(httpContext2); + + // Act + var hash1 = service1.GenerateBrowserFingerprintHash(); + var hash2 = service2.GenerateBrowserFingerprintHash(); + + // Assert + hash1.Should().NotBe(hash2); + } + + [Fact] + public void ValidateBrowserFingerprint_WhenMatchingFingerprint_ShouldReturnTrue() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["User-Agent"] = "TestBrowser/1.0"; + httpContext.Request.Headers["Accept-Language"] = "en-US"; + var service = CreateService(httpContext); + var fingerprintHash = service.GenerateBrowserFingerprintHash(); + + // Act + var result = service.ValidateBrowserFingerprint(fingerprintHash); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void ValidateBrowserFingerprint_WhenMismatchedFingerprint_ShouldReturnFalse() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["User-Agent"] = "TestBrowser/1.0"; + httpContext.Request.Headers["Accept-Language"] = "en-US"; + var service = CreateService(httpContext); + + var differentFingerprint = Convert.ToBase64String(SHA256.HashData("DifferentBrowser/2.0|da-DK"u8)); + + // Act + var result = service.ValidateBrowserFingerprint(differentFingerprint); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void ValidateBrowserFingerprint_WhenMockProvider_ShouldAlwaysReturnTrue() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["User-Agent"] = "TestBrowser/1.0"; + httpContext.Request.Headers["Accept-Language"] = "en-US"; + httpContext.Request.Headers.Append("Cookie", $"{OAuthProviderFactory.UseMockProviderCookieName}=true"); + var service = CreateService(httpContext, true); + + var wrongFingerprint = Convert.ToBase64String(SHA256.HashData("CompletelyWrong"u8)); + + // Act + var result = service.ValidateBrowserFingerprint(wrongFingerprint); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void GetExternalLoginIdFromState_WhenValidState_ShouldReturnExternalLoginId() + { + // Arrange + var httpContext = new DefaultHttpContext(); + var service = CreateService(httpContext); + var externalLoginId = new ExternalLoginId("extlg_01TESTID12345678901234"); + var protectedState = service.ProtectState(externalLoginId); + + // Act + var result = service.GetExternalLoginIdFromState(protectedState); + + // Assert + result.Should().NotBeNull(); + result.ToString().Should().Be(externalLoginId.ToString()); + } + + [Fact] + public void GetExternalLoginIdFromState_WhenTamperedState_ShouldReturnNull() + { + // Arrange + var httpContext = new DefaultHttpContext(); + var service = CreateService(httpContext); + + // Act + var result = service.GetExternalLoginIdFromState("garbage-tampered-data"); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void GetExternalLoginIdFromState_WhenNullState_ShouldReturnNull() + { + // Arrange + var httpContext = new DefaultHttpContext(); + var service = CreateService(httpContext); + + // Act + var result = service.GetExternalLoginIdFromState(null); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void GetExternalLoginIdFromState_WhenEmptyState_ShouldReturnNull() + { + // Arrange + var httpContext = new DefaultHttpContext(); + var service = CreateService(httpContext); + + // Act + var result = service.GetExternalLoginIdFromState(""); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void GetExternalLoginCookie_WhenTamperedCookie_ShouldReturnNull() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers.Append("Cookie", "__Host-external-login=corrupted-encrypted-data"); + var service = CreateService(httpContext); + + // Act + var result = service.GetExternalLoginCookie(); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void GetExternalLoginCookie_WhenMissingCookie_ShouldReturnNull() + { + // Arrange + var httpContext = new DefaultHttpContext(); + var service = CreateService(httpContext); + + // Act + var result = service.GetExternalLoginCookie(); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void GetExternalLoginCookie_WhenValidCookieWithoutPreferredTenant_ShouldReturnCookie() + { + // Arrange + var dataProtectionProvider = new EphemeralDataProtectionProvider(); + var externalLoginId = ExternalLoginId.NewId(); + + var writeContext = new DefaultHttpContext(); + writeContext.Request.Headers["User-Agent"] = "TestBrowser/1.0"; + writeContext.Request.Headers["Accept-Language"] = "en-US"; + var writeService = CreateServiceWithProvider(writeContext, dataProtectionProvider); + writeService.SetExternalLoginCookie(externalLoginId); + + var setCookieHeader = writeContext.Response.Headers["Set-Cookie"].ToString(); + var cookieValue = setCookieHeader.Split(';')[0].Split('=', 2)[1]; + + var readContext = new DefaultHttpContext(); + readContext.Request.Headers.Append("Cookie", $"__Host-external-login={cookieValue}"); + var readService = CreateServiceWithProvider(readContext, dataProtectionProvider); + + // Act + var result = readService.GetExternalLoginCookie(); + + // Assert + result.Should().NotBeNull(); + result.ExternalLoginId.Should().Be(externalLoginId); + } + + [Fact] + public void GetExternalLoginCookie_WhenCookieHasWrongNumberOfParts_ShouldReturnNull() + { + // Arrange + var httpContext = new DefaultHttpContext(); + var dataProtectionProvider = new EphemeralDataProtectionProvider(); + var dataProtector = dataProtectionProvider.CreateProtector("ExternalLogin"); + var malformedValue = dataProtector.Protect("only-one-part"); + httpContext.Request.Headers.Append("Cookie", $"__Host-external-login={malformedValue}"); + var service = CreateServiceWithProvider(httpContext, dataProtectionProvider); + + // Act + var result = service.GetExternalLoginCookie(); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void GetExternalLoginCookie_WhenCookieHasInvalidId_ShouldReturnNull() + { + // Arrange + var httpContext = new DefaultHttpContext(); + var dataProtectionProvider = new EphemeralDataProtectionProvider(); + var dataProtector = dataProtectionProvider.CreateProtector("ExternalLogin"); + var malformedValue = dataProtector.Protect("not-a-valid-id|some-fingerprint"); + httpContext.Request.Headers.Append("Cookie", $"__Host-external-login={malformedValue}"); + var service = CreateServiceWithProvider(httpContext, dataProtectionProvider); + + // Act + var result = service.GetExternalLoginCookie(); + + // Assert + result.Should().BeNull(); + } +} diff --git a/application/account-management/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs b/application/account-management/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs new file mode 100644 index 000000000..d7b507ed5 --- /dev/null +++ b/application/account-management/Tests/ExternalAuthentication/ExternalAuthenticationTestBase.cs @@ -0,0 +1,314 @@ +using System.Net; +using System.Text.Json; +using System.Web; +using Bogus; +using FluentAssertions; +using JetBrains.Annotations; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Channel; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using PlatformPlatform.AccountManagement.Database; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; +using PlatformPlatform.AccountManagement.Features.Users.Domain; +using PlatformPlatform.AccountManagement.Integrations.OAuth; +using PlatformPlatform.SharedKernel.Domain; +using PlatformPlatform.SharedKernel.ExecutionContext; +using PlatformPlatform.SharedKernel.Integrations.Email; +using PlatformPlatform.SharedKernel.SinglePageApp; +using PlatformPlatform.SharedKernel.Telemetry; +using PlatformPlatform.SharedKernel.Tests.Persistence; +using PlatformPlatform.SharedKernel.Tests.Telemetry; + +namespace PlatformPlatform.AccountManagement.Tests.ExternalAuthentication; + +public abstract class ExternalAuthenticationTestBase : IDisposable +{ + protected readonly Faker Faker = new(); + protected readonly TimeProvider TimeProvider; + private readonly WebApplicationFactory _webApplicationFactory; + protected TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy; + + protected ExternalAuthenticationTestBase() + { + Environment.SetEnvironmentVariable(SinglePageAppConfiguration.PublicUrlKey, "https://localhost:9000"); + Environment.SetEnvironmentVariable(SinglePageAppConfiguration.CdnUrlKey, "https://localhost:9000/account-management"); + Environment.SetEnvironmentVariable( + "APPLICATIONINSIGHTS_CONNECTION_STRING", + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://localhost;LiveEndpoint=https://localhost" + ); + Environment.SetEnvironmentVariable("BypassAntiforgeryValidation", "true"); + + TimeProvider = TimeProvider.System; + + Connection = new SqliteConnection($"Data Source=TestDb_{Guid.NewGuid():N};Mode=Memory;Cache=Shared"); + Connection.Open(); + + using (var command = Connection.CreateCommand()) + { + command.CommandText = "PRAGMA foreign_keys = ON;"; + command.ExecuteNonQuery(); + command.CommandText = "PRAGMA recursive_triggers = ON;"; + command.ExecuteNonQuery(); + command.CommandText = "PRAGMA ignore_check_constraints = OFF;"; + command.ExecuteNonQuery(); + command.CommandText = "PRAGMA trusted_schema = OFF;"; + command.ExecuteNonQuery(); + } + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTransient(); + services.AddDbContext(options => { options.UseSqlite(Connection); }); + services.AddAccountManagementServices(); + + TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()); + services.AddScoped(_ => TelemetryEventsCollectorSpy); + + var emailClient = Substitute.For(); + services.AddScoped(_ => emailClient); + + var telemetryChannel = Substitute.For(); + services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = telemetryChannel })); + + services.AddScoped(); + + using var serviceProvider = services.BuildServiceProvider(); + using var serviceScope = serviceProvider.CreateScope(); + serviceScope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + DatabaseSeeder = serviceScope.ServiceProvider.GetRequiredService(); + + _webApplicationFactory = new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.ConfigureLogging(logging => { logging.AddFilter(_ => false); }); + + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["OAuth:AllowMockProvider"] = "true" + } + ); + } + ); + + builder.ConfigureTestServices(testServices => + { + testServices.Remove(testServices.Single(d => d.ServiceType == typeof(IDbContextOptionsConfiguration))); + testServices.AddDbContext(options => { options.UseSqlite(Connection); }); + + TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()); + testServices.AddScoped(_ => TelemetryEventsCollectorSpy); + + testServices.Remove(testServices.Single(d => d.ServiceType == typeof(IEmailClient))); + testServices.AddTransient(_ => emailClient); + + testServices.AddScoped(); + } + ); + } + ); + + NoRedirectHttpClient = _webApplicationFactory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + NoRedirectHttpClient.DefaultRequestHeaders.Add("User-Agent", "TestBrowser/1.0"); + NoRedirectHttpClient.DefaultRequestHeaders.Add("Accept-Language", "en-US"); + NoRedirectHttpClient.DefaultRequestHeaders.Add("Cookie", $"{OAuthProviderFactory.UseMockProviderCookieName}=true"); + } + + protected SqliteConnection Connection { get; } + + protected DatabaseSeeder DatabaseSeeder { get; } + + protected HttpClient NoRedirectHttpClient { get; } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected async Task<(string CallbackUrl, string[] Cookies)> StartLoginFlow(string? returnPath = null, string? locale = null, TenantId? preferredTenantId = null) + { + var url = BuildStartUrl("login", returnPath, locale, preferredTenantId); + var response = await NoRedirectHttpClient.GetAsync(url); + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + return (response.Headers.Location!.ToString(), ExtractSetCookieHeaders(response)); + } + + protected async Task<(string CallbackUrl, string[] Cookies)> StartSignupFlow(string? returnPath = null, string? locale = null) + { + var url = BuildStartUrl("signup", returnPath, locale); + var response = await NoRedirectHttpClient.GetAsync(url); + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + return (response.Headers.Location!.ToString(), ExtractSetCookieHeaders(response)); + } + + protected async Task CallCallback(string callbackUrl, IEnumerable cookies, string flowType = "login") + { + var uri = ToAbsoluteUri(callbackUrl); + var queryParams = HttpUtility.ParseQueryString(uri.Query); + + var requestUrl = $"{uri.AbsolutePath}?code={Uri.EscapeDataString(queryParams["code"]!)}&state={Uri.EscapeDataString(queryParams["state"]!)}"; + var request = CreateRequestWithCookies(HttpMethod.Get, requestUrl, cookies); + + return await NoRedirectHttpClient.SendAsync(request); + } + + protected async Task CallCallbackWithError(string callbackUrl, IEnumerable cookies, string error, string? errorDescription = null) + { + var uri = ToAbsoluteUri(callbackUrl); + var queryParams = HttpUtility.ParseQueryString(uri.Query); + + var requestUrl = $"{uri.AbsolutePath}?state={Uri.EscapeDataString(queryParams["state"]!)}&error={Uri.EscapeDataString(error)}"; + if (errorDescription is not null) requestUrl += $"&error_description={Uri.EscapeDataString(errorDescription)}"; + + var request = CreateRequestWithCookies(HttpMethod.Get, requestUrl, cookies); + return await NoRedirectHttpClient.SendAsync(request); + } + + protected async Task CallCallbackWithoutCode(string callbackUrl, IEnumerable cookies) + { + var uri = ToAbsoluteUri(callbackUrl); + var queryParams = HttpUtility.ParseQueryString(uri.Query); + + var requestUrl = $"{uri.AbsolutePath}?state={Uri.EscapeDataString(queryParams["state"]!)}"; + var request = CreateRequestWithCookies(HttpMethod.Get, requestUrl, cookies); + return await NoRedirectHttpClient.SendAsync(request); + } + + protected string GetExternalLoginIdFromResponse(HttpResponseMessage startResponse) + { + var location = startResponse.Headers.Location!.ToString(); + return GetExternalLoginIdFromUrl(location); + } + + protected string GetExternalLoginIdFromUrl(string url) + { + var uri = ToAbsoluteUri(url); + var queryParams = HttpUtility.ParseQueryString(uri.Query); + var state = queryParams["state"]; + + using var scope = _webApplicationFactory.Services.CreateScope(); + var externalAuthService = scope.ServiceProvider.GetRequiredService(); + var externalLoginId = externalAuthService.GetExternalLoginIdFromState(state); + return externalLoginId!.ToString(); + } + + protected void ExpireExternalLogin(string externalLoginId) + { + var expiredTime = TimeProvider.GetUtcNow().AddSeconds(-(ExternalLogin.ValidForSeconds + 1)); + Connection.Update("ExternalLogins", "Id", externalLoginId, [("CreatedAt", expiredTime)]); + } + + protected void TamperWithNonce(string externalLoginId) + { + Connection.Update("ExternalLogins", "Id", externalLoginId, [("Nonce", "tampered-nonce-value")]); + } + + protected UserId InsertUserWithExternalIdentity(string email, ExternalProviderType providerType, string providerUserId) + { + var userId = UserId.NewId(); + var identities = JsonSerializer.Serialize(new[] { new { Provider = providerType.ToString(), ProviderUserId = providerUserId } }); + Connection.Insert("Users", [ + ("TenantId", DatabaseSeeder.Tenant1.Id.ToString()), + ("Id", userId.ToString()), + ("CreatedAt", TimeProvider.GetUtcNow()), + ("ModifiedAt", null), + ("Email", email.ToLower()), + ("EmailConfirmed", true), + ("FirstName", Faker.Name.FirstName()), + ("LastName", Faker.Name.LastName()), + ("Title", null), + ("Avatar", JsonSerializer.Serialize(new Avatar())), + ("Role", nameof(UserRole.Member)), + ("Locale", "en-US"), + ("ExternalIdentities", identities) + ] + ); + return userId; + } + + [UsedImplicitly] + protected virtual void Dispose(bool disposing) + { + if (!disposing) return; + Connection.Close(); + _webApplicationFactory.Dispose(); + } + + private static string BuildStartUrl(string flowType, string? returnPath, string? locale, TenantId? preferredTenantId = null) + { + var url = $"/api/account-management/authentication/Google/{flowType}/start"; + var queryParams = new List(); + if (returnPath is not null) queryParams.Add($"returnPath={Uri.EscapeDataString(returnPath)}"); + if (locale is not null) queryParams.Add($"locale={locale}"); + if (preferredTenantId is not null) queryParams.Add($"preferredTenantId={preferredTenantId}"); + if (queryParams.Count > 0) url += "?" + string.Join("&", queryParams); + return url; + } + + private static Uri ToAbsoluteUri(string url) + { + var uri = new Uri(url, UriKind.RelativeOrAbsolute); + return uri.IsAbsoluteUri ? uri : new Uri(new Uri("https://localhost:9000"), url); + } + + private static string[] ExtractSetCookieHeaders(HttpResponseMessage response) + { + return response.Headers.TryGetValues("Set-Cookie", out var cookies) ? cookies.ToArray() : []; + } + + protected async Task CallCallbackWithTamperedState(string callbackUrl, IEnumerable cookies) + { + var uri = ToAbsoluteUri(callbackUrl); + var queryParams = HttpUtility.ParseQueryString(uri.Query); + + var requestUrl = $"{uri.AbsolutePath}?code={Uri.EscapeDataString(queryParams["code"]!)}&state=garbage-tampered-data"; + var request = CreateRequestWithCookies(HttpMethod.Get, requestUrl, cookies); + return await NoRedirectHttpClient.SendAsync(request); + } + + protected async Task CallCallbackWithCrossedFlows(string callbackUrl, IEnumerable crossedCookies) + { + var uri = ToAbsoluteUri(callbackUrl); + var queryParams = HttpUtility.ParseQueryString(uri.Query); + + var requestUrl = $"{uri.AbsolutePath}?code={Uri.EscapeDataString(queryParams["code"]!)}&state={Uri.EscapeDataString(queryParams["state"]!)}"; + var request = CreateRequestWithCookies(HttpMethod.Get, requestUrl, crossedCookies); + return await NoRedirectHttpClient.SendAsync(request); + } + + protected async Task CallCallbackWithTamperedCookie(string callbackUrl, string tamperedCookieValue) + { + var uri = ToAbsoluteUri(callbackUrl); + var queryParams = HttpUtility.ParseQueryString(uri.Query); + + var requestUrl = $"{uri.AbsolutePath}?code={Uri.EscapeDataString(queryParams["code"]!)}&state={Uri.EscapeDataString(queryParams["state"]!)}"; + var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + request.Headers.TryAddWithoutValidation("Cookie", $"__Host-external-login={tamperedCookieValue}"); + request.Headers.TryAddWithoutValidation("Cookie", $"{OAuthProviderFactory.UseMockProviderCookieName}=true"); + return await NoRedirectHttpClient.SendAsync(request); + } + + private static HttpRequestMessage CreateRequestWithCookies(HttpMethod method, string requestUrl, IEnumerable cookies) + { + var request = new HttpRequestMessage(method, requestUrl); + foreach (var cookie in cookies) + { + var cookieParts = cookie.Split(';')[0]; + request.Headers.TryAddWithoutValidation("Cookie", cookieParts); + } + + request.Headers.TryAddWithoutValidation("Cookie", $"{OAuthProviderFactory.UseMockProviderCookieName}=true"); + return request; + } +} diff --git a/application/account-management/Tests/ExternalAuthentication/ExternalAvatarClientTests.cs b/application/account-management/Tests/ExternalAuthentication/ExternalAvatarClientTests.cs new file mode 100644 index 000000000..fc19a3201 --- /dev/null +++ b/application/account-management/Tests/ExternalAuthentication/ExternalAvatarClientTests.cs @@ -0,0 +1,174 @@ +using System.Net; +using System.Net.Http.Headers; +using FluentAssertions; +using NSubstitute; +using PlatformPlatform.AccountManagement.Integrations.OAuth; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.ExternalAuthentication; + +public sealed class ExternalAvatarClientTests +{ + [Theory] + [InlineData("https://lh3.googleusercontent.com/a/photo123")] + [InlineData("https://lh4.googleusercontent.com/a/photo456")] + [InlineData("https://lh6.googleusercontent.com/a/photo789")] + [InlineData("https://www.gravatar.com/avatar/abc123")] + public async Task DownloadAvatarAsync_WhenDomainIsAllowlisted_ShouldAttemptDownload(string avatarUrl) + { + // Arrange + var handler = new MockHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent([0x89, 0x50, 0x4E, 0x47]) + { + Headers = { ContentType = new MediaTypeHeaderValue("image/png") } + } + } + ); + var httpClient = new HttpClient(handler); + var logger = Substitute.For>(); + var client = new ExternalAvatarClient(httpClient, logger); + + // Act + var result = await client.DownloadAvatarAsync(avatarUrl, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.ContentType.Should().Be("image/png"); + handler.WasRequestSent.Should().BeTrue(); + } + + [Theory] + [InlineData("https://evil.com/avatar.png")] + [InlineData("https://notgoogleusercontent.com/a/photo")] + [InlineData("https://fakegravatar.com/avatar/abc")] + [InlineData("https://googleusercontent.com.evil.com/photo")] + [InlineData("https://169.254.169.254/metadata")] + [InlineData("https://internal-service.local/secret")] + public async Task DownloadAvatarAsync_WhenDomainIsNotAllowlisted_ShouldReturnNull(string avatarUrl) + { + // Arrange + var handler = new MockHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK)); + var httpClient = new HttpClient(handler); + var logger = Substitute.For>(); + var client = new ExternalAvatarClient(httpClient, logger); + + // Act + var result = await client.DownloadAvatarAsync(avatarUrl, CancellationToken.None); + + // Assert + result.Should().BeNull(); + handler.WasRequestSent.Should().BeFalse(); + } + + [Theory] + [InlineData("http://lh3.googleusercontent.com/a/photo")] + [InlineData("ftp://lh3.googleusercontent.com/a/photo")] + public async Task DownloadAvatarAsync_WhenSchemeIsNotHttps_ShouldReturnNull(string avatarUrl) + { + // Arrange + var handler = new MockHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK)); + var httpClient = new HttpClient(handler); + var logger = Substitute.For>(); + var client = new ExternalAvatarClient(httpClient, logger); + + // Act + var result = await client.DownloadAvatarAsync(avatarUrl, CancellationToken.None); + + // Assert + result.Should().BeNull(); + handler.WasRequestSent.Should().BeFalse(); + } + + [Theory] + [InlineData("not-a-url")] + [InlineData("")] + [InlineData("/relative/path")] + public async Task DownloadAvatarAsync_WhenUrlIsMalformed_ShouldReturnNull(string avatarUrl) + { + // Arrange + var handler = new MockHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK)); + var httpClient = new HttpClient(handler); + var logger = Substitute.For>(); + var client = new ExternalAvatarClient(httpClient, logger); + + // Act + var result = await client.DownloadAvatarAsync(avatarUrl, CancellationToken.None); + + // Assert + result.Should().BeNull(); + handler.WasRequestSent.Should().BeFalse(); + } + + [Fact] + public async Task DownloadAvatarAsync_WhenContentLengthExceedsLimit_ShouldReturnNull() + { + // Arrange + var content = new ByteArrayContent([0x89, 0x50, 0x4E, 0x47]); + content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + content.Headers.ContentLength = 2 * 1024 * 1024; + var handler = new MockHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + var httpClient = new HttpClient(handler); + var logger = Substitute.For>(); + var client = new ExternalAvatarClient(httpClient, logger); + + // Act + var result = await client.DownloadAvatarAsync("https://lh3.googleusercontent.com/a/photo", CancellationToken.None); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task DownloadAvatarAsync_WhenBodyExceedsLimitWithoutContentLength_ShouldReturnNull() + { + // Arrange + var oversizedBody = new byte[2 * 1024 * 1024]; + var content = new ByteArrayContent(oversizedBody); + content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + content.Headers.ContentLength = null; + var handler = new MockHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + var httpClient = new HttpClient(handler); + var logger = Substitute.For>(); + var client = new ExternalAvatarClient(httpClient, logger); + + // Act + var result = await client.DownloadAvatarAsync("https://lh3.googleusercontent.com/a/photo", CancellationToken.None); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task DownloadAvatarAsync_WhenBodyIsWithinLimit_ShouldReturnAvatar() + { + // Arrange + var imageData = new byte[512 * 1024]; + var content = new ByteArrayContent(imageData); + content.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); + content.Headers.ContentLength = null; + var handler = new MockHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + var httpClient = new HttpClient(handler); + var logger = Substitute.For>(); + var client = new ExternalAvatarClient(httpClient, logger); + + // Act + var result = await client.DownloadAvatarAsync("https://lh3.googleusercontent.com/a/photo", CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.ContentType.Should().Be("image/jpeg"); + result.Stream.Length.Should().Be(512 * 1024); + } + + private sealed class MockHttpMessageHandler(HttpResponseMessage response) : HttpMessageHandler + { + public bool WasRequestSent { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + WasRequestSent = true; + return Task.FromResult(response); + } + } +} diff --git a/application/account-management/Tests/ExternalAuthentication/GoogleOAuthAtHashTests.cs b/application/account-management/Tests/ExternalAuthentication/GoogleOAuthAtHashTests.cs new file mode 100644 index 000000000..0a669e4ef --- /dev/null +++ b/application/account-management/Tests/ExternalAuthentication/GoogleOAuthAtHashTests.cs @@ -0,0 +1,90 @@ +using System.Security.Cryptography; +using System.Text; +using FluentAssertions; +using Microsoft.IdentityModel.Tokens; +using PlatformPlatform.AccountManagement.Integrations.OAuth.Google; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.ExternalAuthentication; + +public sealed class GoogleOAuthAtHashTests +{ + [Fact] + public void ComputeAtHash_WhenRS256_ShouldReturnCorrectHash() + { + // Arrange + var accessToken = "test-access-token-value"; + var expectedHash = ComputeExpectedAtHash(accessToken, SHA256.Create()); + + // Act + var result = GoogleOAuthProvider.ComputeAtHash(accessToken, "RS256"); + + // Assert + result.Should().Be(expectedHash); + } + + [Fact] + public void ComputeAtHash_WhenRS384_ShouldReturnCorrectHash() + { + // Arrange + var accessToken = "test-access-token-value"; + var expectedHash = ComputeExpectedAtHash(accessToken, SHA384.Create()); + + // Act + var result = GoogleOAuthProvider.ComputeAtHash(accessToken, "RS384"); + + // Assert + result.Should().Be(expectedHash); + } + + [Fact] + public void ComputeAtHash_WhenRS512_ShouldReturnCorrectHash() + { + // Arrange + var accessToken = "test-access-token-value"; + var expectedHash = ComputeExpectedAtHash(accessToken, SHA512.Create()); + + // Act + var result = GoogleOAuthProvider.ComputeAtHash(accessToken, "RS512"); + + // Assert + result.Should().Be(expectedHash); + } + + [Fact] + public void ComputeAtHash_WhenUnsupportedAlgorithm_ShouldReturnNull() + { + // Act + var result = GoogleOAuthProvider.ComputeAtHash("test-token", "ES256"); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void ComputeAtHash_WhenKnownAccessToken_ShouldMatchExpectedValue() + { + // Arrange + var accessToken = "ya29.a0AfH6SMBx"; + using var sha256 = SHA256.Create(); + var hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(accessToken)); + var leftHalf = hash[..(hash.Length / 2)]; + var expected = Base64UrlEncoder.Encode(leftHalf); + + // Act + var result = GoogleOAuthProvider.ComputeAtHash(accessToken, "RS256"); + + // Assert + result.Should().Be(expected); + } + + private static string ComputeExpectedAtHash(string accessToken, HashAlgorithm hashAlgorithm) + { + using (hashAlgorithm) + { + var hash = hashAlgorithm.ComputeHash(Encoding.ASCII.GetBytes(accessToken)); + var leftHalf = hash[..(hash.Length / 2)]; + return Base64UrlEncoder.Encode(leftHalf); + } + } +} diff --git a/application/account-management/Tests/ExternalAuthentication/MockOAuthProviderEnforcementTests.cs b/application/account-management/Tests/ExternalAuthentication/MockOAuthProviderEnforcementTests.cs new file mode 100644 index 000000000..ac186b25e --- /dev/null +++ b/application/account-management/Tests/ExternalAuthentication/MockOAuthProviderEnforcementTests.cs @@ -0,0 +1,113 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using PlatformPlatform.AccountManagement.Integrations.OAuth; +using PlatformPlatform.AccountManagement.Integrations.OAuth.Mock; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.ExternalAuthentication; + +public sealed class MockOAuthProviderEnforcementTests +{ + [Fact] + public void MockEmail_ShouldEndWithMockLocalhostDomain() + { + // Assert + MockOAuthProvider.MockEmail.Should().EndWith(OAuthProviderFactory.MockEmailDomain); + } + + [Fact] + public void ShouldUseMockProvider_WhenMockProviderDisabled_ShouldReturnFalse() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["OAuth:AllowMockProvider"] = "false" }) + .Build(); + var factory = new OAuthProviderFactory(new ServiceCollection().BuildServiceProvider(), configuration); + var httpContext = new DefaultHttpContext(); + + // Act + var result = factory.ShouldUseMockProvider(httpContext); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void ShouldUseMockProvider_WhenMockProviderEnabledButNoCookie_ShouldReturnFalse() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["OAuth:AllowMockProvider"] = "true" }) + .Build(); + var factory = new OAuthProviderFactory(new ServiceCollection().BuildServiceProvider(), configuration); + var httpContext = new DefaultHttpContext(); + + // Act + var result = factory.ShouldUseMockProvider(httpContext); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void ShouldUseMockProvider_WhenMockProviderEnabledWithCookie_ShouldReturnTrue() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["OAuth:AllowMockProvider"] = "true" }) + .Build(); + var factory = new OAuthProviderFactory(new ServiceCollection().BuildServiceProvider(), configuration); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers.Append("Cookie", $"{OAuthProviderFactory.UseMockProviderCookieName}=true"); + + // Act + var result = factory.ShouldUseMockProvider(httpContext); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public async Task GetUserProfileAsync_ShouldAlwaysReturnMockLocalhostEmail() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["OAuth:AllowMockProvider"] = "true" }) + .Build(); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers.Append("Cookie", $"{OAuthProviderFactory.UseMockProviderCookieName}=true"); + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + var mockProvider = new MockOAuthProvider(configuration, httpContextAccessor); + var tokenResponse = new OAuthTokenResponse("mock-access-token", "mock-id-token:test-nonce", 3600); + + // Act + var profile = await mockProvider.GetUserProfileAsync(tokenResponse, CancellationToken.None); + + // Assert + profile.Should().NotBeNull(); + profile.Email.Should().EndWith(OAuthProviderFactory.MockEmailDomain); + } + + [Fact] + public async Task GetUserProfileAsync_WhenCustomEmailPrefix_ShouldReturnMockLocalhostEmail() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["OAuth:AllowMockProvider"] = "true" }) + .Build(); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers.Append("Cookie", $"{OAuthProviderFactory.UseMockProviderCookieName}=customuser"); + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + var mockProvider = new MockOAuthProvider(configuration, httpContextAccessor); + var tokenResponse = new OAuthTokenResponse("mock-access-token", "mock-id-token:test-nonce", 3600); + + // Act + var profile = await mockProvider.GetUserProfileAsync(tokenResponse, CancellationToken.None); + + // Assert + profile.Should().NotBeNull(); + profile.Email.Should().Be($"customuser{OAuthProviderFactory.MockEmailDomain}"); + } +} diff --git a/application/account-management/Tests/ExternalAuthentication/StartExternalLoginTests.cs b/application/account-management/Tests/ExternalAuthentication/StartExternalLoginTests.cs new file mode 100644 index 000000000..cd81349ea --- /dev/null +++ b/application/account-management/Tests/ExternalAuthentication/StartExternalLoginTests.cs @@ -0,0 +1,56 @@ +using System.Net; +using FluentAssertions; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; +using PlatformPlatform.SharedKernel.Tests.Persistence; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.ExternalAuthentication; + +public sealed class StartExternalLoginTests : ExternalAuthenticationTestBase +{ + [Fact] + public async Task StartExternalLogin_WhenValidProvider_ShouldRedirectToAuthorizationUrl() + { + // Act + var response = await NoRedirectHttpClient.GetAsync( + "/api/account-management/authentication/Google/login/start?returnPath=%2Fdashboard&locale=en-US" + ); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + var location = response.Headers.Location!.ToString(); + location.Should().Contain("/api/account-management/authentication/Google/login/callback"); + location.Should().Contain("code=mock-authorization-code"); + location.Should().Contain("state="); + + var externalLoginId = GetExternalLoginIdFromResponse(response); + Connection.RowExists("ExternalLogins", externalLoginId).Should().BeTrue(); + + var loginType = Connection.ExecuteScalar( + "SELECT Type FROM ExternalLogins WHERE Id = @id", [new { id = externalLoginId }] + ); + loginType.Should().Be(nameof(ExternalLoginType.Login)); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalLoginStarted"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task StartExternalLogin_WhenNullReturnPathAndLocale_ShouldRedirectToAuthorizationUrl() + { + // Act + var response = await NoRedirectHttpClient.GetAsync( + "/api/account-management/authentication/Google/login/start" + ); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + var location = response.Headers.Location!.ToString(); + location.Should().Contain("code=mock-authorization-code"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalLoginStarted"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } +} diff --git a/application/account-management/Tests/ExternalAuthentication/StartExternalSignupTests.cs b/application/account-management/Tests/ExternalAuthentication/StartExternalSignupTests.cs new file mode 100644 index 000000000..edb853326 --- /dev/null +++ b/application/account-management/Tests/ExternalAuthentication/StartExternalSignupTests.cs @@ -0,0 +1,56 @@ +using System.Net; +using FluentAssertions; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; +using PlatformPlatform.SharedKernel.Tests.Persistence; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.ExternalAuthentication; + +public sealed class StartExternalSignupTests : ExternalAuthenticationTestBase +{ + [Fact] + public async Task StartExternalSignup_WhenValidProvider_ShouldRedirectToAuthorizationUrl() + { + // Act + var response = await NoRedirectHttpClient.GetAsync( + "/api/account-management/authentication/Google/signup/start?returnPath=%2Fonboarding&locale=en-US" + ); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + var location = response.Headers.Location!.ToString(); + location.Should().Contain("/api/account-management/authentication/Google/signup/callback"); + location.Should().Contain("code=mock-authorization-code"); + location.Should().Contain("state="); + + var externalLoginId = GetExternalLoginIdFromResponse(response); + Connection.RowExists("ExternalLogins", externalLoginId).Should().BeTrue(); + + var loginType = Connection.ExecuteScalar( + "SELECT Type FROM ExternalLogins WHERE Id = @id", [new { id = externalLoginId }] + ); + loginType.Should().Be(nameof(ExternalLoginType.Signup)); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalSignupStarted"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task StartExternalSignup_WhenNullReturnPathAndLocale_ShouldRedirectToAuthorizationUrl() + { + // Act + var response = await NoRedirectHttpClient.GetAsync( + "/api/account-management/authentication/Google/signup/start" + ); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + var location = response.Headers.Location!.ToString(); + location.Should().Contain("code=mock-authorization-code"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("ExternalSignupStarted"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } +} diff --git a/application/account-management/WebApp/tests/e2e/google-oauth-flows.spec.ts b/application/account-management/WebApp/tests/e2e/google-oauth-flows.spec.ts new file mode 100644 index 000000000..98fa9f80e --- /dev/null +++ b/application/account-management/WebApp/tests/e2e/google-oauth-flows.spec.ts @@ -0,0 +1,323 @@ +import { faker } from "@faker-js/faker"; +import { expect, type Page } from "@playwright/test"; +import { test } from "@shared/e2e/fixtures/page-auth"; +import { createTestContext } from "@shared/e2e/utils/test-assertions"; +import { step } from "@shared/e2e/utils/test-step-wrapper"; + +const MOCK_PROVIDER_COOKIE = "__Test_Use_Mock_Provider"; + +async function setMockProviderCookie(page: Page, value: string): Promise { + await page.context().addCookies([ + { + name: MOCK_PROVIDER_COOKIE, + value: value, + url: "https://localhost:9000" + } + ]); +} + +test.describe("@smoke", () => { + /** + * Tests Google OAuth authentication flows including: + * 1. Signup with Google OAuth to create new tenant and user + * 2. Logout and verify redirect to login page + * 3. Login with Google OAuth and verify authentication + * 4. Verify user profile shows correct email + * 5. Logout via menu and verify redirect + * 6. Attempt signup as existing user - verify account already exists error page + * 7. Navigate to login from error page and complete login + * + * Note: Uses mock OAuth provider with unique email per test run to avoid conflicts. + */ + test("should handle Google OAuth signup, login, and existing user signup redirect flow", async ({ page }) => { + const context = createTestContext(page); + const emailPrefix = faker.string.alphanumeric(10); + const mockUserEmail = `${emailPrefix}@mock.localhost`; + + // === SIGNUP: Create mock user via Google OAuth === + + await step("Navigate to signup page & sign up with Google OAuth")(async () => { + await page.goto("/signup"); + + await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); + await setMockProviderCookie(page, emailPrefix); + await page.getByRole("button", { name: "Sign up with Google" }).click(); + + await expect(page).toHaveURL("/"); + await expect(page.getByRole("button", { name: "User profile menu" })).toBeVisible(); + })(); + + await step("Open user profile menu & log out")(async () => { + context.monitoring.expectedStatusCodes.push(401); + await page.getByRole("button", { name: "User profile menu" }).dispatchEvent("click"); + const menu = page.getByRole("menu"); + await expect(menu).toBeVisible(); + const logoutMenuItem = page.getByRole("menuitem", { name: "Log out" }); + await expect(logoutMenuItem).toBeVisible(); + await logoutMenuItem.dispatchEvent("click"); + + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + })(); + + // === LOGIN: Verify Google OAuth login works === + + await step("Click Google login button & verify successful authentication")(async () => { + await setMockProviderCookie(page, emailPrefix); + await page.getByRole("button", { name: "Log in with Google" }).click(); + + await expect(page).toHaveURL("/"); + await expect(page.getByRole("button", { name: "User profile menu" })).toBeVisible(); + })(); + + await step("Open user profile menu & verify mock user email displays")(async () => { + await page.getByRole("button", { name: "User profile menu" }).dispatchEvent("click"); + const menu = page.getByRole("menu"); + await expect(menu).toBeVisible(); + + await expect(page.getByText(mockUserEmail)).toBeVisible(); + })(); + + await step("Log out via menu & verify redirect to login page")(async () => { + context.monitoring.expectedStatusCodes.push(401); + const menu = page.getByRole("menu"); + await expect(menu).toBeVisible(); + const logoutMenuItem = page.getByRole("menuitem", { name: "Log out" }); + await expect(logoutMenuItem).toBeVisible(); + await logoutMenuItem.dispatchEvent("click"); + + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + })(); + + // === EXISTING USER SIGNUP: Verify error page with login redirect === + + await step("Navigate to signup page & attempt Google signup as existing user")(async () => { + await page.goto("/signup"); + + await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); + await setMockProviderCookie(page, emailPrefix); + await page.getByRole("button", { name: "Sign up with Google" }).click(); + + await expect(page.getByRole("heading", { name: "Account already exists" })).toBeVisible(); + await expect(page.getByText("An account with this email already exists.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Log in" })).toBeVisible(); + })(); + + await step("Click log in button from error page & login with Google")(async () => { + await page.getByRole("button", { name: "Log in" }).click(); + + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + await setMockProviderCookie(page, emailPrefix); + await page.getByRole("button", { name: "Log in with Google" }).click(); + + await expect(page).toHaveURL("/"); + await expect(page.getByRole("button", { name: "User profile menu" })).toBeVisible(); + })(); + }); +}); + +test.describe("@comprehensive", () => { + /** + * Tests Google OAuth error paths, preferred tenant selection, and error page rendering including: + * 1. Preferred tenant - signup, extract tenant ID, set localStorage, re-login and verify PreferredTenantId passed + * 2. Access denied - user cancels OAuth consent, verify error page + * 3. Token exchange failure - mock provider returns null tokens, verify error page + * 4. Email not verified - mock provider returns unverified email, verify error page + * 5. User not found on login - login with unknown email, verify error page with signup action + * 6. Direct error page rendering for each OAuth error code with reference ID display + */ + test("should handle preferred tenant selection and OAuth error paths with error page rendering", async ({ page }) => { + const context = createTestContext(page); + const emailPrefix = faker.string.alphanumeric(10); + const mockUserEmail = `${emailPrefix}@mock.localhost`; + + // === PREFERRED TENANT: Verify PreferredTenantId is passed during Google login === + + await step("Sign up with Google OAuth & create tenant")(async () => { + await page.goto("/signup"); + + await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); + await setMockProviderCookie(page, emailPrefix); + await page.getByRole("button", { name: "Sign up with Google" }).click(); + + await expect(page).toHaveURL("/"); + await expect(page.getByRole("button", { name: "User profile menu" })).toBeVisible(); + })(); + + let tenantId: string; + + await step("Extract tenant ID from user info & log out")(async () => { + tenantId = await page.evaluate(() => { + const metaTag = document.head.getElementsByTagName("meta").namedItem("userInfoEnv"); + if (!metaTag) { + return ""; + } + const content = JSON.parse(metaTag.content); + return content.tenantId || ""; + }); + expect(tenantId).toBeTruthy(); + + context.monitoring.expectedStatusCodes.push(401); + await page.getByRole("button", { name: "User profile menu" }).dispatchEvent("click"); + const menu = page.getByRole("menu"); + await expect(menu).toBeVisible(); + const logoutMenuItem = page.getByRole("menuitem", { name: "Log out" }); + await expect(logoutMenuItem).toBeVisible(); + await logoutMenuItem.dispatchEvent("click"); + + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + })(); + + await step("Set preferred tenant, enter email & login with Google OAuth verifying query parameter")(async () => { + await page.evaluate((tid) => localStorage.setItem("preferred-tenant", tid), tenantId); + + await page.getByLabel("Email").fill(mockUserEmail); + + let capturedUrl = ""; + await page.route("**/authentication/Google/login/start**", async (route) => { + capturedUrl = route.request().url(); + await route.continue(); + }); + + await setMockProviderCookie(page, emailPrefix); + await page.getByRole("button", { name: "Log in with Google" }).click(); + + await expect(page).toHaveURL("/"); + await expect(page.getByRole("button", { name: "User profile menu" })).toBeVisible(); + + expect(capturedUrl).toContain(`PreferredTenantId=${tenantId}`); + })(); + + await step("Log out to prepare for error path tests")(async () => { + context.monitoring.expectedStatusCodes.push(401); + await page.getByRole("button", { name: "User profile menu" }).dispatchEvent("click"); + const menu = page.getByRole("menu"); + await expect(menu).toBeVisible(); + const logoutMenuItem = page.getByRole("menuitem", { name: "Log out" }); + await expect(logoutMenuItem).toBeVisible(); + await logoutMenuItem.dispatchEvent("click"); + + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + })(); + + // === MOCK PROVIDER ERROR PATHS === + + await step("Navigate to login & trigger access denied error via mock provider")(async () => { + await page.goto("/login"); + + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + await setMockProviderCookie(page, "fail:access_denied"); + await page.getByRole("button", { name: "Log in with Google" }).click(); + + await expect(page.getByRole("heading", { name: "Access denied" })).toBeVisible(); + await expect(page.getByText("Authentication was cancelled or denied.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Log in" })).toBeVisible(); + })(); + + await step("Navigate to signup & trigger token exchange failure via mock provider")(async () => { + await page.goto("/signup"); + + await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); + await setMockProviderCookie(page, "fail:token_exchange"); + await page.getByRole("button", { name: "Sign up with Google" }).click(); + + await expect(page.getByRole("heading", { name: "Authentication failed" })).toBeVisible(); + await expect(page.getByText("We detected a security issue with your login attempt.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Log in" })).toBeVisible(); + })(); + + await step("Navigate to signup & trigger email not verified error via mock provider")(async () => { + await page.goto("/signup"); + + await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); + await setMockProviderCookie(page, "fail:email_not_verified"); + await page.getByRole("button", { name: "Sign up with Google" }).click(); + + await expect(page.getByRole("heading", { name: "Authentication failed" })).toBeVisible(); + await expect(page.getByText("We detected a security issue with your login attempt.")).toBeVisible(); + })(); + + await step("Navigate to login & trigger user not found error with unknown email")(async () => { + const unknownPrefix = faker.string.alphanumeric(10); + await page.goto("/login"); + + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + await setMockProviderCookie(page, unknownPrefix); + await page.getByRole("button", { name: "Log in with Google" }).click(); + + await expect(page.getByRole("heading", { name: "Account not found" })).toBeVisible(); + await expect(page.getByText("No account found for this email address.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Sign up" })).toBeVisible(); + })(); + + // === DIRECT ERROR PAGE RENDERING === + + await step("Navigate to user_not_found error page & verify content and reference ID")(async () => { + await page.goto("/error?error=user_not_found&id=test-ref-001"); + + await expect(page.getByRole("heading", { name: "Account not found" })).toBeVisible(); + await expect(page.getByText("No account found for this email address.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Sign up" })).toBeVisible(); + await expect(page.getByText("Reference ID: test-ref-001")).toBeVisible(); + })(); + + await step("Navigate to authentication_failed error page & verify content and reference ID")(async () => { + await page.goto("/error?error=authentication_failed&id=test-ref-002"); + + await expect(page.getByRole("heading", { name: "Authentication failed" })).toBeVisible(); + await expect(page.getByText("We detected a security issue with your login attempt.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Log in" })).toBeVisible(); + await expect(page.getByText("Reference ID: test-ref-002")).toBeVisible(); + })(); + + await step("Navigate to access_denied error page & verify content and reference ID")(async () => { + await page.goto("/error?error=access_denied&id=test-ref-003"); + + await expect(page.getByRole("heading", { name: "Access denied" })).toBeVisible(); + await expect(page.getByText("Authentication was cancelled or denied.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Log in" })).toBeVisible(); + await expect(page.getByText("Reference ID: test-ref-003")).toBeVisible(); + })(); + + await step("Navigate to invalid_request error page & verify content")(async () => { + await page.goto("/error?error=invalid_request&id=test-ref-004"); + + await expect(page.getByRole("heading", { name: "Invalid request" })).toBeVisible(); + await expect(page.getByText("The authentication request was invalid.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Log in" })).toBeVisible(); + await expect(page.getByText("Reference ID: test-ref-004")).toBeVisible(); + })(); + + await step("Navigate to identity_mismatch error page & verify content with back to login button")(async () => { + await page.goto("/error?error=identity_mismatch&id=test-ref-005"); + + await expect(page.getByRole("heading", { name: "Identity mismatch" })).toBeVisible(); + await expect(page.getByText("This account is linked to a different Google identity.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Log in" })).toBeVisible(); + await expect(page.getByText("Reference ID: test-ref-005")).toBeVisible(); + })(); + + await step("Navigate to session_expired error page & verify content")(async () => { + await page.goto("/error?error=session_expired&id=test-ref-006"); + + await expect(page.getByRole("heading", { name: "Session expired" })).toBeVisible(); + await expect(page.getByText("Your session has expired.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Log in" })).toBeVisible(); + await expect(page.getByText("Reference ID: test-ref-006")).toBeVisible(); + })(); + + await step("Navigate to unknown error code & verify fallback error page")(async () => { + await page.goto("/error?error=some_unknown_error&id=test-ref-007"); + + await expect(page.getByRole("heading", { name: "Something went wrong" })).toBeVisible(); + await expect(page.getByText("An unexpected error occurred.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Log in" })).toBeVisible(); + await expect(page.getByText("Reference ID: test-ref-007")).toBeVisible(); + })(); + + await step("Navigate to error page without error param & verify redirect to login")(async () => { + await page.goto("/error"); + + await expect(page).toHaveURL("/login"); + })(); + }); +}); From 6aeaf6790860c162838c58339be831e7a7bb23ca Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 10 Feb 2026 03:20:15 +0100 Subject: [PATCH 11/13] Add Azure infrastructure for Google OAuth --- application/AppHost/Program.cs | 8 +++++++ application/Directory.Packages.props | 1 + .../SharedInfrastructureConfiguration.cs | 21 +++++++++++++++++++ .../SharedKernel/SharedKernel.csproj | 1 + .../cluster/deploy-cluster.sh | 2 ++ .../cluster/main-cluster.bicep | 17 +++++++++++++++ .../cluster/main-cluster.bicepparam | 2 ++ .../modules/key-vault-secrets.bicep | 18 ++++++++++++++++ 8 files changed, 70 insertions(+) create mode 100644 cloud-infrastructure/modules/key-vault-secrets.bicep diff --git a/application/AppHost/Program.cs b/application/AppHost/Program.cs index d36310cd6..20aa1541d 100644 --- a/application/AppHost/Program.cs +++ b/application/AppHost/Program.cs @@ -14,6 +14,11 @@ SecretManagerHelper.GenerateAuthenticationTokenSigningKey("authentication-token-signing-key"); +var googleOAuthClientId = builder.AddParameter("google-oauth-client-id", true) + .WithDescription("Google OAuth Client ID from [Google Cloud Console](https://console.cloud.google.com/apis/credentials). See README.md for setup instructions. Enter `not-configured` to skip Google OAuth.", true); +var googleOAuthClientSecret = builder.AddParameter("google-oauth-client-secret", true) + .WithDescription("Google OAuth Client Secret from [Google Cloud Console](https://console.cloud.google.com/apis/credentials). See README.md for setup instructions. Enter `not-configured` to skip Google OAuth.", true); + var sqlPassword = builder.CreateStablePassword("sql-server-password"); var sqlServer = builder.AddSqlServer("sql-server", sqlPassword, 9002) .WithDataVolume("platform-platform-sql-server-data") @@ -65,6 +70,9 @@ .WithUrlConfiguration("/account-management") .WithReference(accountManagementDatabase) .WithReference(azureStorage) + .WithEnvironment("OAuth__Google__ClientId", googleOAuthClientId) + .WithEnvironment("OAuth__Google__ClientSecret", googleOAuthClientSecret) + .WithEnvironment("OAuth__AllowMockProvider", "true") .WaitFor(accountManagementWorkers); var backOfficeDatabase = sqlServer diff --git a/application/Directory.Packages.props b/application/Directory.Packages.props index 68cd23085..30251a19d 100644 --- a/application/Directory.Packages.props +++ b/application/Directory.Packages.props @@ -14,6 +14,7 @@ + diff --git a/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs b/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs index 94a302364..9733b994e 100644 --- a/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs +++ b/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs @@ -1,5 +1,7 @@ +using Azure.Extensions.AspNetCore.Configuration.Secrets; using Azure.Identity; using Azure.Monitor.OpenTelemetry.AspNetCore; +using Azure.Security.KeyVault.Secrets; using Azure.Storage.Blobs; using Microsoft.ApplicationInsights.AspNetCore.Extensions; using Microsoft.ApplicationInsights.Extensibility; @@ -37,6 +39,7 @@ public IHostApplicationBuilder AddSharedInfrastructure(string connectionName) where T : DbContext { builder + .AddAzureKeyVaultConfiguration() .ConfigureDatabaseContext(connectionName) .AddDefaultBlobStorage() .AddConfigureOpenTelemetry() @@ -57,6 +60,24 @@ public IHostApplicationBuilder AddSharedInfrastructure(string connectionName) extension(IHostApplicationBuilder builder) { + private IHostApplicationBuilder AddAzureKeyVaultConfiguration() + { + if (IsRunningInAzure) + { + var keyVaultUri = new Uri(Environment.GetEnvironmentVariable("KEYVAULT_URL")!); + var secretClient = new SecretClient(keyVaultUri, DefaultAzureCredential); + + builder.Configuration.AddAzureKeyVault(secretClient, new AzureKeyVaultConfigurationOptions + { + Manager = new KeyVaultSecretManager(), + ReloadInterval = TimeSpan.FromMinutes(1) + } + ); + } + + return builder; + } + private IHostApplicationBuilder ConfigureDatabaseContext(string connectionName) where T : DbContext { diff --git a/application/shared-kernel/SharedKernel/SharedKernel.csproj b/application/shared-kernel/SharedKernel/SharedKernel.csproj index 039bfe031..c661b6a8b 100644 --- a/application/shared-kernel/SharedKernel/SharedKernel.csproj +++ b/application/shared-kernel/SharedKernel/SharedKernel.csproj @@ -19,6 +19,7 @@ + diff --git a/cloud-infrastructure/cluster/deploy-cluster.sh b/cloud-infrastructure/cluster/deploy-cluster.sh index c96495d1c..610dc28b6 100755 --- a/cloud-infrastructure/cluster/deploy-cluster.sh +++ b/cloud-infrastructure/cluster/deploy-cluster.sh @@ -30,6 +30,8 @@ export ENVIRONMENT export LOCATION=$CLUSTER_LOCATION export DOMAIN_NAME export SQL_ADMIN_OBJECT_ID +export GOOGLE_OAUTH_CLIENT_ID +export GOOGLE_OAUTH_CLIENT_SECRET export CONTAINER_REGISTRY_NAME=$UNIQUE_PREFIX$ENVIRONMENT export GLOBAL_RESOURCE_GROUP_NAME="$UNIQUE_PREFIX-$ENVIRONMENT-global" diff --git a/cloud-infrastructure/cluster/main-cluster.bicep b/cloud-infrastructure/cluster/main-cluster.bicep index 17425901b..e18fca983 100644 --- a/cloud-infrastructure/cluster/main-cluster.bicep +++ b/cloud-infrastructure/cluster/main-cluster.bicep @@ -15,6 +15,11 @@ param communicationServicesDataLocation string = 'europe' param mailSenderDisplayName string = 'PlatformPlatform' param revisionSuffix string +@secure() +param googleOAuthClientId string +@secure() +param googleOAuthClientSecret string + var storageAccountUniquePrefix = replace(clusterResourceGroupName, '-', '') var tags = { environment: environment, 'managed-by': 'bicep' } @@ -93,6 +98,18 @@ module keyVault '../modules/key-vault.bicep' = { dependsOn: [virtualNetwork] } +module googleOAuthSecrets '../modules/key-vault-secrets.bicep' = if (!empty(googleOAuthClientId) && !empty(googleOAuthClientSecret)) { + scope: clusterResourceGroup + name: '${clusterResourceGroupName}-google-oauth-secrets' + params: { + keyVaultName: keyVault.outputs.name + secrets: { + 'OAuth--Google--ClientId': googleOAuthClientId + 'OAuth--Google--ClientSecret': googleOAuthClientSecret + } + } +} + module communicationService '../modules/communication-services.bicep' = { scope: clusterResourceGroup name: '${clusterResourceGroupName}-communication-services' diff --git a/cloud-infrastructure/cluster/main-cluster.bicepparam b/cloud-infrastructure/cluster/main-cluster.bicepparam index c727ffd2a..5f7844ca3 100644 --- a/cloud-infrastructure/cluster/main-cluster.bicepparam +++ b/cloud-infrastructure/cluster/main-cluster.bicepparam @@ -13,3 +13,5 @@ param accountManagementVersion = readEnvironmentVariable('ACCOUNT_MANAGEMENT_VER param backOfficeVersion = readEnvironmentVariable('BACK_OFFICE_VERSION') param applicationInsightsConnectionString = readEnvironmentVariable('APPLICATIONINSIGHTS_CONNECTION_STRING') param revisionSuffix = readEnvironmentVariable('REVISION_SUFFIX') +param googleOAuthClientId = readEnvironmentVariable('GOOGLE_OAUTH_CLIENT_ID', '') +param googleOAuthClientSecret = readEnvironmentVariable('GOOGLE_OAUTH_CLIENT_SECRET', '') diff --git a/cloud-infrastructure/modules/key-vault-secrets.bicep b/cloud-infrastructure/modules/key-vault-secrets.bicep new file mode 100644 index 000000000..42532cce3 --- /dev/null +++ b/cloud-infrastructure/modules/key-vault-secrets.bicep @@ -0,0 +1,18 @@ +param keyVaultName string + +@secure() +param secrets object + +resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { + name: keyVaultName +} + +resource keyVaultSecrets 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = [ + for secret in items(secrets): { + parent: keyVault + name: secret.key + properties: { + value: secret.value + } + } +] From 38c67ecba32cd40f306204053b8326474bbfa4b3 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 10 Feb 2026 03:20:49 +0100 Subject: [PATCH 12/13] Add developer CLI command for GitHub config --- .github/workflows/_deploy-infrastructure.yml | 6 + developer-cli/Commands/GithubConfigCommand.cs | 235 ++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 developer-cli/Commands/GithubConfigCommand.cs diff --git a/.github/workflows/_deploy-infrastructure.yml b/.github/workflows/_deploy-infrastructure.yml index 52cd14ce6..1105c73e0 100644 --- a/.github/workflows/_deploy-infrastructure.yml +++ b/.github/workflows/_deploy-infrastructure.yml @@ -78,6 +78,9 @@ jobs: - name: Plan Cluster Resources id: deploy_cluster + env: + GOOGLE_OAUTH_CLIENT_ID: ${{ vars.GOOGLE_OAUTH_CLIENT_ID }} + GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }} run: bash ./cloud-infrastructure/cluster/deploy-cluster.sh ${{ inputs.unique_prefix }} ${{ inputs.azure_environment }} ${{ inputs.cluster_location }} ${{ inputs.cluster_location_acronym }} ${{ inputs.sql_admin_object_id }} ${{ inputs.domain_name }} --plan - name: Show DNS Configuration @@ -137,6 +140,9 @@ jobs: - name: Deploy Cluster Resources id: deploy_cluster + env: + GOOGLE_OAUTH_CLIENT_ID: ${{ vars.GOOGLE_OAUTH_CLIENT_ID }} + GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }} run: bash ./cloud-infrastructure/cluster/deploy-cluster.sh ${{ inputs.unique_prefix }} ${{ inputs.azure_environment }} ${{ inputs.cluster_location }} ${{ inputs.cluster_location_acronym }} ${{ inputs.sql_admin_object_id }} ${{ inputs.domain_name }} --apply - name: Refresh Azure Tokens # The previous step may take a while, so we refresh the token to avoid timeouts diff --git a/developer-cli/Commands/GithubConfigCommand.cs b/developer-cli/Commands/GithubConfigCommand.cs new file mode 100644 index 000000000..e88f64abb --- /dev/null +++ b/developer-cli/Commands/GithubConfigCommand.cs @@ -0,0 +1,235 @@ +using System.CommandLine; +using PlatformPlatform.DeveloperCli.Installation; +using PlatformPlatform.DeveloperCli.Utilities; +using Spectre.Console; + +namespace PlatformPlatform.DeveloperCli.Commands; + +public sealed class GithubConfigCommand : Command +{ + private static readonly Dictionary Configurations = new() + { + ["GOOGLE_OAUTH_CLIENT_ID"] = new GithubConfig( + "Google OAuth Client ID from Google Cloud Console", + GithubScope.Environment, + GithubType.Variable, + "123456789012-abcdefghijklmnopqrstuvwxyz012345.apps.googleusercontent.com", + "Google OAuth" + ), + ["GOOGLE_OAUTH_CLIENT_SECRET"] = new GithubConfig( + "Google OAuth Client Secret from Google Cloud Console", + GithubScope.Environment, + GithubType.Secret, + "GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxxx", + "Google OAuth" + ) + }; + + public GithubConfigCommand() : base("github-config", "Configure GitHub repository variables and secrets for external integrations like Google OAuth") + { + SetAction(_ => Execute()); + } + + private static void Execute() + { + Prerequisite.Ensure(Prerequisite.GithubCli); + + var githubUri = GithubHelper.GetGithubUri(); + var githubInfo = GithubHelper.GetGithubInfo(githubUri); + + if (!IsLoggedInToGitHub()) + { + AnsiConsole.MarkupLine("[yellow]You need to be logged in to GitHub CLI.[/]"); + ProcessHelper.StartProcess("gh auth login --git-protocol https --web"); + + if (!IsLoggedInToGitHub()) + { + AnsiConsole.MarkupLine("[red]Failed to log in to GitHub. Please try again.[/]"); + Environment.Exit(1); + } + } + + AnsiConsole.MarkupLine($"[blue]GitHub Configuration for {githubInfo.Path}[/]"); + AnsiConsole.WriteLine(); + + var groups = Configurations.GroupBy(c => c.Value.Group).ToList(); + + var groupChoices = groups.Select(g => g.Key).Concat(["Exit"]).ToArray(); + var selectedGroup = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select configuration group:") + .AddChoices(groupChoices) + ); + + if (selectedGroup == "Exit") + { + return; + } + + AnsiConsole.WriteLine(); + + var configurationsInGroup = Configurations.Where(c => c.Value.Group == selectedGroup).ToList(); + var pendingChanges = new List(); + + foreach (var (name, config) in configurationsInGroup) + { + AnsiConsole.MarkupLine($"[bold]{name}[/]"); + AnsiConsole.MarkupLine($"[dim]Type:[/] {config.GithubType}"); + AnsiConsole.MarkupLine($"[dim]Scope:[/] {config.GithubScope}"); + AnsiConsole.MarkupLine($"[dim]Description:[/] {config.Description}"); + AnsiConsole.MarkupLine($"[dim]Example:[/] [blue]{config.ExampleValue}[/]"); + AnsiConsole.WriteLine(); + + if (!AnsiConsole.Confirm($"Configure {name}?")) + { + AnsiConsole.WriteLine(); + continue; + } + + string? environment = null; + if (config.GithubScope == GithubScope.Environment) + { + var scopeChoice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select scope:") + .AddChoices("Staging environment", "Production environment", "Both environments", "Repository level") + ); + + environment = scopeChoice switch + { + "Staging environment" => "staging", + "Production environment" => "production", + "Both environments" => "both", + _ => null + }; + } + + var valuePrompt = config.GithubType == GithubType.Secret + ? new TextPrompt($"Enter value for {name}:").Secret() + : new TextPrompt($"Enter value for {name}:"); + + var value = AnsiConsole.Prompt(valuePrompt); + + if (string.IsNullOrWhiteSpace(value)) + { + AnsiConsole.MarkupLine($"[yellow]Skipping {name} (empty value).[/]"); + AnsiConsole.WriteLine(); + continue; + } + + pendingChanges.Add(new PendingChange(name, config.GithubType, environment, value)); + AnsiConsole.WriteLine(); + } + + if (pendingChanges.Count == 0) + { + AnsiConsole.MarkupLine("[dim]No changes to apply.[/]"); + return; + } + + PrintPendingChanges(pendingChanges); + + if (!AnsiConsole.Confirm("Apply these changes?")) + { + AnsiConsole.MarkupLine("[dim]Changes cancelled.[/]"); + return; + } + + AnsiConsole.WriteLine(); + ApplyChanges(githubInfo, pendingChanges); + PrintSummary(githubInfo); + } + + private static bool IsLoggedInToGitHub() + { + var result = ProcessHelper.StartProcess("gh auth status", redirectOutput: true, exitOnError: false); + return result.Contains("Logged in to github.com"); + } + + private static void PrintPendingChanges(List pendingChanges) + { + AnsiConsole.MarkupLine("[bold]Pending changes:[/]"); + AnsiConsole.WriteLine(); + + foreach (var change in pendingChanges) + { + var typeLabel = change.Type == GithubType.Variable ? "Variable" : "Secret"; + var scopeLabel = change.Environment switch + { + "staging" => "staging environment", + "production" => "production environment", + "both" => "both environments", + _ => "repository level" + }; + var valueDisplay = change.Type == GithubType.Secret ? "[dim](hidden)[/]" : $"[blue]{change.Value}[/]"; + + AnsiConsole.MarkupLine($" [green]>[/] {change.Name} ({typeLabel}, {scopeLabel}): {valueDisplay}"); + } + + AnsiConsole.WriteLine(); + } + + private static void ApplyChanges(GithubInfo githubInfo, List pendingChanges) + { + foreach (var change in pendingChanges) + { + var environments = change.Environment switch + { + "both" => new[] { "staging", "production" }, + null => new string?[] { null }, + _ => new[] { change.Environment } + }; + + foreach (var env in environments) + { + if (change.Type == GithubType.Variable) + { + SetGithubVariable(githubInfo, change.Name, change.Value, env); + } + else + { + SetGithubSecret(githubInfo, change.Name, change.Value, env); + } + + var scopeLabel = env is not null ? $" ({env})" : ""; + AnsiConsole.MarkupLine($"[green]Set {change.Name}{scopeLabel}[/]"); + } + } + + AnsiConsole.WriteLine(); + } + + private static void SetGithubVariable(GithubInfo githubInfo, string name, string value, string? environment) + { + var envFlag = environment is not null ? $" --env {environment}" : ""; + ProcessHelper.StartProcess($"gh variable set {name} -b\"{value}\" --repo={githubInfo.Path}{envFlag}"); + } + + private static void SetGithubSecret(GithubInfo githubInfo, string name, string value, string? environment) + { + var envFlag = environment is not null ? $" --env {environment}" : ""; + ProcessHelper.StartProcess($"gh secret set {name} -b\"{value}\" --repo={githubInfo.Path}{envFlag}"); + } + + private static void PrintSummary(GithubInfo githubInfo) + { + AnsiConsole.MarkupLine("[green]GitHub configuration complete![/]"); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[yellow]Tip:[/] View your configuration at [blue]{githubInfo.Url}/settings/secrets/actions[/]"); + } + + private record GithubConfig(string Description, GithubScope GithubScope, GithubType GithubType, string ExampleValue, string Group); + + private record PendingChange(string Name, GithubType Type, string? Environment, string Value); + + private enum GithubScope + { + Environment + } + + private enum GithubType + { + Variable, + Secret + } +} From 122f1b8d59df807dab48a5d6d3fb371b6c747962 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 10 Feb 2026 03:21:04 +0100 Subject: [PATCH 13/13] Update README with Google authentication documentation --- README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/README.md b/README.md index 485b2d4b8..8a01c115e 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,32 @@ Once the Aspire dashboard fully loads, click to the WebApp and sign up for a new Getting Started +### (Optional) Set up Google OAuth for "Sign in with Google" + +PlatformPlatform supports authentication via Google OAuth. This is optional for local development since email-based one-time passwords work without any configuration. When running locally without Google OAuth credentials configured, the Aspire dashboard prompts for parameters -- enter `not-configured` to skip and start Aspire without Google OAuth. + +
+ +Google Cloud Console setup + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project (e.g., "YourProduct OAuth") +3. Navigate to **APIs & Services** > **Credentials** +4. Configure OAuth consent screen (first time only): + - App name, support email, audience (External), contact info + - Agree to Google API Services: User Data Policy +5. Create OAuth client ID: + - Application type: "Web application" + - Name: "YourProduct Localhost" +6. Add Authorized redirect URIs: + - `https://localhost:9000/api/account-management/authentication/Google/login/callback` + - `https://localhost:9000/api/account-management/authentication/Google/signup/callback` +7. Note the Client ID and Client Secret + +
+ +**Aspire parameter configuration**: Click **Parameters** in the Aspire dashboard and enter your Google OAuth Client ID and Client Secret. These values are stored securely in .NET user secrets and persist across restarts. + ## 4. Set up CI/CD with passwordless deployments from GitHub to Azure Run this command to automate Azure Subscription configuration and set up [GitHub Workflows](https://github.com/platformplatform/PlatformPlatform/actions) for deploying [Azure Infrastructure](./cloud-infrastructure) (using Bicep) and compiling [application code](./application) to Docker images deployed to Azure Container Apps: @@ -219,6 +245,20 @@ The infrastructure is configured with auto-scaling and hosting costs in focus. I ![Azure Costs](https://platformplatformgithub.blob.core.windows.net/$root/azure-costs-center.png) +### (Optional) Configure Google OAuth for staging and production + +If you set up Google OAuth locally, use the Developer CLI to store your Google OAuth credentials as GitHub secrets for deployment to Azure Key Vault: + +```bash +pp github-config +``` + +Remember to add redirect URIs for each environment in your Google Cloud Console configuration, e.g.: +- `https://staging.yourproduct.com/api/account-management/authentication/Google/login/callback` +- `https://staging.yourproduct.com/api/account-management/authentication/Google/signup/callback` +- `https://app.yourproduct.com/api/account-management/authentication/Google/login/callback` +- `https://app.yourproduct.com/api/account-management/authentication/Google/signup/callback` + # Experimental: Agentic Workflow with Claude Code PlatformPlatform includes a multi-agent autonomous development workflow powered by [Claude Code](https://claude.com/product/claude-code). Nine specialized AI agents collaborate to deliver complete features, from requirements to production-ready code, while enforcing enterprise-grade quality standards.