Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/apps/api/Accounts/AccountEndpoints.cs
diff options
context:
space:
mode:
authorLeitwolf112026-08-30 21:45:01 +0300
committerLeitwolf112026-08-30 21:45:01 +0300
commit76c4c1876a86dbca0adf86750362b3581d6fc69d (patch)
tree52bc17d6a977d6c1b13c26c1e016a0e00db99ca5 /apps/api/Accounts/AccountEndpoints.cs
parentc89a3f20a0f477573c41b831d993850e0adb30e7 (diff)
downloadprogmaweb-1.0.0.tar.gz
Add secure Google sign-in to Progmasoft Account. Use the server-side authorization-code flow with an exact account-domain callback, temporary hardened external cookies, verified provider email matching, canonical Account-name creation for new users, and no separate ViGet publisher identity. Add frontend entry points, provider failure guidance, locked Google authentication dependencies, service secret injection, and backend regression coverage without committing OAuth credentials.1.0.0
Diffstat (limited to 'apps/api/Accounts/AccountEndpoints.cs')
-rw-r--r--apps/api/Accounts/AccountEndpoints.cs72
1 files changed, 72 insertions, 0 deletions
diff --git a/apps/api/Accounts/AccountEndpoints.cs b/apps/api/Accounts/AccountEndpoints.cs
index b1bdfe0..5e9fb9d 100644
--- a/apps/api/Accounts/AccountEndpoints.cs
+++ b/apps/api/Accounts/AccountEndpoints.cs
@@ -3,8 +3,14 @@
namespace Progmasoft.Progmaweb.Api.Accounts;
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Google;
+
internal static class AccountEndpoints
{
+ internal const string ExternalCookieScheme = "ProgmasoftExternal";
+
public static IEndpointRouteBuilder MapAccountEndpoints(this IEndpointRouteBuilder endpoints)
{
RouteGroupBuilder accounts = endpoints.MapGroup("/api/v1/accounts");
@@ -13,10 +19,76 @@ internal static class AccountEndpoints
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;
}
+ private static IResult StartGoogleAsync(string? accountName, 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");
+ }
+
+ AuthenticationProperties properties = new()
+ {
+ RedirectUri = "/api/v1/accounts/oauth/google/complete"
+ };
+ if (normalized is not null)
+ {
+ properties.Items["accountName"] = normalized;
+ }
+
+ return Results.Challenge(properties, [GoogleDefaults.AuthenticationScheme]);
+ }
+
+ private static async Task<IResult> 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? accountName = null;
+ external.Properties?.Items.TryGetValue("accountName", out accountName);
+ (AuthenticatedAccount? authentication, GoogleAccountFailure failure) =
+ await accounts.AuthenticateGoogleAsync(email, accountName, 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",
+ _ => "/login?google=failed"
+ };
+ return Results.Redirect(destination);
+ }
+
+ WriteSessionCookie(context, sessions, authentication);
+ return Results.Redirect($"/{Uri.EscapeDataString(authentication.Account.AccountName)}/dashboard");
+ }
+
private static async Task<IResult> RegisterAsync(
RegisterAccountRequest request,
AccountService accounts,