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

summaryrefslogtreecommitdiffhomepage
path: root/apps/api/Accounts/AccountService.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/AccountService.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/AccountService.cs')
-rw-r--r--apps/api/Accounts/AccountService.cs66
1 files changed, 65 insertions, 1 deletions
diff --git a/apps/api/Accounts/AccountService.cs b/apps/api/Accounts/AccountService.cs
index a3f83ad..3e7ced1 100644
--- a/apps/api/Accounts/AccountService.cs
+++ b/apps/api/Accounts/AccountService.cs
@@ -62,7 +62,7 @@ internal sealed class AccountService(
}
AccountRecord? account = await store.FindByEmailAsync(normalizedEmail, cancellationToken);
- if (account is null)
+ if (account is null || account.PasswordHash is null)
{
// A fixed dummy record keeps the missing-account path on the same password-hashing primitive.
AccountRecord dummyBase = new(Guid.Empty, "Missing00", "MISSING00", "missing@example.invalid",
@@ -85,6 +85,61 @@ internal sealed class AccountService(
return await sessions.CreateAsync(account, cancellationToken);
}
+ public async ValueTask<(AuthenticatedAccount? Authentication, GoogleAccountFailure Failure)>
+ AuthenticateGoogleAsync(
+ string? providerEmail,
+ string? requestedAccountName,
+ SessionService sessions,
+ CancellationToken cancellationToken)
+ {
+ if (!EmailAddressPolicy.TryNormalize(providerEmail, out string email, out string normalizedEmail, out _))
+ {
+ return (null, GoogleAccountFailure.ProviderRejected);
+ }
+
+ AccountRecord? existing = await store.FindByEmailAsync(normalizedEmail, cancellationToken);
+ if (existing is not null)
+ {
+ return (await sessions.CreateAsync(existing, cancellationToken), GoogleAccountFailure.None);
+ }
+
+ if (requestedAccountName is null)
+ {
+ return (null, GoogleAccountFailure.AccountNameRequired);
+ }
+
+ if (!AccountNamePolicy.TryNormalize(requestedAccountName, out string accountName, out _))
+ {
+ return (null, GoogleAccountFailure.InvalidAccountName);
+ }
+
+ AccountRecord account = new(
+ Guid.NewGuid(),
+ accountName,
+ AccountNamePolicy.NormalizeForLookup(accountName),
+ email,
+ normalizedEmail,
+ null,
+ timeProvider.GetUtcNow());
+ CreateAccountResult result = await store.CreateAsync(account, cancellationToken);
+
+ if (result.Status is CreateAccountStatus.AccountNameUnavailable)
+ {
+ return (null, GoogleAccountFailure.AccountNameUnavailable);
+ }
+
+ if (result.Status is CreateAccountStatus.EmailUnavailable)
+ {
+ // A concurrent callback may have created the same verified Google identity.
+ AccountRecord? concurrent = await store.FindByEmailAsync(normalizedEmail, cancellationToken);
+ return concurrent is null
+ ? (null, GoogleAccountFailure.ProviderRejected)
+ : (await sessions.CreateAsync(concurrent, cancellationToken), GoogleAccountFailure.None);
+ }
+
+ return (await sessions.CreateAsync(account, cancellationToken), GoogleAccountFailure.None);
+ }
+
private static Dictionary<string, string[]> ValidateRegistration(
RegisterAccountRequest request,
out string accountName,
@@ -111,3 +166,12 @@ internal sealed class AccountService(
return errors;
}
}
+
+internal enum GoogleAccountFailure
+{
+ None,
+ ProviderRejected,
+ AccountNameRequired,
+ InvalidAccountName,
+ AccountNameUnavailable
+}