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/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. 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/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/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/ExternalAuthenticationEndpoints.cs b/application/account-management/Api/Endpoints/ExternalAuthenticationEndpoints.cs new file mode 100644 index 000000000..8329ca8fc --- /dev/null +++ b/application/account-management/Api/Endpoints/ExternalAuthenticationEndpoints.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Mvc; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Commands; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; +using PlatformPlatform.SharedKernel.ApiResults; +using PlatformPlatform.SharedKernel.Endpoints; + +namespace PlatformPlatform.AccountManagement.Api.Endpoints; + +public sealed class ExternalAuthenticationEndpoints : IEndpoints +{ + private const string RoutesPrefix = "/api/account-management/authentication"; + + public void MapEndpoints(IEndpointRouteBuilder routes) + { + var group = routes.MapGroup(RoutesPrefix).WithTags("ExternalAuthentication").RequireAuthorization().ProducesValidationProblem(); + + group.MapGet("/{provider}/login/start", async Task> (ExternalProviderType provider, [AsParameters] StartExternalLoginCommand command, IMediator mediator) + => await mediator.Send(command with { ProviderType = provider }) + ).AllowAnonymous(); + + group.MapGet("/{provider}/login/callback", async Task> (ExternalProviderType provider, string? code, string? state, string? error, [FromQuery(Name = "error_description")] string? errorDescription, IMediator mediator) + => await mediator.Send(new CompleteExternalLoginCommand(code, state, error, errorDescription) { Provider = provider.ToString() }) + ).AllowAnonymous(); + + group.MapGet("/{provider}/signup/start", async Task> (ExternalProviderType provider, IMediator mediator) + => await mediator.Send(new StartExternalSignupCommand { ProviderType = provider }) + ).AllowAnonymous(); + + group.MapGet("/{provider}/signup/callback", async Task> (ExternalProviderType provider, string? code, string? state, string? error, [FromQuery(Name = "error_description")] string? errorDescription, IMediator mediator) + => await mediator.Send(new CompleteExternalSignupCommand(code, state, error, errorDescription) { Provider = provider.ToString() }) + ).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..e2c42da59 100644 --- a/application/account-management/Core/Configuration.cs +++ b/application/account-management/Core/Configuration.cs @@ -1,9 +1,16 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using PlatformPlatform.AccountManagement.Database; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Shared; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Shared; using PlatformPlatform.AccountManagement.Features.Users.Shared; using PlatformPlatform.AccountManagement.Integrations.Gravatar; +using PlatformPlatform.AccountManagement.Integrations.OAuth; +using PlatformPlatform.AccountManagement.Integrations.OAuth.Google; +using PlatformPlatform.AccountManagement.Integrations.OAuth.Mock; using PlatformPlatform.SharedKernel.Configuration; +using PlatformPlatform.SharedKernel.OpenIdConnect; namespace PlatformPlatform.AccountManagement; @@ -33,10 +40,24 @@ public IServiceCollection AddAccountManagementServices() } ); + services.AddHttpClient(client => { client.Timeout = TimeSpan.FromSeconds(10); }); + services.AddSingleton(); + + services.AddHttpClient(client => { client.Timeout = TimeSpan.FromSeconds(10); }); + + services.AddHttpClient(client => { client.Timeout = TimeSpan.FromSeconds(10); }); + services.AddKeyedScoped("google"); + services.AddKeyedScoped("mock-google"); + services.AddScoped(); + return services .AddSharedServices([Assembly]) + .AddScoped() + .AddScoped() .AddScoped() - .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/Database/Migrations/20260210103500_AddExternalLogins.cs b/application/account-management/Core/Database/Migrations/20260210103500_AddExternalLogins.cs new file mode 100644 index 000000000..ff26fe524 --- /dev/null +++ b/application/account-management/Core/Database/Migrations/20260210103500_AddExternalLogins.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace PlatformPlatform.AccountManagement.Database.Migrations; + +[DbContext(typeof(AccountManagementDbContext))] +[Migration("20260210103500_AddExternalLogins")] +public sealed class AddExternalLogins : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + "ExternalLogins", + table => new + { + Id = table.Column("varchar(32)", nullable: false), + CreatedAt = table.Column("datetimeoffset", nullable: false), + ModifiedAt = table.Column("datetimeoffset", nullable: true), + ProviderType = table.Column("varchar(20)", nullable: false), + Type = table.Column("varchar(20)", nullable: false), + CodeVerifier = table.Column("char(128)", nullable: false), + Nonce = table.Column("char(43)", nullable: false), + BrowserFingerprint = table.Column("char(64)", nullable: false), + LoginResult = table.Column("varchar(30)", nullable: true) + }, + constraints: table => { table.PrimaryKey("PK_ExternalLogins", x => x.Id); } + ); + + migrationBuilder.AddColumn("ExternalIdentities", "Users", "nvarchar(max)", nullable: false, defaultValue: "[]"); + } +} 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..89ef390cc 100644 --- a/application/account-management/Core/Features/Authentication/Domain/SessionTypes.cs +++ b/application/account-management/Core/Features/Authentication/Domain/SessionTypes.cs @@ -12,6 +12,14 @@ public enum DeviceType Tablet } +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum LoginMethod +{ + OneTimePassword, + Google +} + /// /// 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/ExternalAuthentication/Commands/CompleteExternalLogin.cs b/application/account-management/Core/Features/ExternalAuthentication/Commands/CompleteExternalLogin.cs new file mode 100644 index 000000000..e88814033 --- /dev/null +++ b/application/account-management/Core/Features/ExternalAuthentication/Commands/CompleteExternalLogin.cs @@ -0,0 +1,147 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; +using PlatformPlatform.AccountManagement.Features.Authentication.Domain; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Shared; +using PlatformPlatform.AccountManagement.Features.Users.Domain; +using PlatformPlatform.AccountManagement.Features.Users.Shared; +using PlatformPlatform.AccountManagement.Integrations.OAuth; +using PlatformPlatform.SharedKernel.Authentication.TokenGeneration; +using PlatformPlatform.SharedKernel.Cqrs; +using PlatformPlatform.SharedKernel.ExecutionContext; +using PlatformPlatform.SharedKernel.OpenIdConnect; +using PlatformPlatform.SharedKernel.Telemetry; + +namespace PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Commands; + +[PublicAPI] +public sealed record CompleteExternalLoginCommand(string? Code, string? State, string? Error, string? ErrorDescription) + : ICommand, IRequest> +{ + [JsonIgnore] + public string? Provider { get; init; } +} + +public sealed class CompleteExternalLoginHandler( + IExternalLoginRepository externalLoginRepository, + IUserRepository userRepository, + ISessionRepository sessionRepository, + UserInfoFactory userInfoFactory, + AuthenticationTokenService authenticationTokenService, + AvatarUpdater avatarUpdater, + ExternalAvatarClient externalAvatarClient, + ExternalAuthenticationHelper externalAuthenticationHelper, + ExternalAuthenticationService externalAuthenticationService, + IHttpContextAccessor httpContextAccessor, + IExecutionContext executionContext, + ITelemetryEventsCollector events, + TimeProvider timeProvider, + ILogger logger +) : IRequestHandler> +{ + public async Task> Handle(CompleteExternalLoginCommand command, CancellationToken cancellationToken) + { + try + { + var validationResult = await externalAuthenticationHelper.ValidateCallback( + command.Code, command.State, command.Error, command.ErrorDescription, ExternalLoginType.Login, cancellationToken + ); + + if (!validationResult.IsSuccess) return validationResult.ErrorResult!; + + var externalLogin = validationResult.ExternalLogin; + var externalLoginCookie = validationResult.Cookie; + var userProfile = validationResult.UserProfile!; + + var usersWithEmail = await userRepository.GetUsersByEmailUnfilteredAsync(userProfile.Email, cancellationToken); + if (usersWithEmail.Length == 0) + { + logger.LogWarning("User not found for external login '{ExternalLoginId}'", externalLogin.Id); + return LoginFailedRedirect(externalLogin, ExternalLoginResult.UserNotFound); + } + + var user = externalLoginCookie.PreferredTenantId is not null + ? usersWithEmail.SingleOrDefault(u => u.TenantId == externalLoginCookie.PreferredTenantId) ?? usersWithEmail[0] + : usersWithEmail[0]; + + var existingIdentity = user.GetExternalIdentity(externalLogin.ProviderType); + if (existingIdentity is not null && existingIdentity.ProviderUserId != userProfile.ProviderUserId) + { + logger.LogWarning("Identity mismatch for user '{UserId}' with provider '{ProviderType}'", user.Id, externalLogin.ProviderType); + return LoginFailedRedirect(externalLogin, ExternalLoginResult.IdentityMismatch); + } + + if (existingIdentity is null) + { + user.AddExternalIdentity(externalLogin.ProviderType, userProfile.ProviderUserId); + userRepository.Update(user); + } + + if (!user.EmailConfirmed) + { + user.ConfirmEmail(); + userRepository.Update(user); + } + + if (user.FirstName is null && user.LastName is null && (userProfile.FirstName is not null || userProfile.LastName is not null)) + { + user.Update(userProfile.FirstName ?? string.Empty, userProfile.LastName ?? string.Empty, user.Title ?? string.Empty); + userRepository.Update(user); + } + + if (userProfile.AvatarUrl is not null && user.Avatar.Url is null) + { + var externalAvatar = await externalAvatarClient.DownloadAvatarAsync(userProfile.AvatarUrl, cancellationToken); + if (externalAvatar is not null) + { + await avatarUpdater.UpdateAvatar(user, false, externalAvatar.ContentType, externalAvatar.Stream, cancellationToken); + } + } + + externalLogin.MarkCompleted(); + externalLoginRepository.Update(externalLogin); + + var httpContext = httpContextAccessor.HttpContext!; + var userAgent = httpContext.Request.Headers.UserAgent.ToString(); + var loginMethod = ExternalAuthenticationService.GetLoginMethod(externalLogin.ProviderType); + var ipAddress = executionContext.ClientIpAddress; + var session = Session.Create(user.TenantId, user.Id, loginMethod, userAgent, ipAddress); + await sessionRepository.AddAsync(session, cancellationToken); + + user.UpdateLastSeen(timeProvider.GetUtcNow()); + userRepository.Update(user); + + var userInfo = await userInfoFactory.CreateUserInfoAsync(user, session.Id, cancellationToken); + authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo, session.Id, session.RefreshTokenJti); + + events.CollectEvent(new SessionCreated(session.Id)); + var loginTimeInSeconds = (int)(timeProvider.GetUtcNow() - externalLogin.CreatedAt).TotalSeconds; + events.CollectEvent(new ExternalLoginCompleted(user.Id, externalLogin.ProviderType, loginTimeInSeconds)); + + var returnPath = ReturnPathHelper.GetReturnPathCookie(httpContext) ?? "/"; + ReturnPathHelper.ClearReturnPathCookie(httpContext); + + return Result.Redirect(returnPath); + } + finally + { + externalAuthenticationService.ClearExternalLoginCookie(); + externalAuthenticationService.ClearLocaleCookie(); + } + } + + private Result LoginFailedRedirect(ExternalLogin externalLogin, ExternalLoginResult loginResult) + { + var timeInSeconds = (int)(timeProvider.GetUtcNow() - externalLogin.CreatedAt).TotalSeconds; + if (!externalLogin.IsConsumed) + { + externalLogin.MarkFailed(loginResult); + externalLoginRepository.Update(externalLogin); + } + + events.CollectEvent(new ExternalLoginFailed(externalLogin.Id, loginResult, timeInSeconds)); + + var oidcError = ExternalAuthenticationService.MapToOidcError(loginResult); + return Result.Redirect($"/error?error={oidcError}&id={externalLogin.Id}"); + } +} diff --git a/application/account-management/Core/Features/ExternalAuthentication/Commands/CompleteExternalSignup.cs b/application/account-management/Core/Features/ExternalAuthentication/Commands/CompleteExternalSignup.cs new file mode 100644 index 000000000..36cfe732c --- /dev/null +++ b/application/account-management/Core/Features/ExternalAuthentication/Commands/CompleteExternalSignup.cs @@ -0,0 +1,144 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; +using PlatformPlatform.AccountManagement.Features.Authentication.Domain; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Shared; +using PlatformPlatform.AccountManagement.Features.Tenants.Commands; +using PlatformPlatform.AccountManagement.Features.Users.Domain; +using PlatformPlatform.AccountManagement.Features.Users.Shared; +using PlatformPlatform.AccountManagement.Integrations.OAuth; +using PlatformPlatform.SharedKernel.Authentication.TokenGeneration; +using PlatformPlatform.SharedKernel.Cqrs; +using PlatformPlatform.SharedKernel.ExecutionContext; +using PlatformPlatform.SharedKernel.OpenIdConnect; +using PlatformPlatform.SharedKernel.Telemetry; + +namespace PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Commands; + +[PublicAPI] +public sealed record CompleteExternalSignupCommand(string? Code, string? State, string? Error, string? ErrorDescription) + : ICommand, IRequest> +{ + [JsonIgnore] + public string? Provider { get; init; } +} + +public sealed class CompleteExternalSignupHandler( + IExternalLoginRepository externalLoginRepository, + IUserRepository userRepository, + ISessionRepository sessionRepository, + UserInfoFactory userInfoFactory, + AuthenticationTokenService authenticationTokenService, + AvatarUpdater avatarUpdater, + ExternalAvatarClient externalAvatarClient, + ExternalAuthenticationHelper externalAuthenticationHelper, + ExternalAuthenticationService externalAuthenticationService, + IHttpContextAccessor httpContextAccessor, + IExecutionContext executionContext, + IMediator mediator, + ITelemetryEventsCollector events, + TimeProvider timeProvider, + ILogger logger +) : IRequestHandler> +{ + public async Task> Handle(CompleteExternalSignupCommand command, CancellationToken cancellationToken) + { + try + { + var validationResult = await externalAuthenticationHelper.ValidateCallback( + command.Code, command.State, command.Error, command.ErrorDescription, ExternalLoginType.Signup, cancellationToken + ); + + if (!validationResult.IsSuccess) return validationResult.ErrorResult!; + + var externalLogin = validationResult.ExternalLogin; + var userProfile = validationResult.UserProfile!; + + var existingUser = await userRepository.GetUserByEmailUnfilteredAsync(userProfile.Email, cancellationToken); + if (existingUser is not null) + { + logger.LogWarning("User already exists for external login '{ExternalLoginId}'", externalLogin.Id); + return SignupFailedRedirect(externalLogin, ExternalLoginResult.AccountAlreadyExists); + } + + var locale = externalAuthenticationService.GetLocaleCookie() ?? userProfile.Locale; + + var createTenantResult = await mediator.Send(new CreateTenantCommand(userProfile.Email, true, locale), cancellationToken); + if (!createTenantResult.IsSuccess) + { + logger.LogWarning("Failed to create tenant for external signup '{ExternalLoginId}'", externalLogin.Id); + return SignupFailedRedirect(externalLogin, ExternalLoginResult.CodeExchangeFailed); + } + + var user = await userRepository.GetByIdAsync(createTenantResult.Value!.UserId, cancellationToken); + if (user is null) + { + logger.LogWarning("Failed to get user after tenant creation for external signup '{ExternalLoginId}'", externalLogin.Id); + return SignupFailedRedirect(externalLogin, ExternalLoginResult.CodeExchangeFailed); + } + + user.AddExternalIdentity(externalLogin.ProviderType, userProfile.ProviderUserId); + + if (userProfile.FirstName is not null || userProfile.LastName is not null) + { + user.Update(userProfile.FirstName ?? string.Empty, userProfile.LastName ?? string.Empty, string.Empty); + } + + if (userProfile.AvatarUrl is not null) + { + var externalAvatar = await externalAvatarClient.DownloadAvatarAsync(userProfile.AvatarUrl, cancellationToken); + if (externalAvatar is not null) + { + await avatarUpdater.UpdateAvatar(user, false, externalAvatar.ContentType, externalAvatar.Stream, cancellationToken); + } + } + + userRepository.Update(user); + + externalLogin.MarkCompleted(); + externalLoginRepository.Update(externalLogin); + + var httpContext = httpContextAccessor.HttpContext!; + var userAgent = httpContext.Request.Headers.UserAgent.ToString(); + var loginMethod = ExternalAuthenticationService.GetLoginMethod(externalLogin.ProviderType); + var ipAddress = executionContext.ClientIpAddress; + var session = Session.Create(user.TenantId, user.Id, loginMethod, userAgent, ipAddress); + await sessionRepository.AddAsync(session, cancellationToken); + + user.UpdateLastSeen(timeProvider.GetUtcNow()); + userRepository.Update(user); + + var userInfo = await userInfoFactory.CreateUserInfoAsync(user, session.Id, cancellationToken); + authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo, session.Id, session.RefreshTokenJti); + + events.CollectEvent(new SessionCreated(session.Id)); + var signupTimeInSeconds = (int)(timeProvider.GetUtcNow() - externalLogin.CreatedAt).TotalSeconds; + events.CollectEvent(new ExternalSignupCompleted(createTenantResult.Value.TenantId, externalLogin.ProviderType, signupTimeInSeconds)); + + var returnPath = ReturnPathHelper.GetReturnPathCookie(httpContext) ?? "/"; + ReturnPathHelper.ClearReturnPathCookie(httpContext); + + return Result.Redirect(returnPath); + } + finally + { + externalAuthenticationService.ClearExternalLoginCookie(); + externalAuthenticationService.ClearLocaleCookie(); + } + } + + private Result SignupFailedRedirect(ExternalLogin externalLogin, ExternalLoginResult loginResult) + { + var timeInSeconds = (int)(timeProvider.GetUtcNow() - externalLogin.CreatedAt).TotalSeconds; + if (!externalLogin.IsConsumed) + { + externalLogin.MarkFailed(loginResult); + externalLoginRepository.Update(externalLogin); + } + + events.CollectEvent(new ExternalSignupFailed(externalLogin.Id, loginResult, timeInSeconds)); + + var oidcError = ExternalAuthenticationService.MapToOidcError(loginResult); + return Result.Redirect($"/error?error={oidcError}&id={externalLogin.Id}"); + } +} diff --git a/application/account-management/Core/Features/ExternalAuthentication/Commands/StartExternalAuthentication.cs b/application/account-management/Core/Features/ExternalAuthentication/Commands/StartExternalAuthentication.cs new file mode 100644 index 000000000..2f89f8f0d --- /dev/null +++ b/application/account-management/Core/Features/ExternalAuthentication/Commands/StartExternalAuthentication.cs @@ -0,0 +1,117 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; +using PlatformPlatform.AccountManagement.Integrations.OAuth; +using PlatformPlatform.SharedKernel.Cqrs; +using PlatformPlatform.SharedKernel.Domain; +using PlatformPlatform.SharedKernel.OpenIdConnect; +using PlatformPlatform.SharedKernel.Telemetry; + +namespace PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Commands; + +[PublicAPI] +public sealed record StartExternalLoginCommand(TenantId? PreferredTenantId = null) : ICommand, IRequest> +{ + [JsonIgnore] + public ExternalProviderType ProviderType { get; init; } +} + +public sealed class StartExternalLoginHandler( + IExternalLoginRepository externalLoginRepository, + OAuthProviderFactory oauthProviderFactory, + ExternalAuthenticationService externalAuthenticationService, + IHttpContextAccessor httpContextAccessor, + ITelemetryEventsCollector events +) : IRequestHandler> +{ + public async Task> Handle(StartExternalLoginCommand command, CancellationToken cancellationToken) + { + return await StartExternalAuthenticationHelper.StartFlow( + command.ProviderType, ExternalLoginType.Login, command.PreferredTenantId, + externalLoginRepository, oauthProviderFactory, externalAuthenticationService, httpContextAccessor, events, cancellationToken + ); + } +} + +[PublicAPI] +public sealed record StartExternalSignupCommand : ICommand, IRequest> +{ + [JsonIgnore] + public ExternalProviderType ProviderType { get; init; } +} + +public sealed class StartExternalSignupHandler( + IExternalLoginRepository externalLoginRepository, + OAuthProviderFactory oauthProviderFactory, + ExternalAuthenticationService externalAuthenticationService, + IHttpContextAccessor httpContextAccessor, + ITelemetryEventsCollector events +) : IRequestHandler> +{ + public async Task> Handle(StartExternalSignupCommand command, CancellationToken cancellationToken) + { + return await StartExternalAuthenticationHelper.StartFlow( + command.ProviderType, ExternalLoginType.Signup, null, + externalLoginRepository, oauthProviderFactory, externalAuthenticationService, httpContextAccessor, events, cancellationToken + ); + } +} + +internal static class StartExternalAuthenticationHelper +{ + public static async Task> StartFlow( + ExternalProviderType providerType, + ExternalLoginType loginType, + TenantId? preferredTenantId, + IExternalLoginRepository externalLoginRepository, + OAuthProviderFactory oauthProviderFactory, + ExternalAuthenticationService externalAuthenticationService, + IHttpContextAccessor httpContextAccessor, + ITelemetryEventsCollector events, + CancellationToken cancellationToken + ) + { + var httpContext = httpContextAccessor.HttpContext!; + var useMockProvider = oauthProviderFactory.ShouldUseMockProvider(httpContext); + + var oauthProvider = oauthProviderFactory.GetProvider(providerType, useMockProvider); + if (oauthProvider is null) + { + return Result.BadRequest($"Provider '{providerType}' is not configured."); + } + + var codeVerifier = PkceUtilities.GenerateCodeVerifier(); + var codeChallenge = PkceUtilities.GenerateCodeChallenge(codeVerifier); + var nonce = NonceUtilities.GenerateNonce(); + + var browserFingerprint = externalAuthenticationService.GenerateBrowserFingerprintHash(); + + var externalLogin = ExternalLogin.Create(providerType, loginType, codeVerifier, nonce, browserFingerprint); + await externalLoginRepository.AddAsync(externalLogin, cancellationToken); + + var stateToken = externalAuthenticationService.ProtectState(externalLogin.Id); + externalAuthenticationService.SetExternalLoginCookie(externalLogin.Id, preferredTenantId); + + var returnPath = httpContext.Request.Query["ReturnPath"].ToString(); + if (!string.IsNullOrEmpty(returnPath)) + { + ReturnPathHelper.SetReturnPathCookie(httpContext, returnPath); + } + + var locale = httpContext.Request.Query["Locale"].ToString(); + if (!string.IsNullOrEmpty(locale)) + { + externalAuthenticationService.SetLocaleCookie(locale); + } + + var redirectUri = ExternalAuthenticationService.GetRedirectUri(providerType, loginType); + var authorizationUrl = oauthProvider.BuildAuthorizationUrl(stateToken, codeChallenge, nonce, redirectUri); + + TelemetryEvent telemetryEvent = loginType == ExternalLoginType.Login + ? new ExternalLoginStarted(providerType) + : new ExternalSignupStarted(providerType); + events.CollectEvent(telemetryEvent); + + return Result.Redirect(authorizationUrl); + } +} diff --git a/application/account-management/Core/Features/ExternalAuthentication/Domain/ExternalAuthenticationTypes.cs b/application/account-management/Core/Features/ExternalAuthentication/Domain/ExternalAuthenticationTypes.cs new file mode 100644 index 000000000..c58700b30 --- /dev/null +++ b/application/account-management/Core/Features/ExternalAuthentication/Domain/ExternalAuthenticationTypes.cs @@ -0,0 +1,38 @@ +using JetBrains.Annotations; + +namespace PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ExternalProviderType +{ + Google +} + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ExternalLoginType +{ + Login, + Signup +} + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ExternalLoginResult +{ + Success, + IdentityProviderError, + InvalidState, + LoginReplayDetected, + SessionNotFound, + FlowIdMismatch, + SessionHijackingDetected, + LoginExpired, + LoginAlreadyCompleted, + CodeExchangeFailed, + NonceMismatch, + IdentityMismatch, + UserNotFound, + AccountAlreadyExists +} diff --git a/application/account-management/Core/Features/ExternalAuthentication/Domain/ExternalLogin.cs b/application/account-management/Core/Features/ExternalAuthentication/Domain/ExternalLogin.cs new file mode 100644 index 000000000..3236cd4e1 --- /dev/null +++ b/application/account-management/Core/Features/ExternalAuthentication/Domain/ExternalLogin.cs @@ -0,0 +1,99 @@ +using System.Security; +using JetBrains.Annotations; +using PlatformPlatform.SharedKernel.Domain; +using PlatformPlatform.SharedKernel.StronglyTypedIds; + +namespace PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; + +public sealed class ExternalLogin : AggregateRoot +{ + public const int ValidForSeconds = 300; + + private ExternalLogin( + ExternalProviderType providerType, + ExternalLoginType type, + string codeVerifier, + string nonce, + string browserFingerprint + ) + : base(ExternalLoginId.NewId()) + { + ProviderType = providerType; + Type = type; + CodeVerifier = codeVerifier; + Nonce = nonce; + BrowserFingerprint = browserFingerprint; + } + + public ExternalProviderType ProviderType { get; private init; } + + public ExternalLoginType Type { get; private init; } + + public string CodeVerifier { get; private init; } + + public string Nonce { get; private init; } + + // Stored for forensic analysis only; validation uses the cookie copy for CSRF binding + public string BrowserFingerprint { get; private init; } + + public ExternalLoginResult? LoginResult { get; private set; } + + public bool IsConsumed => LoginResult is not null; + + public bool IsExpired(DateTimeOffset now) + { + if (CreatedAt > now) + { + throw new SecurityException($"ExternalLogin '{Id}' has CreatedAt in the future. Possible data tampering."); + } + + return now > CreatedAt.AddSeconds(ValidForSeconds); + } + + public static ExternalLogin Create( + ExternalProviderType providerType, + ExternalLoginType type, + string codeVerifier, + string nonce, + string browserFingerprint + ) + { + return new ExternalLogin(providerType, type, codeVerifier, nonce, browserFingerprint); + } + + public void MarkCompleted() + { + if (LoginResult is not null) + { + throw new UnreachableException("The external login has already been completed."); + } + + LoginResult = ExternalLoginResult.Success; + } + + public void MarkFailed(ExternalLoginResult loginResult) + { + if (loginResult == ExternalLoginResult.Success) + { + throw new UnreachableException("Cannot mark a login as failed with a success result."); + } + + if (LoginResult is not null) + { + throw new UnreachableException("The external login has already been completed."); + } + + LoginResult = loginResult; + } +} + +[PublicAPI] +[IdPrefix("exlog")] +[JsonConverter(typeof(StronglyTypedIdJsonConverter))] +public sealed record ExternalLoginId(string Value) : StronglyTypedUlid(Value) +{ + public override string ToString() + { + return Value; + } +} diff --git a/application/account-management/Core/Features/ExternalAuthentication/Domain/ExternalLoginConfiguration.cs b/application/account-management/Core/Features/ExternalAuthentication/Domain/ExternalLoginConfiguration.cs new file mode 100644 index 000000000..a619a5fab --- /dev/null +++ b/application/account-management/Core/Features/ExternalAuthentication/Domain/ExternalLoginConfiguration.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PlatformPlatform.SharedKernel.EntityFramework; + +namespace PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; + +public sealed class ExternalLoginConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ExternalLogins"); + builder.MapStronglyTypedId(el => el.Id); + } +} diff --git a/application/account-management/Core/Features/ExternalAuthentication/Domain/ExternalLoginRepository.cs b/application/account-management/Core/Features/ExternalAuthentication/Domain/ExternalLoginRepository.cs new file mode 100644 index 000000000..ac11ea758 --- /dev/null +++ b/application/account-management/Core/Features/ExternalAuthentication/Domain/ExternalLoginRepository.cs @@ -0,0 +1,13 @@ +using PlatformPlatform.AccountManagement.Database; +using PlatformPlatform.SharedKernel.Domain; +using PlatformPlatform.SharedKernel.Persistence; + +namespace PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; + +public interface IExternalLoginRepository : IAppendRepository +{ + void Update(ExternalLogin aggregate); +} + +public sealed class ExternalLoginRepository(AccountManagementDbContext accountManagementDbContext) + : RepositoryBase(accountManagementDbContext), IExternalLoginRepository; diff --git a/application/account-management/Core/Features/ExternalAuthentication/ExternalAuthenticationService.cs b/application/account-management/Core/Features/ExternalAuthentication/ExternalAuthenticationService.cs new file mode 100644 index 000000000..878191814 --- /dev/null +++ b/application/account-management/Core/Features/ExternalAuthentication/ExternalAuthenticationService.cs @@ -0,0 +1,195 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using PlatformPlatform.AccountManagement.Features.Authentication.Domain; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; +using PlatformPlatform.AccountManagement.Integrations.OAuth; +using PlatformPlatform.SharedKernel.Domain; +using PlatformPlatform.SharedKernel.SinglePageApp; + +namespace PlatformPlatform.AccountManagement.Features.ExternalAuthentication; + +public sealed record ExternalLoginCookie(ExternalLoginId ExternalLoginId, string FingerprintHash, TenantId? PreferredTenantId); + +public sealed class ExternalAuthenticationService(IHttpContextAccessor httpContextAccessor, IDataProtectionProvider dataProtectionProvider, OAuthProviderFactory oauthProviderFactory, ILogger logger) +{ + private const string DataProtectionPurpose = "ExternalLogin"; + private const string ExternalLoginCookieName = "__Host-external-login"; + private const string LocaleCookieName = "__Host-external-login-locale"; + + private static readonly string PublicUrl = Environment.GetEnvironmentVariable(SinglePageAppConfiguration.PublicUrlKey) + ?? throw new InvalidOperationException($"'{SinglePageAppConfiguration.PublicUrlKey}' environment variable is not configured."); + + private readonly IDataProtector _dataProtector = dataProtectionProvider.CreateProtector(DataProtectionPurpose); + + public void SetExternalLoginCookie(ExternalLoginId externalLoginId, TenantId? preferredTenantId = null) + { + var fingerprintHash = GenerateBrowserFingerprintHash(); + var rawValue = preferredTenantId is not null + ? $"{externalLoginId}|{fingerprintHash}|{preferredTenantId}" + : $"{externalLoginId}|{fingerprintHash}"; + var cookieValue = _dataProtector.Protect(rawValue); + httpContextAccessor.HttpContext!.Response.Cookies.Append( + ExternalLoginCookieName, + cookieValue, + new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + Path = "/", + IsEssential = true, + MaxAge = TimeSpan.FromSeconds(ExternalLogin.ValidForSeconds) + } + ); + } + + public ExternalLoginCookie? GetExternalLoginCookie() + { + var cookieValue = httpContextAccessor.HttpContext?.Request.Cookies[ExternalLoginCookieName]; + if (string.IsNullOrEmpty(cookieValue)) return null; + + try + { + var decryptedValue = _dataProtector.Unprotect(cookieValue); + + var parts = decryptedValue.Split('|'); + if (parts.Length is not (2 or 3)) return null; + + if (!ExternalLoginId.TryParse(parts[0], out var externalLoginId)) return null; + + var preferredTenantId = parts.Length == 3 && TenantId.TryParse(parts[2], out var parsedTenantId) + ? parsedTenantId + : null; + + return new ExternalLoginCookie(externalLoginId, parts[1], preferredTenantId); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to decrypt external login cookie"); + return null; + } + } + + public void ClearExternalLoginCookie() + { + httpContextAccessor.HttpContext!.Response.Cookies.Delete(ExternalLoginCookieName, new CookieOptions { Secure = true }); + } + + public void SetLocaleCookie(string locale) + { + httpContextAccessor.HttpContext!.Response.Cookies.Append( + LocaleCookieName, + locale, + new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + IsEssential = true, + MaxAge = TimeSpan.FromSeconds(ExternalLogin.ValidForSeconds) + } + ); + } + + public string? GetLocaleCookie() + { + return httpContextAccessor.HttpContext?.Request.Cookies[LocaleCookieName]; + } + + public void ClearLocaleCookie() + { + httpContextAccessor.HttpContext!.Response.Cookies.Delete(LocaleCookieName, new CookieOptions { Secure = true }); + } + + /// + /// Validates that the current browser fingerprint matches the one stored at login initiation. + /// This is a best-effort defense-in-depth measure, not a security boundary. It may detect + /// opportunistic session hijacking but will not stop determined attackers who can replay + /// the victim's User-Agent and Accept-Language headers. Primary security relies on PKCE, + /// nonce validation, state token encryption, and cookie binding. + /// + public bool ValidateBrowserFingerprint(string fingerprintHash) + { + if (oauthProviderFactory.ShouldUseMockProvider(httpContextAccessor.HttpContext!)) + { + return true; + } + + return GenerateBrowserFingerprintHash() == fingerprintHash; + } + + /// + /// Generates a SHA-256 hash of User-Agent and Accept-Language headers as a low-entropy + /// browser fingerprint. Known limitations: Chrome UA reduction means many browsers share + /// identical frozen UA strings, headers are trivially spoofable by an attacker who + /// intercepts the OAuth authorization code, and many users share identical combinations. + /// This serves as forensic/telemetry signal rather than an authorization boundary. + /// + public string GenerateBrowserFingerprintHash() + { + var httpContext = httpContextAccessor.HttpContext!; + var userAgent = httpContext.Request.Headers.UserAgent.ToString(); + var acceptLanguage = httpContext.Request.Headers.AcceptLanguage.ToString(); + var fingerprint = $"{userAgent}|{acceptLanguage}"; + return Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(fingerprint))); + } + + public string ProtectState(ExternalLoginId externalLoginId) + { + return _dataProtector.Protect(externalLoginId.ToString()); + } + + public ExternalLoginId? GetExternalLoginIdFromState(string? state) + { + if (string.IsNullOrEmpty(state)) return null; + + try + { + var decryptedState = _dataProtector.Unprotect(state); + return new ExternalLoginId(decryptedState); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to decrypt state"); + return null; + } + } + + public static string GetRedirectUri(ExternalProviderType providerType, ExternalLoginType loginType) + { + var loginTypeSegment = loginType == ExternalLoginType.Login ? "login" : "signup"; + return $"{PublicUrl}/api/account-management/authentication/{providerType}/{loginTypeSegment}/callback"; + } + + public static LoginMethod GetLoginMethod(ExternalProviderType providerType) + { + return providerType switch + { + ExternalProviderType.Google => LoginMethod.Google, + _ => throw new UnreachableException() + }; + } + + public static string MapToOidcError(ExternalLoginResult internalError) + { + return internalError switch + { + ExternalLoginResult.SessionHijackingDetected => "authentication_failed", + ExternalLoginResult.FlowIdMismatch => "authentication_failed", + ExternalLoginResult.LoginReplayDetected => "authentication_failed", + ExternalLoginResult.LoginAlreadyCompleted => "authentication_failed", + ExternalLoginResult.InvalidState => "invalid_request", + ExternalLoginResult.CodeExchangeFailed => "authentication_failed", + ExternalLoginResult.NonceMismatch => "authentication_failed", + ExternalLoginResult.SessionNotFound => "session_expired", + ExternalLoginResult.LoginExpired => "session_expired", + ExternalLoginResult.IdentityMismatch => "authentication_failed", + ExternalLoginResult.IdentityProviderError => "authentication_failed", + ExternalLoginResult.UserNotFound => "user_not_found", + ExternalLoginResult.AccountAlreadyExists => "account_already_exists", + _ => "server_error" + }; + } +} diff --git a/application/account-management/Core/Features/ExternalAuthentication/Shared/ExternalAuthenticationHelper.cs b/application/account-management/Core/Features/ExternalAuthentication/Shared/ExternalAuthenticationHelper.cs new file mode 100644 index 000000000..3d6f6e8a9 --- /dev/null +++ b/application/account-management/Core/Features/ExternalAuthentication/Shared/ExternalAuthenticationHelper.cs @@ -0,0 +1,213 @@ +using Microsoft.AspNetCore.Http; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; +using PlatformPlatform.AccountManagement.Integrations.OAuth; +using PlatformPlatform.SharedKernel.Cqrs; +using PlatformPlatform.SharedKernel.Telemetry; + +namespace PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Shared; + +internal sealed record CallbackValidationResult( + bool IsSuccess, + ExternalLogin ExternalLogin, + ExternalLoginCookie Cookie, + OAuthUserProfile? UserProfile, + Result? ErrorResult +) +{ + public static CallbackValidationResult Success(ExternalLogin externalLogin, ExternalLoginCookie cookie, OAuthUserProfile userProfile) + { + return new CallbackValidationResult(true, externalLogin, cookie, userProfile, null); + } + + public static CallbackValidationResult Failure(ExternalLogin externalLogin, ExternalLoginCookie cookie, Result errorResult) + { + return new CallbackValidationResult(false, externalLogin, cookie, null, errorResult); + } +} + +public sealed class ExternalAuthenticationHelper( + IExternalLoginRepository externalLoginRepository, + OAuthProviderFactory oauthProviderFactory, + ExternalAuthenticationService externalAuthenticationService, + IHttpContextAccessor httpContextAccessor, + ITelemetryEventsCollector events, + TimeProvider timeProvider, + ILogger logger +) +{ + internal async Task ValidateCallback( + string? code, + string? state, + string? error, + string? errorDescription, + ExternalLoginType loginType, + CancellationToken cancellationToken + ) + { + var externalLoginCookie = externalAuthenticationService.GetExternalLoginCookie(); + var externalLoginIdFromState = externalAuthenticationService.GetExternalLoginIdFromState(state); + + if (externalLoginIdFromState is null && externalLoginCookie is null) + { + logger.LogWarning("Missing state and cookie"); + return FailedRedirect(null!, externalLoginCookie!, ExternalLoginResult.InvalidState, loginType); + } + + Activity.Current?.SetTag("flow_id", externalLoginIdFromState?.ToString() ?? externalLoginCookie?.ExternalLoginId.ToString()); + + if (externalLoginIdFromState is null) + { + logger.LogWarning("Missing external login ID from state"); + return FailedRedirect(null!, externalLoginCookie!, ExternalLoginResult.InvalidState, loginType); + } + + if (externalLoginCookie is null) + { + logger.LogWarning("Replay detected for flow '{FlowId}' - session cookie missing", externalLoginIdFromState); + return FailedRedirect(null!, externalLoginCookie!, ExternalLoginResult.LoginReplayDetected, loginType); + } + + var externalLogin = await externalLoginRepository.GetByIdAsync(externalLoginIdFromState, cancellationToken); + if (externalLogin is null) + { + logger.LogWarning("Session not found for external login '{ExternalLoginId}'", externalLoginIdFromState); + return FailedRedirect(null!, externalLoginCookie, ExternalLoginResult.SessionNotFound, loginType); + } + + if (externalLoginIdFromState != externalLoginCookie.ExternalLoginId) + { + logger.LogWarning("Flow ID mismatch for external login '{ExternalLoginId}'", externalLoginIdFromState); + return FailedRedirect(externalLogin, externalLoginCookie, ExternalLoginResult.FlowIdMismatch, loginType); + } + + if (!externalAuthenticationService.ValidateBrowserFingerprint(externalLoginCookie.FingerprintHash)) + { + logger.LogWarning("Session hijacking detected for external login '{ExternalLoginId}'", externalLoginIdFromState); + return FailedRedirect(externalLogin, externalLoginCookie, ExternalLoginResult.SessionHijackingDetected, loginType); + } + + if (externalLogin.IsExpired(timeProvider.GetUtcNow())) + { + logger.LogWarning("Login expired for external login '{ExternalLoginId}'", externalLogin.Id); + return FailedRedirect(externalLogin, externalLoginCookie, ExternalLoginResult.LoginExpired, loginType); + } + + if (externalLogin.IsConsumed) + { + logger.LogWarning("Login already completed for external login '{ExternalLoginId}'", externalLoginIdFromState); + return FailedRedirect(externalLogin, externalLoginCookie, ExternalLoginResult.LoginAlreadyCompleted, loginType); + } + + if (!string.IsNullOrEmpty(error)) + { + logger.LogWarning("OAuth error received: '{Error}' - '{ErrorDescription}'", error, errorDescription); + return OAuthErrorRedirect(externalLogin, externalLoginCookie, error, loginType); + } + + if (string.IsNullOrEmpty(code)) + { + logger.LogWarning("Authorization code missing from OAuth callback"); + return FailedRedirect(externalLogin, externalLoginCookie, ExternalLoginResult.CodeExchangeFailed, loginType); + } + + var httpContext = httpContextAccessor.HttpContext!; + var useMockProvider = oauthProviderFactory.ShouldUseMockProvider(httpContext); + var oauthProvider = oauthProviderFactory.GetProvider(externalLogin.ProviderType, useMockProvider); + if (oauthProvider is null) + { + logger.LogWarning("Provider '{ProviderType}' not configured", externalLogin.ProviderType); + return FailedRedirect(externalLogin, externalLoginCookie, ExternalLoginResult.CodeExchangeFailed, loginType); + } + + var redirectUri = ExternalAuthenticationService.GetRedirectUri(externalLogin.ProviderType, loginType); + var tokenResponse = await oauthProvider.ExchangeCodeForTokensAsync(code, externalLogin.CodeVerifier, redirectUri, cancellationToken); + if (tokenResponse is null) + { + logger.LogWarning("Token exchange failed for external login '{ExternalLoginId}'", externalLogin.Id); + return FailedRedirect(externalLogin, externalLoginCookie, ExternalLoginResult.CodeExchangeFailed, loginType); + } + + var userProfile = await oauthProvider.GetUserProfileAsync(tokenResponse, cancellationToken); + if (userProfile is null) + { + logger.LogWarning("Failed to get user profile for external login '{ExternalLoginId}'", externalLogin.Id); + return FailedRedirect(externalLogin, externalLoginCookie, ExternalLoginResult.CodeExchangeFailed, loginType); + } + + if (!userProfile.EmailVerified) + { + logger.LogWarning("Email not verified for external login '{ExternalLoginId}'", externalLogin.Id); + return FailedRedirect(externalLogin, externalLoginCookie, ExternalLoginResult.CodeExchangeFailed, loginType); + } + + if (userProfile.Nonce != externalLogin.Nonce) + { + logger.LogWarning("Nonce mismatch for external login '{ExternalLoginId}'", externalLogin.Id); + return FailedRedirect(externalLogin, externalLoginCookie, ExternalLoginResult.NonceMismatch, loginType); + } + + return CallbackValidationResult.Success(externalLogin, externalLoginCookie, userProfile); + } + + private CallbackValidationResult FailedRedirect( + ExternalLogin? externalLogin, + ExternalLoginCookie cookie, + ExternalLoginResult loginResult, + ExternalLoginType loginType + ) + { + var timeInSeconds = 0; + + if (externalLogin is not null) + { + timeInSeconds = (int)(timeProvider.GetUtcNow() - externalLogin.CreatedAt).TotalSeconds; + if (!externalLogin.IsConsumed) + { + externalLogin.MarkFailed(loginResult); + externalLoginRepository.Update(externalLogin); + } + } + + CollectFailedEvent(loginType, externalLogin, loginResult, timeInSeconds, null); + + var oidcError = ExternalAuthenticationService.MapToOidcError(loginResult); + var referenceId = externalLogin?.Id.ToString() ?? Activity.Current?.TraceId.ToString(); + var redirectUrl = $"/error?error={oidcError}&id={referenceId}"; + + var errorResult = Result.Redirect(redirectUrl); + return CallbackValidationResult.Failure(externalLogin!, cookie, errorResult); + } + + private CallbackValidationResult OAuthErrorRedirect( + ExternalLogin externalLogin, + ExternalLoginCookie cookie, + string oauthError, + ExternalLoginType loginType + ) + { + var timeInSeconds = (int)(timeProvider.GetUtcNow() - externalLogin.CreatedAt).TotalSeconds; + if (!externalLogin.IsConsumed) + { + externalLogin.MarkFailed(ExternalLoginResult.IdentityProviderError); + externalLoginRepository.Update(externalLogin); + } + + CollectFailedEvent(loginType, externalLogin, ExternalLoginResult.IdentityProviderError, timeInSeconds, oauthError); + + var sanitizedError = Uri.EscapeDataString(oauthError); + var errorResult = Result.Redirect($"/error?error={sanitizedError}&id={externalLogin.Id}"); + return CallbackValidationResult.Failure(externalLogin, cookie, errorResult); + } + + private void CollectFailedEvent(ExternalLoginType loginType, ExternalLogin? externalLogin, ExternalLoginResult loginResult, int timeInSeconds, string? oauthError) + { + if (loginType == ExternalLoginType.Login) + { + events.CollectEvent(new ExternalLoginFailed(externalLogin?.Id, loginResult, timeInSeconds, oauthError)); + } + else + { + events.CollectEvent(new ExternalSignupFailed(externalLogin?.Id, loginResult, timeInSeconds, oauthError)); + } + } +} 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..e5c2f7a5d 100644 --- a/application/account-management/Core/Features/TelemetryEvents.cs +++ b/application/account-management/Core/Features/TelemetryEvents.cs @@ -1,5 +1,6 @@ using PlatformPlatform.AccountManagement.Features.Authentication.Domain; -using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; +using PlatformPlatform.AccountManagement.Features.EmailAuthentication.Domain; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; using PlatformPlatform.AccountManagement.Features.Tenants.Domain; using PlatformPlatform.AccountManagement.Features.Users.Domain; using PlatformPlatform.SharedKernel.Authentication.TokenGeneration; @@ -14,30 +15,48 @@ 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 ExternalLoginCompleted(UserId userId, ExternalProviderType providerType, int loginTimeInSeconds) + : TelemetryEvent(("user_id", userId), ("provider_type", providerType), ("login_time_in_seconds", loginTimeInSeconds)); + +public sealed class ExternalLoginFailed(ExternalLoginId? externalLoginId, ExternalLoginResult loginResult, int timeInSeconds, string? oauthError = null) + : TelemetryEvent(("external_login_id", externalLoginId as object ?? "unknown"), ("login_result", loginResult), ("time_in_seconds", timeInSeconds), ("oauth_error", oauthError as object ?? "none")); + +public sealed class ExternalLoginStarted(ExternalProviderType providerType) + : TelemetryEvent(("provider_type", providerType)); + +public sealed class ExternalSignupCompleted(TenantId tenantId, ExternalProviderType providerType, int signupTimeInSeconds) + : TelemetryEvent(("tenant_id", tenantId), ("provider_type", providerType), ("signup_time_in_seconds", signupTimeInSeconds)); + +public sealed class ExternalSignupFailed(ExternalLoginId? externalLoginId, ExternalLoginResult loginResult, int timeInSeconds, string? oauthError = null) + : TelemetryEvent(("external_login_id", externalLoginId as object ?? "unknown"), ("login_result", loginResult), ("time_in_seconds", timeInSeconds), ("oauth_error", oauthError as object ?? "none")); + +public sealed class ExternalSignupStarted(ExternalProviderType providerType) + : TelemetryEvent(("provider_type", providerType)); + +public sealed class GravatarUpdated(long size) + : TelemetryEvent(("size", size)); + public sealed class Logout : TelemetryEvent; @@ -86,9 +105,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 +128,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/Core/Features/Users/Domain/User.cs b/application/account-management/Core/Features/Users/Domain/User.cs index 4bc05b419..8a29f50b6 100644 --- a/application/account-management/Core/Features/Users/Domain/User.cs +++ b/application/account-management/Core/Features/Users/Domain/User.cs @@ -1,4 +1,6 @@ +using System.Collections.Immutable; using System.ComponentModel.DataAnnotations.Schema; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; using PlatformPlatform.SharedKernel.Domain; using PlatformPlatform.SharedKernel.Platform; @@ -15,6 +17,7 @@ private User(TenantId tenantId, string email, UserRole role, bool emailConfirmed EmailConfirmed = emailConfirmed; Locale = locale ?? string.Empty; Avatar = new Avatar(); + ExternalIdentities = []; } public string Email @@ -41,6 +44,8 @@ public string Email public DateTimeOffset? LastSeenAt { get; private set; } + public ImmutableArray ExternalIdentities { get; private set; } + public DateTimeOffset? DeletedAt { get; private set; } [NotMapped] @@ -109,6 +114,23 @@ public void UpdateLastSeen(DateTimeOffset lastSeenAt) { LastSeenAt = lastSeenAt; } + + public void AddExternalIdentity(ExternalProviderType provider, string providerUserId) + { + if (ExternalIdentities.Any(e => e.Provider == provider)) + { + throw new UnreachableException($"User already has an external identity for provider {provider}."); + } + + ExternalIdentities = ExternalIdentities.Add(new ExternalIdentity(provider, providerUserId)); + } + + public ExternalIdentity? GetExternalIdentity(ExternalProviderType provider) + { + return ExternalIdentities.FirstOrDefault(e => e.Provider == provider); + } } public sealed record Avatar(string? Url = null, int Version = 0, bool IsGravatar = false); + +public sealed record ExternalIdentity(ExternalProviderType Provider, string ProviderUserId); diff --git a/application/account-management/Core/Features/Users/Domain/UserConfiguration.cs b/application/account-management/Core/Features/Users/Domain/UserConfiguration.cs index 6296e5467..eeee1a696 100644 --- a/application/account-management/Core/Features/Users/Domain/UserConfiguration.cs +++ b/application/account-management/Core/Features/Users/Domain/UserConfiguration.cs @@ -1,3 +1,5 @@ +using System.Collections.Immutable; +using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using PlatformPlatform.AccountManagement.Features.Tenants.Domain; @@ -8,6 +10,8 @@ namespace PlatformPlatform.AccountManagement.Features.Users.Domain; public sealed class UserConfiguration : IEntityTypeConfiguration { + private static readonly JsonSerializerOptions JsonSerializerOptions = JsonSerializerOptions.Default; + public void Configure(EntityTypeBuilder builder) { builder.MapStronglyTypedUuid(u => u.Id); @@ -18,5 +22,12 @@ public void Configure(EntityTypeBuilder builder) .WithMany() .HasForeignKey(u => u.TenantId) .HasPrincipalKey(t => t.Id); + + builder.Property(u => u.ExternalIdentities) + .HasColumnName("ExternalIdentities") + .HasConversion( + v => JsonSerializer.Serialize(v.ToArray(), JsonSerializerOptions), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions) + ); } } diff --git a/application/account-management/Core/Integrations/OAuth/ExternalAvatarClient.cs b/application/account-management/Core/Integrations/OAuth/ExternalAvatarClient.cs new file mode 100644 index 000000000..4a9002e70 --- /dev/null +++ b/application/account-management/Core/Integrations/OAuth/ExternalAvatarClient.cs @@ -0,0 +1,77 @@ +namespace PlatformPlatform.AccountManagement.Integrations.OAuth; + +public sealed record ExternalAvatar(Stream Stream, string ContentType); + +public sealed class ExternalAvatarClient(HttpClient httpClient, ILogger logger) +{ + private const long MaxAvatarSizeInBytes = 1024 * 1024; + + private static readonly string[] AllowedDomainSuffixes = [".googleusercontent.com", ".gravatar.com"]; + + public async Task DownloadAvatarAsync(string avatarUrl, CancellationToken cancellationToken) + { + if (!IsAllowedDomain(avatarUrl)) + { + logger.LogWarning("Avatar URL '{AvatarUrl}' is not from an allowlisted domain, skipping download", avatarUrl); + return null; + } + + try + { + using var response = await httpClient.GetAsync(avatarUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode) + { + logger.LogWarning("Failed to download external avatar from '{AvatarUrl}', status '{StatusCode}'", avatarUrl, response.StatusCode); + return null; + } + + var contentType = response.Content.Headers.ContentType?.MediaType; + if (contentType is null || !contentType.StartsWith("image/", StringComparison.Ordinal)) + { + logger.LogWarning("External avatar from '{AvatarUrl}' has unexpected content type '{ContentType}'", avatarUrl, contentType); + return null; + } + + if (response.Content.Headers.ContentLength > MaxAvatarSizeInBytes) + { + logger.LogWarning("External avatar from '{AvatarUrl}' exceeds maximum size, content length '{ContentLength}'", avatarUrl, response.Content.Headers.ContentLength); + return null; + } + + var avatarBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + if (avatarBytes.Length > MaxAvatarSizeInBytes) + { + logger.LogWarning("External avatar from '{AvatarUrl}' exceeds maximum size, read '{BytesRead}' bytes", avatarUrl, avatarBytes.Length); + return null; + } + + return new ExternalAvatar(new MemoryStream(avatarBytes), contentType); + } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + logger.LogWarning(ex, "Timeout when downloading external avatar from '{AvatarUrl}'", avatarUrl); + return null; + } + catch (HttpRequestException ex) + { + logger.LogWarning(ex, "Failed to download external avatar from '{AvatarUrl}'", avatarUrl); + return null; + } + } + + private static bool IsAllowedDomain(string avatarUrl) + { + if (!Uri.TryCreate(avatarUrl, UriKind.Absolute, out var uri)) + { + return false; + } + + if (uri.Scheme is not "https") + { + return false; + } + + var host = uri.Host; + return AllowedDomainSuffixes.Any(suffix => host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/application/account-management/Core/Integrations/OAuth/Google/GoogleOAuthProvider.cs b/application/account-management/Core/Integrations/OAuth/Google/GoogleOAuthProvider.cs new file mode 100644 index 000000000..c654685cf --- /dev/null +++ b/application/account-management/Core/Integrations/OAuth/Google/GoogleOAuthProvider.cs @@ -0,0 +1,222 @@ +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; +using PlatformPlatform.SharedKernel.OpenIdConnect; + +namespace PlatformPlatform.AccountManagement.Integrations.OAuth.Google; + +internal sealed record GoogleOAuthConfiguration(string ClientId, string ClientSecret); + +public sealed class GoogleOAuthProvider(HttpClient httpClient, IConfiguration configuration, OpenIdConnectConfigurationManagerFactory openIdConnectConfigurationManagerFactory, ILogger logger) : IOAuthProvider +{ + private const string AuthorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"; + private const string TokenEndpoint = "https://oauth2.googleapis.com/token"; + private const string GoogleDomain = "accounts.google.com"; + private const string GoogleDiscoveryUrl = $"https://{GoogleDomain}/.well-known/openid-configuration"; + private static readonly JsonWebTokenHandler TokenHandler = new(); + + private readonly GoogleOAuthConfiguration _configuration = configuration.GetSection("OAuth:Google").Get() + ?? throw new InvalidOperationException("OAuth:Google configuration is missing."); + + public ExternalProviderType ProviderType => ExternalProviderType.Google; + + public string BuildAuthorizationUrl(string stateToken, string codeChallenge, string nonce, string redirectUri) + { + var parameters = new Dictionary + { + ["client_id"] = _configuration.ClientId, + ["redirect_uri"] = redirectUri, + ["response_type"] = "code", + ["scope"] = "openid email profile", + ["state"] = stateToken, + ["code_challenge"] = codeChallenge, + ["code_challenge_method"] = "S256", + ["nonce"] = nonce, + ["prompt"] = "select_account" + }; + + var queryString = string.Join("&", parameters.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}")); + return $"{AuthorizationEndpoint}?{queryString}"; + } + + public async Task ExchangeCodeForTokensAsync(string code, string codeVerifier, string redirectUri, CancellationToken cancellationToken) + { + try + { + var tokenRequest = new FormUrlEncodedContent([ + new KeyValuePair("grant_type", "authorization_code"), + new KeyValuePair("code", code), + new KeyValuePair("client_id", _configuration.ClientId), + new KeyValuePair("client_secret", _configuration.ClientSecret), + new KeyValuePair("redirect_uri", redirectUri), + new KeyValuePair("code_verifier", codeVerifier) + ] + ); + + var response = await httpClient.PostAsync(TokenEndpoint, tokenRequest, cancellationToken); + if (!response.IsSuccessStatusCode) + { + await LogTokenExchangeError(response, cancellationToken); + return null; + } + + var tokenResponse = await response.Content.ReadFromJsonAsync(cancellationToken); + if (tokenResponse is null) return null; + + return new OAuthTokenResponse(tokenResponse.AccessToken, tokenResponse.IdToken, tokenResponse.ExpiresIn); + } + catch (HttpRequestException) + { + return null; + } + catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return null; + } + } + + public async Task GetUserProfileAsync(OAuthTokenResponse tokenResponse, CancellationToken cancellationToken) + { + if (tokenResponse.IdToken is null) return null; + + if (!TokenHandler.CanReadToken(tokenResponse.IdToken)) return null; + + var configurationManager = openIdConnectConfigurationManagerFactory.GetOrCreate(GoogleDiscoveryUrl); + var openIdConfiguration = await configurationManager.GetConfigurationAsync(cancellationToken); + + var validationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + // Google ID tokens may contain either https://accounts.google.com or accounts.google.com as iss claim (see github.com/coreos/go-oidc/issues/125) + ValidIssuers = [$"https://{GoogleDomain}", GoogleDomain], + ValidateAudience = true, + ValidAudiences = [_configuration.ClientId], + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(10), // Both Azure and Google use NTP-synced clocks, so minimal skew is safe + IssuerSigningKeys = openIdConfiguration.SigningKeys, + ValidateIssuerSigningKey = true, + ValidAlgorithms = [SecurityAlgorithms.RsaSha256] + }; + + var validationResult = await TokenHandler.ValidateTokenAsync(tokenResponse.IdToken, validationParameters); + + if (!validationResult.IsValid) + { + logger.LogError(validationResult.Exception, "Google ID token validation failed"); + return null; + } + + var token = (JsonWebToken)validationResult.SecurityToken; + + if (!ValidateAccessTokenHash(token, tokenResponse.AccessToken)) + { + return null; + } + + var authorizedParty = token.Claims.FirstOrDefault(c => c.Type == "azp")?.Value; + if (!string.IsNullOrEmpty(authorizedParty) && authorizedParty != _configuration.ClientId) + { + logger.LogError("azp claim mismatch. Expected: {Expected}, Got: {Actual}", _configuration.ClientId, authorizedParty); + return null; + } + + var subject = token.Claims.FirstOrDefault(c => c.Type == "sub")?.Value; + var email = token.Claims.FirstOrDefault(c => c.Type == "email")?.Value; + var emailVerifiedClaim = token.Claims.FirstOrDefault(c => c.Type == "email_verified")?.Value; + var givenName = token.Claims.FirstOrDefault(c => c.Type == "given_name")?.Value; + var familyName = token.Claims.FirstOrDefault(c => c.Type == "family_name")?.Value; + var picture = token.Claims.FirstOrDefault(c => c.Type == "picture")?.Value; + var locale = token.Claims.FirstOrDefault(c => c.Type == "locale")?.Value; + var nonce = token.Claims.FirstOrDefault(c => c.Type == "nonce")?.Value; + + if (string.IsNullOrEmpty(subject) || string.IsNullOrEmpty(email)) return null; + + var emailVerified = emailVerifiedClaim?.Equals("true", StringComparison.OrdinalIgnoreCase) == true; + + return new OAuthUserProfile( + subject, + email, + emailVerified, + givenName, + familyName, + picture, + locale, + nonce + ); + } + + private bool ValidateAccessTokenHash(JsonWebToken idToken, string accessToken) + { + var atHash = idToken.Claims.FirstOrDefault(c => c.Type == "at_hash")?.Value; + if (string.IsNullOrEmpty(atHash)) + { + logger.LogWarning("Google ID token missing required at_hash claim when access token is present"); + return false; + } + + var expectedHash = ComputeAtHash(accessToken, idToken.Alg); + if (expectedHash is null) + { + logger.LogWarning("Unsupported signature algorithm '{Algorithm}' for at_hash validation", idToken.Alg); + return false; + } + + return atHash == expectedHash; + } + + public static string? ComputeAtHash(string accessToken, string algorithm) + { + using var hashAlgorithm = algorithm switch + { + "RS256" => (HashAlgorithm)SHA256.Create(), + "RS384" => SHA384.Create(), + "RS512" => SHA512.Create(), + _ => null + }; + + if (hashAlgorithm is null) return null; + + var hash = hashAlgorithm.ComputeHash(Encoding.ASCII.GetBytes(accessToken)); + var leftHalf = hash[..(hash.Length / 2)]; + return Base64UrlEncoder.Encode(leftHalf); + } + + private async Task LogTokenExchangeError(HttpResponseMessage response, CancellationToken cancellationToken) + { + const int maxBodyLength = 500; + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); + + try + { + using var document = JsonDocument.Parse(errorBody); + var error = document.RootElement.TryGetProperty("error", out var errorElement) ? errorElement.GetString() : null; + var errorDescription = document.RootElement.TryGetProperty("error_description", out var descriptionElement) ? descriptionElement.GetString() : null; + + logger.LogWarning("Google token exchange failed with status '{StatusCode}', error '{Error}': {ErrorDescription}", response.StatusCode, error, errorDescription); + } + catch (JsonException) + { + var truncatedBody = errorBody.Length > maxBodyLength ? errorBody[..maxBodyLength] : errorBody; + logger.LogWarning("Google token exchange failed with status '{StatusCode}': {ErrorBody}", response.StatusCode, truncatedBody); + } + } + + private sealed record GoogleTokenResponse( + [property: JsonPropertyName("access_token")] + string AccessToken, + [property: JsonPropertyName("id_token")] + string? IdToken, + [property: JsonPropertyName("expires_in")] + int ExpiresIn, + [property: JsonPropertyName("token_type")] + string TokenType, + [property: JsonPropertyName("scope")] string Scope, + [property: JsonPropertyName("refresh_token")] + string? RefreshToken + ); +} diff --git a/application/account-management/Core/Integrations/OAuth/IOAuthProvider.cs b/application/account-management/Core/Integrations/OAuth/IOAuthProvider.cs new file mode 100644 index 000000000..052e896d2 --- /dev/null +++ b/application/account-management/Core/Integrations/OAuth/IOAuthProvider.cs @@ -0,0 +1,27 @@ +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; + +namespace PlatformPlatform.AccountManagement.Integrations.OAuth; + +public interface IOAuthProvider +{ + ExternalProviderType ProviderType { get; } + + string BuildAuthorizationUrl(string stateToken, string codeChallenge, string nonce, string redirectUri); + + Task ExchangeCodeForTokensAsync(string code, string codeVerifier, string redirectUri, CancellationToken cancellationToken); + + Task GetUserProfileAsync(OAuthTokenResponse tokenResponse, CancellationToken cancellationToken); +} + +public sealed record OAuthTokenResponse(string AccessToken, string? IdToken, int ExpiresIn); + +public sealed record OAuthUserProfile( + string ProviderUserId, + string Email, + bool EmailVerified, + string? FirstName, + string? LastName, + string? AvatarUrl, + string? Locale, + string? Nonce +); diff --git a/application/account-management/Core/Integrations/OAuth/Mock/MockOAuthProvider.cs b/application/account-management/Core/Integrations/OAuth/Mock/MockOAuthProvider.cs new file mode 100644 index 000000000..0609c1d70 --- /dev/null +++ b/application/account-management/Core/Integrations/OAuth/Mock/MockOAuthProvider.cs @@ -0,0 +1,119 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; + +namespace PlatformPlatform.AccountManagement.Integrations.OAuth.Mock; + +public sealed class MockOAuthProvider(IConfiguration configuration, IHttpContextAccessor httpContextAccessor) : IOAuthProvider +{ + public const string MockEmail = $"mockuser{OAuthProviderFactory.MockEmailDomain}"; + public const string MockProviderUserId = "mock-google-user-id-12345"; + public const string MockFirstName = "Mock"; + public const string MockLastName = "User"; + public const string FailurePrefix = "fail:"; + + private readonly bool _isEnabled = configuration.GetValue("OAuth:AllowMockProvider"); + + public ExternalProviderType ProviderType => ExternalProviderType.Google; + + public string BuildAuthorizationUrl(string stateToken, string codeChallenge, string nonce, string redirectUri) + { + if (!_isEnabled) + { + throw new InvalidOperationException("Mock OAuth provider is not enabled."); + } + + var failureMode = GetFailureMode(httpContextAccessor.HttpContext!); + if (failureMode == "access_denied") + { + return $"{redirectUri}?error=access_denied&error_description=The+user+denied+access&state={Uri.EscapeDataString(stateToken)}"; + } + + return $"{redirectUri}?code=mock-authorization-code:{Uri.EscapeDataString(nonce)}&state={Uri.EscapeDataString(stateToken)}"; + } + + public Task ExchangeCodeForTokensAsync(string code, string codeVerifier, string redirectUri, CancellationToken cancellationToken) + { + if (!_isEnabled) + { + throw new InvalidOperationException("Mock OAuth provider is not enabled."); + } + + var failureMode = GetFailureMode(httpContextAccessor.HttpContext!); + if (failureMode == "token_exchange") + { + return Task.FromResult(null); + } + + var nonce = ExtractNonceFromMockCode(code); + var mockTokenResponse = new OAuthTokenResponse( + "mock-access-token", + $"mock-id-token:{nonce}", + 3600 + ); + + return Task.FromResult(mockTokenResponse); + } + + public Task GetUserProfileAsync(OAuthTokenResponse tokenResponse, CancellationToken cancellationToken) + { + if (!_isEnabled) + { + throw new InvalidOperationException("Mock OAuth provider is not enabled."); + } + + var failureMode = GetFailureMode(httpContextAccessor.HttpContext!); + var emailPrefix = GetMockEmailPrefix(httpContextAccessor.HttpContext!); + var email = emailPrefix is not null ? $"{emailPrefix}{OAuthProviderFactory.MockEmailDomain}" : MockEmail; + var providerUserId = emailPrefix is not null ? $"mock-google-{emailPrefix}" : MockProviderUserId; + var emailVerified = failureMode != "email_not_verified"; + var nonce = ExtractNonceFromMockIdToken(tokenResponse.IdToken); + + return Task.FromResult(new OAuthUserProfile( + providerUserId, + email, + emailVerified, + MockFirstName, + MockLastName, + null, + "en", + nonce + ) + ); + } + + private static string? ExtractNonceFromMockCode(string code) + { + var separatorIndex = code.IndexOf(':'); + return separatorIndex >= 0 ? Uri.UnescapeDataString(code[(separatorIndex + 1)..]) : null; + } + + private static string? ExtractNonceFromMockIdToken(string? idToken) + { + if (idToken is null) return null; + var separatorIndex = idToken.IndexOf(':'); + return separatorIndex >= 0 ? idToken[(separatorIndex + 1)..] : null; + } + + private static string? GetFailureMode(HttpContext httpContext) + { + var cookieValue = httpContext.Request.Cookies[OAuthProviderFactory.UseMockProviderCookieName]; + if (cookieValue is null || !cookieValue.StartsWith(FailurePrefix, StringComparison.Ordinal)) + { + return null; + } + + return cookieValue[FailurePrefix.Length..]; + } + + private static string? GetMockEmailPrefix(HttpContext httpContext) + { + var cookieValue = httpContext.Request.Cookies[OAuthProviderFactory.UseMockProviderCookieName]; + if (cookieValue is null || cookieValue == "true" || cookieValue.StartsWith(FailurePrefix, StringComparison.Ordinal)) + { + return null; + } + + return cookieValue; + } +} diff --git a/application/account-management/Core/Integrations/OAuth/OAuthProviderFactory.cs b/application/account-management/Core/Integrations/OAuth/OAuthProviderFactory.cs new file mode 100644 index 000000000..69d300edb --- /dev/null +++ b/application/account-management/Core/Integrations/OAuth/OAuthProviderFactory.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using PlatformPlatform.AccountManagement.Features.ExternalAuthentication.Domain; +using PlatformPlatform.SharedKernel.Configuration; + +namespace PlatformPlatform.AccountManagement.Integrations.OAuth; + +public sealed class OAuthProviderFactory(IServiceProvider serviceProvider, IConfiguration configuration) +{ + public const string UseMockProviderCookieName = "__Test_Use_Mock_Provider"; + public const string MockEmailDomain = "@mock.localhost"; + + private readonly bool _allowMockProvider = GetAllowMockProvider(configuration); + + public bool ShouldUseMockProvider(HttpContext httpContext) + { + if (!_allowMockProvider) + { + return false; + } + + return httpContext.Request.Cookies.ContainsKey(UseMockProviderCookieName); + } + + public IOAuthProvider? GetProvider(ExternalProviderType providerType, bool useMock) + { + if (useMock && !_allowMockProvider) + { + return null; + } + + var serviceKey = useMock + ? $"mock-{providerType.ToString().ToLowerInvariant()}" + : providerType.ToString().ToLowerInvariant(); + + return serviceProvider.GetKeyedService(serviceKey); + } + + private static bool GetAllowMockProvider(IConfiguration configuration) + { + var allowMockProvider = configuration.GetValue("OAuth:AllowMockProvider"); + + if (allowMockProvider && SharedInfrastructureConfiguration.IsRunningInAzure) + { + throw new InvalidOperationException("Mock OAuth provider cannot be enabled in Azure environments."); + } + + return allowMockProvider; + } +} diff --git a/application/account-management/Tests/Authentication/GetUserSessionsTests.cs b/application/account-management/Tests/Authentication/GetUserSessionsTests.cs index c8fa5c3ef..51d650c1f 100644 --- a/application/account-management/Tests/Authentication/GetUserSessionsTests.cs +++ b/application/account-management/Tests/Authentication/GetUserSessionsTests.cs @@ -157,7 +157,8 @@ private void InsertUser(long tenantId, UserId userId, string email) ("Title", null), ("Avatar", """{"Url":null,"Version":0,"IsGravatar":false}"""), ("Role", "Owner"), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); } @@ -177,6 +178,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/Authentication/SwitchTenantTests.cs b/application/account-management/Tests/Authentication/SwitchTenantTests.cs index cd1e920f1..5dc4f016c 100644 --- a/application/account-management/Tests/Authentication/SwitchTenantTests.cs +++ b/application/account-management/Tests/Authentication/SwitchTenantTests.cs @@ -45,7 +45,8 @@ public async Task SwitchTenant_WhenUserExistsInTargetTenant_ShouldSwitchSuccessf ("Title", null), ("Avatar", JsonSerializer.Serialize(new Avatar())), ("Role", nameof(UserRole.Member)), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); @@ -122,7 +123,8 @@ public async Task SwitchTenant_WhenUserDoesNotExistInTargetTenant_ShouldReturnFo ("Title", null), ("Avatar", JsonSerializer.Serialize(new Avatar())), ("Role", nameof(UserRole.Owner)), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); @@ -181,7 +183,8 @@ public async Task SwitchTenant_WhenUserEmailNotConfirmed_ShouldConfirmEmail() ("Title", null), ("Avatar", JsonSerializer.Serialize(new Avatar())), ("Role", nameof(UserRole.Member)), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); @@ -249,7 +252,8 @@ public async Task SwitchTenant_WhenAcceptingInvite_ShouldCopyProfileData() ("Title", "Manager"), // Has a title that will be overwritten ("Avatar", JsonSerializer.Serialize(new Avatar())), ("Role", nameof(UserRole.Member)), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); @@ -323,7 +327,8 @@ public async Task SwitchTenant_WhenSessionAlreadyRevoked_ShouldReturnUnauthorize ("Title", null), ("Avatar", JsonSerializer.Serialize(new Avatar())), ("Role", nameof(UserRole.Member)), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); 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..65954ad29 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(); @@ -255,17 +233,18 @@ public async Task CompleteLogin_WithValidPreferredTenant_ShouldLoginToPreferredT ("Title", null), ("Avatar", JsonSerializer.Serialize(new Avatar())), ("Role", nameof(UserRole.Owner)), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); - 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 +253,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 +277,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 +297,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 76% rename from application/account-management/Tests/Authentication/StartLoginTests.cs rename to application/account-management/Tests/EmailAuthentication/StartEmailLoginTests.cs index 94b1388f9..ff307a319 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(); @@ -173,18 +172,19 @@ public async Task StartLogin_WhenUserIsSoftDeleted_ShouldReturnFakeLoginIdAndSen ("Role", nameof(UserRole.Member)), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); - 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/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/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/Tenants/DeleteTenantTests.cs b/application/account-management/Tests/Tenants/DeleteTenantTests.cs index 328993c07..a518fab4c 100644 --- a/application/account-management/Tests/Tenants/DeleteTenantTests.cs +++ b/application/account-management/Tests/Tenants/DeleteTenantTests.cs @@ -45,7 +45,8 @@ public async Task DeleteTenant_WhenTenantHasUsers_ShouldReturnBadRequest() ("Role", nameof(UserRole.Member)), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); diff --git a/application/account-management/Tests/Tenants/GetTenantsForUserTests.cs b/application/account-management/Tests/Tenants/GetTenantsForUserTests.cs index 393c8a0f0..c425775df 100644 --- a/application/account-management/Tests/Tenants/GetTenantsForUserTests.cs +++ b/application/account-management/Tests/Tenants/GetTenantsForUserTests.cs @@ -45,7 +45,8 @@ public async Task GetTenants_UserWithMultipleTenants_ReturnsAllTenants() ("Title", null), ("Avatar", JsonSerializer.Serialize(new Avatar())), ("Role", nameof(UserRole.Owner)), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); @@ -118,7 +119,8 @@ public async Task GetTenants_CurrentTenantIncluded_VerifyCurrentTenantInResponse ("Title", null), ("Avatar", JsonSerializer.Serialize(new Avatar())), ("Role", nameof(UserRole.Member)), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); @@ -162,7 +164,8 @@ public async Task GetTenants_UsersOnlySeeTheirOwnTenants_DoesNotReturnOtherUsers ("Title", null), ("Avatar", JsonSerializer.Serialize(new Avatar())), ("Role", nameof(UserRole.Member)), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); @@ -207,7 +210,8 @@ public async Task GetTenants_UserWithUnconfirmedEmail_ShowsAsNewTenant() ("Title", null), ("Avatar", JsonSerializer.Serialize(new Avatar())), ("Role", nameof(UserRole.Member)), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); diff --git a/application/account-management/Tests/Users/BulkDeleteUsersTests.cs b/application/account-management/Tests/Users/BulkDeleteUsersTests.cs index 36901097e..9d37dad22 100644 --- a/application/account-management/Tests/Users/BulkDeleteUsersTests.cs +++ b/application/account-management/Tests/Users/BulkDeleteUsersTests.cs @@ -37,7 +37,8 @@ public async Task BulkDeleteUsers_WhenUsersExist_ShouldSoftDeleteUsers() ("Role", nameof(UserRole.Member)), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); } @@ -147,7 +148,8 @@ public async Task BulkDeleteUsers_WhenMixedConfirmedAndUnconfirmed_ShouldSoftDel ("Role", nameof(UserRole.Member)), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); @@ -164,7 +166,8 @@ public async Task BulkDeleteUsers_WhenMixedConfirmedAndUnconfirmed_ShouldSoftDel ("Role", nameof(UserRole.Member)), ("EmailConfirmed", false), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); diff --git a/application/account-management/Tests/Users/BulkPurgeUsersTests.cs b/application/account-management/Tests/Users/BulkPurgeUsersTests.cs index 01220ca39..c29b84cba 100644 --- a/application/account-management/Tests/Users/BulkPurgeUsersTests.cs +++ b/application/account-management/Tests/Users/BulkPurgeUsersTests.cs @@ -34,7 +34,8 @@ public async Task BulkPurgeUsers_WhenOwnerDeletesMultipleDeletedUsers_ShouldPerm ("Role", nameof(UserRole.Member)), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); Connection.Insert("Users", [ @@ -50,7 +51,8 @@ public async Task BulkPurgeUsers_WhenOwnerDeletesMultipleDeletedUsers_ShouldPerm ("Role", nameof(UserRole.Member)), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); Connection.Insert("Users", [ @@ -66,7 +68,8 @@ public async Task BulkPurgeUsers_WhenOwnerDeletesMultipleDeletedUsers_ShouldPerm ("Role", nameof(UserRole.Member)), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); @@ -107,7 +110,8 @@ public async Task BulkPurgeUsers_WhenMember_ShouldReturnForbidden() ("Role", nameof(UserRole.Member)), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); diff --git a/application/account-management/Tests/Users/DeclineInvitationTests.cs b/application/account-management/Tests/Users/DeclineInvitationTests.cs index a26d69973..3a8e4d215 100644 --- a/application/account-management/Tests/Users/DeclineInvitationTests.cs +++ b/application/account-management/Tests/Users/DeclineInvitationTests.cs @@ -45,7 +45,8 @@ public async Task DeclineInvitation_WhenValidInviteExists_ShouldDeleteUserAndCol ("Title", null), ("Avatar", JsonSerializer.Serialize(new Avatar())), ("Role", nameof(UserRole.Member)), - ("Locale", "") + ("Locale", ""), + ("ExternalIdentities", "[]") ] ); @@ -125,7 +126,8 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif ("Title", null), ("Avatar", JsonSerializer.Serialize(new Avatar())), ("Role", nameof(UserRole.Member)), - ("Locale", "") + ("Locale", ""), + ("ExternalIdentities", "[]") ] ); @@ -142,7 +144,8 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif ("Title", null), ("Avatar", JsonSerializer.Serialize(new Avatar())), ("Role", nameof(UserRole.Member)), - ("Locale", "") + ("Locale", ""), + ("ExternalIdentities", "[]") ] ); diff --git a/application/account-management/Tests/Users/DeleteUserTests.cs b/application/account-management/Tests/Users/DeleteUserTests.cs index e19d5fda1..c67b7649e 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; @@ -45,7 +44,8 @@ public async Task DeleteUser_WhenUserExists_ShouldSoftDeleteUser() ("Role", nameof(UserRole.Member)), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); @@ -73,7 +73,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(); @@ -90,19 +90,22 @@ public async Task DeleteUser_WhenUserHasLoginHistory_ShouldSoftDeleteUserAndKeep ("Role", nameof(UserRole.Member)), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); - 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 +118,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] @@ -136,7 +139,8 @@ public async Task DeleteUser_WhenUserNeverConfirmedEmail_ShouldPermanentlyDelete ("Role", nameof(UserRole.Member)), ("EmailConfirmed", false), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); diff --git a/application/account-management/Tests/Users/EmptyRecycleBinTests.cs b/application/account-management/Tests/Users/EmptyRecycleBinTests.cs index 524d58eef..e06bdd01c 100644 --- a/application/account-management/Tests/Users/EmptyRecycleBinTests.cs +++ b/application/account-management/Tests/Users/EmptyRecycleBinTests.cs @@ -32,7 +32,8 @@ public async Task EmptyRecycleBin_WhenOwnerEmptiesRecycleBin_ShouldPermanentlyDe ("Role", nameof(UserRole.Member)), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); Connection.Insert("Users", [ @@ -48,7 +49,8 @@ public async Task EmptyRecycleBin_WhenOwnerEmptiesRecycleBin_ShouldPermanentlyDe ("Role", nameof(UserRole.Member)), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); diff --git a/application/account-management/Tests/Users/GetDeletedUsersTests.cs b/application/account-management/Tests/Users/GetDeletedUsersTests.cs index 41ade4d41..4e792eac2 100644 --- a/application/account-management/Tests/Users/GetDeletedUsersTests.cs +++ b/application/account-management/Tests/Users/GetDeletedUsersTests.cs @@ -32,7 +32,8 @@ public async Task GetDeletedUsers_WhenOwner_ShouldReturnDeletedUsers() ("Role", nameof(UserRole.Member)), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); diff --git a/application/account-management/Tests/Users/GetUserByIdTests.cs b/application/account-management/Tests/Users/GetUserByIdTests.cs index 128796455..c792cc62f 100644 --- a/application/account-management/Tests/Users/GetUserByIdTests.cs +++ b/application/account-management/Tests/Users/GetUserByIdTests.cs @@ -29,7 +29,8 @@ public GetUserByIdTests() ("Role", nameof(UserRole.Member)), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); } diff --git a/application/account-management/Tests/Users/GetUserSummaryTests.cs b/application/account-management/Tests/Users/GetUserSummaryTests.cs index 90c0ce75b..579e1ac28 100644 --- a/application/account-management/Tests/Users/GetUserSummaryTests.cs +++ b/application/account-management/Tests/Users/GetUserSummaryTests.cs @@ -36,7 +36,8 @@ public async Task GetUserSummary_WhenUsersHaveVariousLastSeenDates_ShouldCountAc ("EmailConfirmed", true), ("LastSeenAt", now.AddDays(-5)), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); @@ -54,7 +55,8 @@ public async Task GetUserSummary_WhenUsersHaveVariousLastSeenDates_ShouldCountAc ("EmailConfirmed", true), ("LastSeenAt", thirtyOneDaysAgo), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); @@ -72,7 +74,8 @@ public async Task GetUserSummary_WhenUsersHaveVariousLastSeenDates_ShouldCountAc ("EmailConfirmed", false), ("LastSeenAt", null), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); diff --git a/application/account-management/Tests/Users/GetUsersTests.cs b/application/account-management/Tests/Users/GetUsersTests.cs index 01bb68bb3..76407797a 100644 --- a/application/account-management/Tests/Users/GetUsersTests.cs +++ b/application/account-management/Tests/Users/GetUsersTests.cs @@ -31,7 +31,8 @@ public GetUsersTests() ("Role", UserRole.ToString()), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); Connection.Insert("Users", [ @@ -46,7 +47,8 @@ public GetUsersTests() ("Role", UserRole.ToString()), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); } diff --git a/application/account-management/Tests/Users/InviteUserTests.cs b/application/account-management/Tests/Users/InviteUserTests.cs index 58e3ae9f7..b6395212d 100644 --- a/application/account-management/Tests/Users/InviteUserTests.cs +++ b/application/account-management/Tests/Users/InviteUserTests.cs @@ -126,7 +126,8 @@ public async Task InviteUser_WhenDeletedUserExists_ShouldReturnBadRequest() ("Role", nameof(UserRole.Member)), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); diff --git a/application/account-management/Tests/Users/PurgeUserTests.cs b/application/account-management/Tests/Users/PurgeUserTests.cs index 8770244d0..6e5459c94 100644 --- a/application/account-management/Tests/Users/PurgeUserTests.cs +++ b/application/account-management/Tests/Users/PurgeUserTests.cs @@ -30,7 +30,8 @@ public async Task PurgeUser_WhenOwnerDeletesSoftDeletedUser_ShouldSucceed() ("Role", nameof(UserRole.Member)), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); @@ -90,7 +91,8 @@ public async Task PurgeUser_WhenUserNotDeleted_ShouldReturnNotFound() ("Role", nameof(UserRole.Member)), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); diff --git a/application/account-management/Tests/Users/RestoreUserTests.cs b/application/account-management/Tests/Users/RestoreUserTests.cs index 3fc7f6d9d..c1aa60347 100644 --- a/application/account-management/Tests/Users/RestoreUserTests.cs +++ b/application/account-management/Tests/Users/RestoreUserTests.cs @@ -30,7 +30,8 @@ public async Task RestoreUser_WhenOwnerRestoresDeletedUser_ShouldSucceed() ("Role", nameof(UserRole.Member)), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); @@ -90,7 +91,8 @@ public async Task RestoreUser_WhenUserNotDeleted_ShouldReturnNotFound() ("Role", nameof(UserRole.Member)), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), - ("Locale", "en-US") + ("Locale", "en-US"), + ("ExternalIdentities", "[]") ] ); diff --git a/application/account-management/WebApp/federated-modules/common/SessionsModal.tsx b/application/account-management/WebApp/federated-modules/common/SessionsModal.tsx index 7c1b59b9e..e215f190c 100644 --- a/application/account-management/WebApp/federated-modules/common/SessionsModal.tsx +++ b/application/account-management/WebApp/federated-modules/common/SessionsModal.tsx @@ -34,7 +34,7 @@ import { InfoIcon, LaptopIcon, LogOutIcon, MonitorIcon, SmartphoneIcon, TabletIc import { useState } from "react"; import { toast } from "sonner"; import { SmartDate } from "@/shared/components/SmartDate"; -import { api, type components, DeviceType } from "@/shared/lib/api/client"; +import { api, type components, DeviceType, LoginMethod } from "@/shared/lib/api/client"; type UserSessionInfo = components["schemas"]["UserSessionInfo"]; @@ -107,6 +107,17 @@ function getDeviceTypeLabel(deviceType: UserSessionInfo["deviceType"]): string { } } +function getLoginMethodLabel(loginMethod: UserSessionInfo["loginMethod"]): string { + switch (loginMethod) { + case LoginMethod.OneTimePassword: + return t`One-time password`; + case LoginMethod.Google: + return t`Google`; + default: + return t`Unknown`; + } +} + function SessionCard({ session, isRevoking, @@ -136,6 +147,9 @@ function SessionCard({ Account: {session.tenantName || Unnamed account} + + Login method: {getLoginMethodLabel(session.loginMethod)} + IP: {session.ipAddress} diff --git a/application/account-management/WebApp/routes/error.tsx b/application/account-management/WebApp/routes/error.tsx index 84a05fab6..a3e054614 100644 --- a/application/account-management/WebApp/routes/error.tsx +++ b/application/account-management/WebApp/routes/error.tsx @@ -1,11 +1,15 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { ErrorCode } from "@repo/infrastructure/auth/AuthenticationMiddleware"; +import { AuthenticationContext } from "@repo/infrastructure/auth/AuthenticationProvider"; +import { loginPath, signUpPath } from "@repo/infrastructure/auth/constants"; +import { useIsAuthenticated, useUserInfo } from "@repo/infrastructure/auth/hooks"; +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"; -import { AlertCircle, LogIn, LogOut, ShieldAlert } from "lucide-react"; -import type { ReactNode } from "react"; +import { AlertCircle, LogIn, LogOut, ShieldAlert, UserPlus, UserX } from "lucide-react"; +import { type ReactNode, useContext, useState } from "react"; import LocaleSwitcher from "@/federated-modules/common/LocaleSwitcher"; import SupportButton from "@/federated-modules/common/SupportButton"; import ThemeModeSelector from "@/federated-modules/common/ThemeModeSelector"; @@ -14,20 +18,25 @@ import logoWrap from "@/shared/images/logo-wrap.svg"; export const Route = createFileRoute("/error")({ validateSearch: (search) => { - const params = search as { error?: string; returnPath?: string }; + const params = search as { error?: string; returnPath?: string; id?: string }; return { error: params.error, - returnPath: params.returnPath?.startsWith("/") ? params.returnPath : undefined + returnPath: params.returnPath && isValidReturnPath(params.returnPath) ? params.returnPath : undefined, + id: params.id && /^[a-zA-Z0-9-]+$/.test(params.id) ? params.id : undefined }; }, component: ErrorPage }); +type ErrorAction = "login" | "signup" | "contact"; + function getErrorDisplay(error: string): { icon: ReactNode; iconBackground: string; title: ReactNode; message: ReactNode; + action: ErrorAction; + secondaryAction?: ErrorAction; } { switch (error) { case ErrorCode.ReplayAttack: @@ -43,7 +52,8 @@ function getErrorDisplay(error: string): {
For your protection, you have been logged out. Please log in again to continue. - ) + ), + action: "login" }; case ErrorCode.SessionRevoked: @@ -57,10 +67,12 @@ function getErrorDisplay(error: string): {
Please log in again to continue. - ) + ), + action: "login" }; case ErrorCode.SessionNotFound: + case ErrorCode.SessionExpired: return { icon: , iconBackground: "bg-muted", @@ -71,7 +83,70 @@ function getErrorDisplay(error: string): {
Please log in again to continue. - ) + ), + action: "login" + }; + + case ErrorCode.UserNotFound: + return { + icon: , + iconBackground: "bg-muted", + title: Account not found, + message: No account found for this email address. Please sign up to create an account., + action: "signup", + secondaryAction: "login" + }; + + case ErrorCode.AccountAlreadyExists: + return { + icon: , + iconBackground: "bg-muted", + title: Account already exists, + message: An account with this email already exists. Please log in instead., + action: "login", + secondaryAction: "signup" + }; + + case ErrorCode.IdentityMismatch: + return { + icon: , + iconBackground: "bg-destructive/10", + title: Identity mismatch, + message: ( + <> + This account is linked to a different Google identity. +
+ This can happen when email ownership has changed. Contact your account administrator. + + ), + action: "contact" + }; + + case ErrorCode.AuthenticationFailed: + return { + icon: , + iconBackground: "bg-destructive/10", + title: Authentication failed, + message: We detected a security issue with your login attempt. Please try again., + action: "login" + }; + + case ErrorCode.InvalidRequest: + return { + icon: , + iconBackground: "bg-destructive/10", + title: Invalid request, + message: The authentication request was invalid. Please try again., + action: "login" + }; + + case ErrorCode.AccessDenied: + return { + icon: , + iconBackground: "bg-muted", + title: Access denied, + message: Authentication was cancelled or denied. Please try again if you want to continue., + action: "login" }; default: @@ -81,12 +156,46 @@ function getErrorDisplay(error: string): { title: Something went wrong, message: ( An unexpected error occurred. Please try again or contact support if the problem persists. - ) + ), + action: "login" }; } } +function useAuthInfoSafe() { + const context = useContext(AuthenticationContext); + const hasContext = context.userInfo !== null; + const isAuthenticated = useIsAuthenticated(); + const userInfo = useUserInfo(); + + return { + isAuthenticated: hasContext && isAuthenticated, + userInfo: hasContext ? userInfo : null + }; +} + function ErrorNavigation() { + const { isAuthenticated, userInfo } = useAuthInfoSafe(); + const [isLoggingOut, setIsLoggingOut] = useState(false); + + const handleLogout = async () => { + setIsLoggingOut(true); + try { + const response = await fetch("/api/account-management/authentication/logout", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-xsrf-token": import.meta.antiforgeryToken + } + }); + if (response.ok) { + globalThis.location.href = loginPath; + } + } catch { + globalThis.location.href = loginPath; + } + }; + return ( ); } +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) { @@ -119,11 +269,15 @@ function ErrorPage() { navigate({ to: "/login", search: { returnPath } }); }; + const handleSignUp = () => { + navigate({ to: signUpPath }); + }; + return ( -
+
-
+
{errorDisplay.icon} @@ -134,12 +288,28 @@ function ErrorPage() {

{errorDisplay.message}

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

+ Reference ID: {id} +

+ )}
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..362be6b2c 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"; @@ -10,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"; @@ -20,9 +22,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() { @@ -47,15 +48,33 @@ 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"); + 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 { loginId, emailConfirmationId, validForSeconds } = startLoginMutation.data; + const { emailLoginId, validForSeconds } = startLoginMutation.data; clearLoginState(); setLoginState({ - loginId, - emailConfirmationId, + emailLoginId, email, expireAt: new Date(Date.now() + validForSeconds * 1000) }); @@ -63,12 +82,14 @@ export function LoginForm() { return ; } + const isPending = startLoginMutation.isPending || isGoogleLoginPending; + return (
{t`Logo`} @@ -90,9 +111,28 @@ export function LoginForm() { autoComplete="email webauthn" placeholder={t`yourname@example.com`} className="flex w-full flex-col" + isDisabled={isPending} /> - +
+
+ + or + +
+
+

diff --git a/application/account-management/WebApp/routes/login/verify.tsx b/application/account-management/WebApp/routes/login/verify.tsx index 867419b0a..69828c4be 100644 --- a/application/account-management/WebApp/routes/login/verify.tsx +++ b/application/account-management/WebApp/routes/login/verify.tsx @@ -3,6 +3,7 @@ import { Trans } from "@lingui/react/macro"; import { authSyncService, type UserLoggedInMessage } from "@repo/infrastructure/auth/AuthSyncService"; import { loggedInPath } 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 { InputOtp, InputOtpGroup, InputOtpSlot } from "@repo/ui/components/InputOtp"; @@ -10,7 +11,7 @@ import { Link } from "@repo/ui/components/Link"; import { mutationSubmitter } from "@repo/ui/forms/mutationSubmitter"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import FederatedErrorPage from "@/federated-modules/errorPages/FederatedErrorPage"; import logoMarkUrl from "@/shared/images/logo-mark.svg"; @@ -29,9 +30,8 @@ import { export const Route = createFileRoute("/login/verify")({ 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 LoginVerifyRoute() { @@ -86,8 +86,9 @@ function useCountdown(expireAt: Date) { } export function CompleteLoginForm() { + const otpInputRef = useRef(null); 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); @@ -112,12 +113,12 @@ export function CompleteLoginForm() { // Get preferred tenant from localStorage const getPreferredTenantId = useCallback(() => { try { - const stored = localStorage.getItem(`preferred-tenant-${email}`); + const stored = localStorage.getItem("preferred-tenant"); return stored || null; } catch { return null; } - }, [email]); + }, []); const resetAfterResend = useCallback((validForSeconds: number) => { const newExpireAt = new Date(); @@ -130,31 +131,34 @@ export function CompleteLoginForm() { setIsRateLimited(false); setTimeout(() => { - const input = document.querySelector('[data-slot="input-otp"]'); - input?.focus(); + otpInputRef.current?.focus(); }, 100); }, []); - const completeLoginMutation = api.useMutation("post", "/api/account-management/authentication/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 - const message: Omit = { - type: "USER_LOGGED_IN", - userId: "", // We don't have the user ID at this point - tenantId: getPreferredTenantId() || "", - email: email || "" - }; - authSyncService.broadcast(message); + 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 + const message: Omit = { + type: "USER_LOGGED_IN", + userId: "", // We don't have the user ID at this point + tenantId: getPreferredTenantId() || "", + email: email || "" + }; + authSyncService.broadcast(message); - clearLoginState(); - window.location.href = returnPath ?? loggedInPath; + clearLoginState(); + window.location.href = returnPath ?? loggedInPath; + } } - }); + ); 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) { @@ -179,8 +183,7 @@ export function CompleteLoginForm() { setOtpValue(""); setAutoSubmitCode(false); setTimeout(() => { - const input = document.querySelector('[data-slot="input-otp"]'); - input?.focus(); + otpInputRef.current?.focus(); }, 100); } } @@ -188,34 +191,42 @@ export function CompleteLoginForm() { const expiresInString = `${Math.floor(secondsRemaining / 60)}:${String(secondsRemaining % 60).padStart(2, "0")}`; - if (!loginId) { + const submitVerification = useCallback( + (code: string) => { + if (!emailLoginId) { + return; + } + setLastSubmittedCode(code); + completeLoginMutation.mutate({ + params: { + path: { id: emailLoginId } + }, + body: { + oneTimePassword: code, + preferredTenantId: getPreferredTenantId() || null + } + }); + }, + [completeLoginMutation, emailLoginId, getPreferredTenantId] + ); + + if (!emailLoginId) { return null; } return ( -

+
{ event.preventDefault(); if (otpValue.length === 6) { - setLastSubmittedCode(otpValue); + submitVerification(otpValue); } - - completeLoginMutation.mutate({ - params: { - path: { id: loginId } - }, - body: { - oneTimePassword: otpValue, - preferredTenantId: getPreferredTenantId() || null - } - }); }} validationErrors={completeLoginMutation.error?.errors} validationBehavior="aria" > - - +
@@ -231,6 +242,7 @@ export function CompleteLoginForm() {
{ - document.querySelector("form")?.requestSubmit(); - }, 10); + submitVerification(upperValue); } }} disabled={isExpired || resendLoginCodeMutation.isPending} @@ -262,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 +

+ )} +
+
+
+ + 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 edb906b63..7f8886ee5 100644 --- a/application/account-management/WebApp/routes/signup/verify.tsx +++ b/application/account-management/WebApp/routes/signup/verify.tsx @@ -10,7 +10,7 @@ import { Link } from "@repo/ui/components/Link"; import { mutationSubmitter } from "@repo/ui/forms/mutationSubmitter"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import FederatedErrorPage from "@/federated-modules/errorPages/FederatedErrorPage"; import logoMarkUrl from "@/shared/images/logo-mark.svg"; @@ -79,8 +79,9 @@ function useCountdown(expireAt: Date) { } export function CompleteSignupForm() { + const otpInputRef = useRef(null); 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); @@ -112,14 +113,13 @@ export function CompleteSignupForm() { setIsRateLimited(false); setTimeout(() => { - const input = document.querySelector('[data-slot="input-otp"]'); - input?.focus(); + otpInputRef.current?.focus(); }, 100); }, []); 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) { @@ -155,8 +155,7 @@ export function CompleteSignupForm() { setOtpValue(""); setAutoSubmitCode(false); setTimeout(() => { - const input = document.querySelector('[data-slot="input-otp"]'); - input?.focus(); + otpInputRef.current?.focus(); }, 100); } } @@ -164,24 +163,30 @@ export function CompleteSignupForm() { const expiresInString = `${Math.floor(secondsRemaining / 60)}:${String(secondsRemaining % 60).padStart(2, "0")}`; + const submitVerification = useCallback( + (code: string) => { + setLastSubmittedCode(code); + completeSignupMutation.mutate({ + params: { + path: { id: emailLoginId } + }, + body: { + oneTimePassword: code, + preferredLocale: localStorage.getItem(preferredLocaleKey) ?? "" + } + }); + }, + [completeSignupMutation, emailLoginId] + ); + return ( -

+
{ event.preventDefault(); if (otpValue.length === 6) { - setLastSubmittedCode(otpValue); + submitVerification(otpValue); } - - completeSignupMutation.mutate({ - params: { - path: { emailConfirmationId } - }, - body: { - oneTimePassword: otpValue, - preferredLocale: localStorage.getItem(preferredLocaleKey) ?? "" - } - }); }} validationErrors={completeSignupMutation.error?.errors} validationBehavior="aria" @@ -201,6 +206,7 @@ export function CompleteSignupForm() {
{ - document.querySelector("form")?.requestSubmit(); - }, 10); + submitVerification(upperValue); } }} disabled={isExpired || resendSignupCodeMutation.isPending} @@ -232,15 +236,17 @@ export function CompleteSignupForm() { - {!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 +

+ )} +