// SPDX-FileCopyrightText: 2026 Progmasoft // SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 namespace Progmasoft.Progmaweb.Api.Accounts; using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Google; /// HTTP endpoints of the account service under /api/v1/accounts. /// /// /// POST /registerCreates a password account and signs it in. /// POST /loginSigns in with email address and password. /// POST /logoutEnds the current session. /// GET /meReturns the signed-in account. /// GET /oauth/google/startBegins sign-in with Google. /// GET /oauth/google/completeFinishes sign-in with Google. /// /// The endpoints that accept credentials share the account-auth rate limit. /// internal static class AccountEndpoints { /// /// Authentication scheme of the short-lived cookie that carries the Google identity between the provider callback /// and /oauth/google/complete. /// internal const string ExternalCookieScheme = "ProgmasoftExternal"; /// Registers the account endpoints. /// The route builder of the application. /// The same route builder, for chaining. public static IEndpointRouteBuilder MapAccountEndpoints(this IEndpointRouteBuilder endpoints) { RouteGroupBuilder accounts = endpoints.MapGroup("/api/v1/accounts"); accounts.MapPost("/register", RegisterAsync).RequireRateLimiting("account-auth"); accounts.MapPost("/login", LoginAsync).RequireRateLimiting("account-auth"); accounts.MapPost("/logout", LogoutAsync); accounts.MapGet("/me", MeAsync); accounts.MapGet("/oauth/google/start", StartGoogleAsync).RequireRateLimiting("account-auth"); accounts.MapGet("/oauth/google/complete", CompleteGoogleAsync).RequireRateLimiting("account-auth"); return endpoints; } /// Begins sign-in with Google. /// /// Account name to create when the Google identity is new; omitted when signing in to an existing account. /// /// /// Whether the person accepted the Privacy Policy and the Security Policy; required with an account name. /// /// Application configuration with the Google client credentials. /// /// A challenge that sends the browser to Google, a redirect back to registration when the name is invalid, or /// 503 when Google sign-in is not configured. /// private static IResult StartGoogleAsync(string? accountName, bool? acceptPolicies, IConfiguration configuration) { if (string.IsNullOrWhiteSpace(configuration["Authentication:Google:ClientId"]) || string.IsNullOrWhiteSpace(configuration["Authentication:Google:ClientSecret"])) { return Results.Problem( title: "Google sign-in is unavailable", detail: "The Google identity provider has not been configured.", statusCode: StatusCodes.Status503ServiceUnavailable); } string? normalized = null; if (accountName is not null && !AccountNamePolicy.TryNormalize(accountName, out normalized, out _)) { return Results.Redirect("/register?google=invalid-account-name"); } // A registration leaves for Google only after the person accepted the policies, so a refusal is shown on // the form rather than after the round trip. if (normalized is not null && acceptPolicies is not true) { return Results.Redirect("/register?google=policies-required"); } AuthenticationProperties properties = new() { RedirectUri = "/api/v1/accounts/oauth/google/complete" }; if (normalized is not null) { properties.Items["accountName"] = normalized; properties.Items["policies"] = PolicyAcceptance.CurrentVersion; } return Results.Challenge(properties, [GoogleDefaults.AuthenticationScheme]); } /// Finishes sign-in with Google after the provider callback. /// Account service. /// Session service. /// The current request, holding the external cookie. /// Cancels the operation. /// A redirect to the dashboard on success, or to the page that explains the failure. private static async Task CompleteGoogleAsync( AccountService accounts, SessionService sessions, HttpContext context, CancellationToken cancellationToken) { AuthenticateResult external = await context.AuthenticateAsync(ExternalCookieScheme); if (!external.Succeeded || external.Principal is null) { return Results.Redirect("/login?google=failed"); } string? email = external.Principal.FindFirstValue(ClaimTypes.Email); string? subject = external.Principal.FindFirstValue(ClaimTypes.NameIdentifier); string? accountName = null; external.Properties?.Items.TryGetValue("accountName", out accountName); string? policies = null; external.Properties?.Items.TryGetValue("policies", out policies); (AuthenticatedAccount? authentication, GoogleAccountFailure failure) = await accounts.AuthenticateGoogleAsync(subject, email, accountName, policies, sessions, cancellationToken); await context.SignOutAsync(ExternalCookieScheme); if (authentication is null) { string destination = failure switch { GoogleAccountFailure.AccountNameRequired => "/register?google=account-name-required", GoogleAccountFailure.InvalidAccountName => "/register?google=invalid-account-name", GoogleAccountFailure.AccountNameUnavailable => "/register?google=account-name-unavailable", GoogleAccountFailure.PoliciesRequired => "/register?google=policies-required", _ => "/login?google=failed" }; return Results.Redirect(destination); } WriteSessionCookie(context, sessions, authentication); return Results.Redirect($"/{Uri.EscapeDataString(authentication.Account.AccountName)}/dashboard"); } /// Creates a password account and opens a session for it. /// The registration body. /// Account service. /// Session service. /// The current request; receives the session cookie. /// Cancels the operation. /// 201 with the account, or a validation problem that names each refused field. private static async Task RegisterAsync( RegisterAccountRequest request, AccountService accounts, SessionService sessions, HttpContext context, CancellationToken cancellationToken) { (AuthenticatedAccount? authentication, Dictionary errors) = await accounts.RegisterAsync(request, sessions, cancellationToken); if (authentication is null) { return Results.ValidationProblem(errors); } WriteSessionCookie(context, sessions, authentication); return Results.Created($"/api/v1/accounts/{Uri.EscapeDataString(authentication.Account.AccountName)}", ToResponse(authentication.Account)); } /// Signs in with email address and password. /// The login body. /// Account service. /// Session service. /// The current request; receives the session cookie. /// Cancels the operation. /// /// 200 with the account, or 401 with one message that does not reveal whether the address or the password was wrong. /// private static async Task LoginAsync( LoginAccountRequest request, AccountService accounts, SessionService sessions, HttpContext context, CancellationToken cancellationToken) { AuthenticatedAccount? authentication = await accounts.LoginAsync(request, sessions, cancellationToken); if (authentication is null) { return Results.Problem( title: "Sign-in failed", detail: "The email address or password is incorrect.", statusCode: StatusCodes.Status401Unauthorized); } WriteSessionCookie(context, sessions, authentication); return Results.Ok(ToResponse(authentication.Account)); } /// Ends the current session and deletes its cookie. /// Session service. /// The current request. /// Cancels the operation. /// 204, also when no session existed. private static async Task LogoutAsync( SessionService sessions, HttpContext context, CancellationToken cancellationToken) { context.Request.Cookies.TryGetValue(sessions.CookieName, out string? token); await sessions.RevokeAsync(token, cancellationToken); context.Response.Cookies.Delete(sessions.CookieName, sessions.CreateCookieOptions(null)); return Results.NoContent(); } /// Returns the account of the current session. /// Session service. /// The current request. /// Cancels the operation. /// 200 with the account, or 401 when the request has no valid session. private static async Task MeAsync( SessionService sessions, HttpContext context, CancellationToken cancellationToken) { context.Request.Cookies.TryGetValue(sessions.CookieName, out string? token); AccountRecord? account = await sessions.AuthenticateAsync(token, cancellationToken); return account is null ? Results.Unauthorized() : Results.Ok(ToResponse(account)); } /// Writes the session cookie of a new session to the response. /// The current request. /// Session service, which names the cookie. /// The session that was opened. private static void WriteSessionCookie( HttpContext context, SessionService sessions, AuthenticatedAccount authentication) => context.Response.Cookies.Append( sessions.CookieName, authentication.SessionToken, sessions.CreateCookieOptions(authentication.ExpiresAt)); /// Projects a stored account to its public view. /// The stored account. /// The fields a client may see. private static AccountResponse ToResponse(AccountRecord account) => new(account.AccountName, account.Email, account.CreatedAt); }