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

summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLeitwolf2026-10-03 20:12:23 +0300
committerGitHub2026-10-03 20:12:23 +0300
commit933244cc0fa149427d22d06be4d226c0a67e8b80 (patch)
tree048ae623f49cc5f2f94234d747351f9e659f5267
parentcf444eb3bccf99c10f59ed5330b61c0397e0aeaa (diff)
parentb84a2216c26cc5392d0dc300b7a605ccb288d8c8 (diff)
downloadprogmaweb-933244cc0fa149427d22d06be4d226c0a67e8b80.tar.gz
Merge pull request #27 from Progmasoft/fix/api-without-google
Run the account API without a Google client
-rw-r--r--apps/api-tests/AccountApiFactory.cs19
-rw-r--r--apps/api-tests/UnconfiguredGoogleTests.cs97
-rw-r--r--apps/api/Program.cs20
-rw-r--r--docs/ACCOUNT-API.md8
-rw-r--r--docs/DEVELOPMENT.md14
-rw-r--r--docs/LIMITATIONS.md12
6 files changed, 137 insertions, 33 deletions
diff --git a/apps/api-tests/AccountApiFactory.cs b/apps/api-tests/AccountApiFactory.cs
index 373cd84..89faa3e 100644
--- a/apps/api-tests/AccountApiFactory.cs
+++ b/apps/api-tests/AccountApiFactory.cs
@@ -7,19 +7,30 @@ using Microsoft.Extensions.Configuration;
namespace Progmasoft.Progmaweb.Api.Tests;
-internal sealed class AccountApiFactory : WebApplicationFactory<Program>
+/// <summary>Starts the account API in memory for a test.</summary>
+/// <param name="configureGoogle">
+/// Whether the Google client is configured. Without it the service has to run and refuse only Google sign-in.
+/// </param>
+internal sealed class AccountApiFactory(bool configureGoogle = true) : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
+
+ // The program decides while it registers services whether the Google scheme exists, so these two values
+ // have to be host settings: configuration sources added below are applied only when the host is built,
+ // after that decision. An empty value also overrides credentials the environment of the test run carries.
+ builder.UseSetting(
+ "Authentication:Google:ClientId",
+ configureGoogle ? "test-client.apps.googleusercontent.com" : string.Empty);
+ builder.UseSetting("Authentication:Google:ClientSecret", configureGoogle ? "test-secret" : string.Empty);
+
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["AccountSessions:LifetimeHours"] = "2",
- ["AccountSessions:CookieName"] = "__Host-ProgmasoftSession",
- ["Authentication:Google:ClientId"] = "test-client.apps.googleusercontent.com",
- ["Authentication:Google:ClientSecret"] = "test-secret"
+ ["AccountSessions:CookieName"] = "__Host-ProgmasoftSession"
});
});
}
diff --git a/apps/api-tests/UnconfiguredGoogleTests.cs b/apps/api-tests/UnconfiguredGoogleTests.cs
new file mode 100644
index 0000000..d4e8c53
--- /dev/null
+++ b/apps/api-tests/UnconfiguredGoogleTests.cs
@@ -0,0 +1,97 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using System.Net;
+using System.Net.Http.Json;
+using Progmasoft.Progmaweb.Api.Accounts;
+
+namespace Progmasoft.Progmaweb.Api.Tests;
+
+/// <summary>
+/// The service without a Google client. It used to answer every request with 500, because the Google scheme was
+/// registered with empty credentials and failed its option validation in the authentication middleware.
+/// </summary>
+public sealed class UnconfiguredGoogleTests
+{
+ [Theory]
+ [InlineData("/api/v1/status", HttpStatusCode.OK)]
+ [InlineData("/health", HttpStatusCode.OK)]
+ [InlineData("/api/v1/accounts/me", HttpStatusCode.Unauthorized)]
+ public async Task OrdinaryRoutesAnswerWithoutGoogle(string path, HttpStatusCode expected)
+ {
+ await using AccountApiFactory factory = new(configureGoogle: false);
+ using HttpClient client = factory.CreateAccountClient();
+
+ HttpResponseMessage response = await client.GetAsync(path, TestContext.Current.CancellationToken);
+
+ Assert.Equal(expected, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task PasswordAccountsWorkWithoutGoogle()
+ {
+ await using AccountApiFactory factory = new(configureGoogle: false);
+ using HttpClient client = factory.CreateAccountClient();
+ const string password = "a sufficiently long passphrase";
+
+ HttpResponseMessage registration = await client.PostAsJsonAsync(
+ "/api/v1/accounts/register",
+ new RegisterAccountRequest("LocalUser1", "local@example.com", password),
+ TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.Created, registration.StatusCode);
+
+ // The registration set the session cookie, so the same client is now signed in.
+ HttpResponseMessage me = await client.GetAsync("/api/v1/accounts/me", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, me.StatusCode);
+
+ HttpResponseMessage logout = await client.PostAsync(
+ "/api/v1/accounts/logout", null, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NoContent, logout.StatusCode);
+
+ HttpResponseMessage login = await client.PostAsJsonAsync(
+ "/api/v1/accounts/login",
+ new LoginAccountRequest("local@example.com", password),
+ TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, login.StatusCode);
+ }
+
+ [Fact]
+ public async Task GoogleStartReportsThatSignInIsUnavailable()
+ {
+ await using AccountApiFactory factory = new(configureGoogle: false);
+ 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.ServiceUnavailable, response.StatusCode);
+ Assert.Null(response.Headers.Location);
+ }
+
+ [Fact]
+ public async Task GoogleCompleteFailsClosedWithoutGoogle()
+ {
+ await using AccountApiFactory factory = new(configureGoogle: false);
+ using HttpClient client = factory.CreateAccountClient(handleCookies: false);
+
+ HttpResponseMessage response = await client.GetAsync(
+ "/api/v1/accounts/oauth/google/complete", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
+ Assert.Equal("/login?google=failed", response.Headers.Location?.OriginalString);
+ Assert.False(response.Headers.Contains("Set-Cookie"));
+ }
+
+ [Fact]
+ public async Task GoogleCallbackIsNotARouteWithoutGoogle()
+ {
+ await using AccountApiFactory factory = new(configureGoogle: false);
+ using HttpClient client = factory.CreateAccountClient(handleCookies: false);
+
+ HttpResponseMessage response = await client.GetAsync(
+ "/api/v1/accounts/oauth/google/callback", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+}
diff --git a/apps/api/Program.cs b/apps/api/Program.cs
index 5f6a7a5..b5c8708 100644
--- a/apps/api/Program.cs
+++ b/apps/api/Program.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
using System.Threading.RateLimiting;
+using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.HttpOverrides;
@@ -15,7 +16,7 @@ builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<IAccountStore, InMemoryAccountStore>();
builder.Services.AddSingleton<AccountService>();
builder.Services.AddSingleton<SessionService>();
-builder.Services
+AuthenticationBuilder authentication = builder.Services
.AddAuthentication()
.AddCookie(AccountEndpoints.ExternalCookieScheme, options =>
{
@@ -25,15 +26,24 @@ builder.Services
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.ExpireTimeSpan = TimeSpan.FromMinutes(10);
- })
- .AddGoogle(GoogleDefaults.AuthenticationScheme, options =>
+ });
+
+// The Google scheme exists only when its client is configured. Registered with empty credentials, its option
+// validation fails inside the authentication middleware and every request, not only Google sign-in, ends in 500.
+// Without the scheme the service runs normally and the Google start endpoint answers that sign-in is unavailable.
+string? googleClientId = builder.Configuration["Authentication:Google:ClientId"];
+string? googleClientSecret = builder.Configuration["Authentication:Google:ClientSecret"];
+if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(googleClientSecret))
+{
+ authentication.AddGoogle(GoogleDefaults.AuthenticationScheme, options =>
{
- options.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? string.Empty;
- options.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? string.Empty;
+ options.ClientId = googleClientId;
+ options.ClientSecret = googleClientSecret;
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 =>
diff --git a/docs/ACCOUNT-API.md b/docs/ACCOUNT-API.md
index 022c958..a6861fd 100644
--- a/docs/ACCOUNT-API.md
+++ b/docs/ACCOUNT-API.md
@@ -102,7 +102,9 @@ The flow is a server-side authorization-code flow. The browser is redirected; it
A Google identity is never linked to an existing account by email address. Password registrations do not verify the
address, so linking by address could give one person's account to another.
-The service needs a Google client identifier and secret to run at all; see [Known limitations](LIMITATIONS.md).
+Google sign-in exists only when both the client identifier and the client secret are configured when the service
+starts. Without them the rest of the service works, `start` answers **503 Service Unavailable**, `complete` redirects
+to `/login?google=failed`, and the callback path is not a route.
## Status and health
@@ -137,8 +139,8 @@ The client address comes from `X-Forwarded-For`, which the service accepts from
| --- | --- | --- |
| `AccountSessions:LifetimeHours` | hours a session stays valid, clamped to 1–168 | `12` |
| `AccountSessions:CookieName` | name of the session cookie | `__Host-ProgmasoftSession` |
-| `Authentication:Google:ClientId` | Google OAuth client identifier | none; required |
-| `Authentication:Google:ClientSecret` | Google OAuth client secret | none; required |
+| `Authentication:Google:ClientId` | Google OAuth client identifier | empty; Google sign-in is off |
+| `Authentication:Google:ClientSecret` | Google OAuth client secret | empty; Google sign-in is off |
| `AllowedHosts` | host names the service answers | see `appsettings.json` |
| `ASPNETCORE_URLS` | listener address | `http://127.0.0.1:5085` in production |
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index c5da810..022af77 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -32,18 +32,12 @@ pnpm install --frozen-lockfile
pnpm dev
```
-Start the account API in a second terminal on the port the frontend expects. The two Google settings must not be
-empty, or the API answers every request with 500; placeholder values are enough for everything except sign-in with
-Google itself.
+Start the account API in a second terminal on the port the frontend expects:
```text
-Authentication__Google__ClientId=local-dev.apps.googleusercontent.com
-Authentication__Google__ClientSecret=local-dev-placeholder
dotnet run --project apps/api/Progmaweb.Api.csproj --urls http://127.0.0.1:5085
```
-Set the two variables in the way your shell sets environment variables before running the last line.
-
The frontend forwards `/api/*` to `PROGMAWEB_API_ORIGIN`, which defaults to `http://127.0.0.1:5085`. Set it when the
API listens elsewhere; `.env.example` shows the variable.
@@ -57,8 +51,10 @@ Open the three surfaces through their development host names:
Accounts created locally live in the memory of the API process and are gone when it stops.
-Real sign-in with Google needs the identifier and secret of a Google OAuth client in those two variables. Never commit
-the downloaded Google client file.
+Sign-in with Google is optional locally. It needs the identifier and secret of a Google OAuth client in
+`Authentication__Google__ClientId` and `Authentication__Google__ClientSecret`, set in the environment before the API
+starts. Without them everything else works and the Google button leads to a 503 from the API. Never commit the
+downloaded Google client file.
## Checks
diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md
index b08d334..6d09e7d 100644
--- a/docs/LIMITATIONS.md
+++ b/docs/LIMITATIONS.md
@@ -18,18 +18,6 @@ API process.
- **What closes it.** A durable implementation of `IAccountStore`. The interface already states the uniqueness and
session rules such a store must keep.
-## The API does not run without Google settings
-
-When `Authentication:Google:ClientId` or `Authentication:Google:ClientSecret` is empty, the API answers every request
-with 500, including registration, sign-in with a password and the status route. The validation of the Google options
-fails inside the authentication middleware, before any endpoint runs.
-
-- **Consequence.** The API cannot be started for local work without setting both values, and the "Google sign-in is
- unavailable" answer of the start endpoint is never reached in that state.
-- **Workaround.** Set both values. Placeholders are enough when Google sign-in itself is not being tested; see
- [Development](DEVELOPMENT.md).
-- **What closes it.** Registering the Google scheme only when it is configured.
-
## Email addresses are not verified
Registration accepts any well-formed address. Nothing proves that the person who registered owns it.