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

summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLeitwolf112026-08-30 21:45:01 +0300
committerLeitwolf112026-08-30 21:45:01 +0300
commit76c4c1876a86dbca0adf86750362b3581d6fc69d (patch)
tree52bc17d6a977d6c1b13c26c1e016a0e00db99ca5
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
-rw-r--r--README.md6
-rw-r--r--apps/api-tests/AccountApiFactory.cs4
-rw-r--r--apps/api-tests/AccountEndpointTests.cs32
-rw-r--r--apps/api-tests/packages.lock.json10
-rw-r--r--apps/api/Accounts/AccountEndpoints.cs72
-rw-r--r--apps/api/Accounts/AccountRecord.cs2
-rw-r--r--apps/api/Accounts/AccountService.cs66
-rw-r--r--apps/api/Progmaweb.Api.csproj3
-rw-r--r--apps/api/Program.cs22
-rw-r--r--apps/api/packages.lock.json9
-rw-r--r--apps/web/app/globals.css28
-rw-r--r--apps/web/app/login/page.tsx14
-rw-r--r--apps/web/app/register/page.tsx22
-rw-r--r--apps/web/components/AuthForm.tsx40
-rw-r--r--ops/systemd/progmaweb-api.service1
15 files changed, 319 insertions, 12 deletions
diff --git a/README.md b/README.md
index 3c75cee..ae0eb1c 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,12 @@ The frontend defaults to `http://localhost:5085` for server-side API requests. S
uses another origin. Browser requests use the same-origin `/api` boundary so production can proxy them without exposing
an internal address.
+Google sign-in uses a server-side authorization-code flow. Configure `Authentication__Google__ClientId` and
+`Authentication__Google__ClientSecret` only in the process environment or secret manager. The production OAuth client
+uses `https://account.progmasoft.com` as its JavaScript origin and
+`https://account.progmasoft.com/api/v1/accounts/oauth/google/callback` as its exact redirect URI. Never commit the
+downloaded Google client JSON.
+
## Verification
```text
diff --git a/apps/api-tests/AccountApiFactory.cs b/apps/api-tests/AccountApiFactory.cs
index b1cbca2..3e9d399 100644
--- a/apps/api-tests/AccountApiFactory.cs
+++ b/apps/api-tests/AccountApiFactory.cs
@@ -17,7 +17,9 @@ internal sealed class AccountApiFactory : WebApplicationFactory<Program>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["AccountSessions:LifetimeHours"] = "2",
- ["AccountSessions:CookieName"] = "__Host-ProgmasoftSession"
+ ["AccountSessions:CookieName"] = "__Host-ProgmasoftSession",
+ ["Authentication:Google:ClientId"] = "test-client.apps.googleusercontent.com",
+ ["Authentication:Google:ClientSecret"] = "test-secret"
});
});
}
diff --git a/apps/api-tests/AccountEndpointTests.cs b/apps/api-tests/AccountEndpointTests.cs
index a794e96..c59f6b8 100644
--- a/apps/api-tests/AccountEndpointTests.cs
+++ b/apps/api-tests/AccountEndpointTests.cs
@@ -10,6 +10,38 @@ namespace Progmasoft.Progmaweb.Api.Tests;
public sealed class AccountEndpointTests
{
[Fact]
+ public async Task GoogleStartUsesTheConfiguredCallbackAndPreservesAccountNameInState()
+ {
+ await using AccountApiFactory factory = new();
+ using HttpClient client = factory.CreateAccountClient(handleCookies: false);
+
+ HttpResponseMessage response = await client.GetAsync(
+ "/api/v1/accounts/oauth/google/start?accountName=GoogleUser",
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
+ Uri location = Assert.IsType<Uri>(response.Headers.Location);
+ Assert.Equal("accounts.google.com", location.Host);
+ Assert.Contains("redirect_uri=https%3A%2F%2Flocalhost%2Fapi%2Fv1%2Faccounts%2Foauth%2Fgoogle%2Fcallback",
+ location.Query, StringComparison.Ordinal);
+ Assert.Contains("state=", location.Query, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task GoogleStartRejectsInvalidAccountNameBeforeLeavingTheSite()
+ {
+ await using AccountApiFactory factory = new();
+ using HttpClient client = factory.CreateAccountClient(handleCookies: false);
+
+ HttpResponseMessage response = await client.GetAsync(
+ "/api/v1/accounts/oauth/google/start?accountName=lowercase",
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
+ Assert.Equal("/register?google=invalid-account-name", response.Headers.Location?.OriginalString);
+ }
+
+ [Fact]
public async Task RegisterCreatesSessionAndMeReturnsAccount()
{
await using AccountApiFactory factory = new();
diff --git a/apps/api-tests/packages.lock.json b/apps/api-tests/packages.lock.json
index 0a5ef16..c4ac317 100644
--- a/apps/api-tests/packages.lock.json
+++ b/apps/api-tests/packages.lock.json
@@ -43,6 +43,11 @@
"resolved": "2.23.0",
"contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
},
+ "Microsoft.AspNetCore.Authentication.Google": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "v/OZld8HxGa5hudLWiChuCf5asMrE36n19sR1RK5cC65SGJoYvQAcwnz3yoAOLDSw90PYc3hgFCh1FW83sakUg=="
+ },
"Microsoft.AspNetCore.TestHost": {
"type": "Transitive",
"resolved": "10.0.11",
@@ -469,7 +474,10 @@
}
},
"progmaweb.api": {
- "type": "Project"
+ "type": "Project",
+ "dependencies": {
+ "Microsoft.AspNetCore.Authentication.Google": "[10.0.11, )"
+ }
}
}
}
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,
diff --git a/apps/api/Accounts/AccountRecord.cs b/apps/api/Accounts/AccountRecord.cs
index a098fd1..253b380 100644
--- a/apps/api/Accounts/AccountRecord.cs
+++ b/apps/api/Accounts/AccountRecord.cs
@@ -9,7 +9,7 @@ internal sealed record AccountRecord(
string NormalizedAccountName,
string Email,
string NormalizedEmail,
- string PasswordHash,
+ string? PasswordHash,
DateTimeOffset CreatedAt);
internal sealed record SessionRecord(
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
+}
diff --git a/apps/api/Progmaweb.Api.csproj b/apps/api/Progmaweb.Api.csproj
index 57255cd..ce76f58 100644
--- a/apps/api/Progmaweb.Api.csproj
+++ b/apps/api/Progmaweb.Api.csproj
@@ -8,4 +8,7 @@
<AssemblyName>Progmaweb.Api</AssemblyName>
<Version>1.0.0</Version>
</PropertyGroup>
+ <ItemGroup>
+ <PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.11" />
+ </ItemGroup>
</Project>
diff --git a/apps/api/Program.cs b/apps/api/Program.cs
index 68d3f26..cdfe360 100644
--- a/apps/api/Program.cs
+++ b/apps/api/Program.cs
@@ -2,6 +2,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.0
using System.Threading.RateLimiting;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.HttpOverrides;
using Progmasoft.Progmaweb.Api.Accounts;
using Progmasoft.Progmaweb.Api.Security;
@@ -13,6 +15,25 @@ builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<IAccountStore, InMemoryAccountStore>();
builder.Services.AddSingleton<AccountService>();
builder.Services.AddSingleton<SessionService>();
+builder.Services
+ .AddAuthentication()
+ .AddCookie(AccountEndpoints.ExternalCookieScheme, options =>
+ {
+ options.Cookie.Name = "__Host-ProgmasoftExternal";
+ options.Cookie.HttpOnly = true;
+ options.Cookie.IsEssential = true;
+ options.Cookie.SameSite = SameSiteMode.Lax;
+ options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
+ options.ExpireTimeSpan = TimeSpan.FromMinutes(10);
+ })
+ .AddGoogle(GoogleDefaults.AuthenticationScheme, options =>
+ {
+ options.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? string.Empty;
+ options.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? string.Empty;
+ options.CallbackPath = "/api/v1/accounts/oauth/google/callback";
+ options.SignInScheme = AccountEndpoints.ExternalCookieScheme;
+ options.SaveTokens = false;
+ });
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
builder.Services.AddRateLimiter(options =>
@@ -50,6 +71,7 @@ if (!app.Environment.IsDevelopment())
app.UseHttpsRedirection();
app.UseMiddleware<SecurityHeadersMiddleware>();
+app.UseAuthentication();
app.UseRateLimiter();
app.UseExceptionHandler();
diff --git a/apps/api/packages.lock.json b/apps/api/packages.lock.json
index 4a91a8c..b7d7ff4 100644
--- a/apps/api/packages.lock.json
+++ b/apps/api/packages.lock.json
@@ -1,6 +1,13 @@
{
"version": 1,
"dependencies": {
- "net10.0": {}
+ "net10.0": {
+ "Microsoft.AspNetCore.Authentication.Google": {
+ "type": "Direct",
+ "requested": "[10.0.11, )",
+ "resolved": "10.0.11",
+ "contentHash": "v/OZld8HxGa5hudLWiChuCf5asMrE36n19sR1RK5cC65SGJoYvQAcwnz3yoAOLDSw90PYc3hgFCh1FW83sakUg=="
+ }
+ }
}
} \ No newline at end of file
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index a481a52..8ec857b 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -863,6 +863,34 @@ a {
margin-top: 3px;
}
+.auth-divider {
+ align-items: center;
+ color: var(--muted);
+ display: flex;
+ font-size: 0.78rem;
+ gap: 0.75rem;
+ text-transform: uppercase;
+}
+
+.auth-divider::before,
+.auth-divider::after {
+ background: var(--line);
+ content: "";
+ flex: 1;
+ height: 1px;
+}
+
+.google-auth-button {
+ background: transparent;
+ border-color: var(--line);
+ color: var(--ink);
+ width: 100%;
+}
+
+.google-auth-button:hover {
+ border-color: var(--accent);
+}
+
.form-switch {
margin: 0;
color: var(--ink-650);
diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx
index 2162ffe..90c2910 100644
--- a/apps/web/app/login/page.tsx
+++ b/apps/web/app/login/page.tsx
@@ -10,14 +10,24 @@ export const metadata: Metadata = {
robots: { index: false, follow: false },
};
-export default function LoginPage() {
+interface LoginPageProps {
+ searchParams: Promise<{ google?: string }>;
+}
+
+export default async function LoginPage({ searchParams }: LoginPageProps) {
+ const { google } = await searchParams;
+ const initialMessage =
+ google === "failed"
+ ? "Google sign-in could not be completed. Try again."
+ : undefined;
+
return (
<AccountShell
eyebrow="Progmasoft account"
title="Sign in securely."
description="Continue to your account dashboard and connected services."
>
- <AuthForm mode="login" />
+ <AuthForm initialMessage={initialMessage} mode="login" />
</AccountShell>
);
}
diff --git a/apps/web/app/register/page.tsx b/apps/web/app/register/page.tsx
index 97c63e5..d120d45 100644
--- a/apps/web/app/register/page.tsx
+++ b/apps/web/app/register/page.tsx
@@ -10,14 +10,32 @@ export const metadata: Metadata = {
robots: { index: false, follow: false },
};
-export default function RegisterPage() {
+interface RegisterPageProps {
+ searchParams: Promise<{ google?: string }>;
+}
+
+const googleMessages: Record<string, string> = {
+ "account-name-required":
+ "Choose an Account name before continuing with Google.",
+ "account-name-unavailable":
+ "That Account name is unavailable. Choose another name.",
+ "invalid-account-name":
+ "Use a valid Account name before continuing with Google.",
+};
+
+export default async function RegisterPage({
+ searchParams,
+}: RegisterPageProps) {
+ const { google } = await searchParams;
+ const initialMessage = google ? googleMessages[google] : undefined;
+
return (
<AccountShell
eyebrow="Create an identity"
title="Start with a durable account."
description="Choose the name used in your Progmasoft account URL."
>
- <AuthForm mode="register" />
+ <AuthForm initialMessage={initialMessage} mode="register" />
</AccountShell>
);
}
diff --git a/apps/web/components/AuthForm.tsx b/apps/web/components/AuthForm.tsx
index 56fb78d..f32c49d 100644
--- a/apps/web/components/AuthForm.tsx
+++ b/apps/web/components/AuthForm.tsx
@@ -5,11 +5,12 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
-import { type FormEvent, useId, useState } from "react";
+import { type FormEvent, type MouseEvent, useId, useState } from "react";
type AuthMode = "login" | "register";
interface AuthFormProps {
+ initialMessage?: string;
mode: AuthMode;
}
@@ -30,13 +31,34 @@ function firstProblemMessage(problem: ProblemDetails): string {
return firstError ?? problem.title ?? "The request could not be completed.";
}
-export function AuthForm({ mode }: AuthFormProps) {
+export function AuthForm({ initialMessage, mode }: AuthFormProps) {
const router = useRouter();
const formId = useId();
const [pending, setPending] = useState(false);
- const [message, setMessage] = useState<string | null>(null);
+ const [message, setMessage] = useState<string | null>(initialMessage ?? null);
const [showPassword, setShowPassword] = useState(false);
+ function continueWithGoogle(event: MouseEvent<HTMLButtonElement>) {
+ const form = event.currentTarget.form;
+ const parameters = new URLSearchParams();
+
+ if (mode === "register") {
+ const accountName = form?.elements.namedItem("accountName");
+ if (!(accountName instanceof HTMLInputElement)) {
+ setMessage("The Account name field could not be read.");
+ return;
+ }
+ if (!accountName.checkValidity()) {
+ accountName.reportValidity();
+ return;
+ }
+ parameters.set("accountName", accountName.value);
+ }
+
+ const query = parameters.size === 0 ? "" : `?${parameters.toString()}`;
+ window.open(`/api/v1/accounts/oauth/google/start${query}`, "_self");
+ }
+
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setMessage(null);
@@ -217,6 +239,18 @@ export function AuthForm({ mode }: AuthFormProps) {
: "Sign in"}
</button>
+ <div className="auth-divider" aria-hidden="true">
+ <span>or</span>
+ </div>
+ <button
+ className="button google-auth-button"
+ type="button"
+ disabled={pending}
+ onClick={continueWithGoogle}
+ >
+ Continue with Google
+ </button>
+
<p className="form-switch">
{mode === "register"
? "Already have an account?"
diff --git a/ops/systemd/progmaweb-api.service b/ops/systemd/progmaweb-api.service
index 201b402..e26aad4 100644
--- a/ops/systemd/progmaweb-api.service
+++ b/ops/systemd/progmaweb-api.service
@@ -14,6 +14,7 @@ WorkingDirectory=/srv/progmaweb/current/api
ExecStart=/usr/bin/env /srv/progmaweb/current/api/Progmaweb.Api
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=ASPNETCORE_URLS=http://127.0.0.1:5085
+EnvironmentFile=-/etc/progmaweb/account.env
Restart=on-failure
RestartSec=5s
TimeoutStopSec=30s