From aff1ec17694163cff03c017a972a68e9d32a9be2 Mon Sep 17 00:00:00 2001
From: hokiepokedad2 <38219945+hokiepokedad2@users.noreply.github.com>
Date: Wed, 5 Aug 2026 10:59:18 -0400
Subject: [PATCH] feat: report the running build via GET /api/version
Answers "what is this instance actually running?" without shell access to the
host.
The image already carries version and revision in its OCI labels, but labels are
only readable with `docker inspect` on the host, so they cannot answer the
question from outside -- and a locally-built image carries no labels at all,
which is exactly the case that came up: the dev container's provenance was
simply unrecoverable.
Labels are also invisible from inside the container, so the values are now
passed as Docker build args and baked into the environment. CI supplies them
from the same metadata that produces the labels; any build that omits them
reports "unknown" instead of failing, so `dotnet run` and local `docker build`
keep working.
Anonymous on purpose: the point is checking a deployment without credentials.
The repository is public, so the commit SHA is already visible on GitHub, and
the response exposes no configuration or secret.
Verified end to end, not just by unit test: built the image with build args and
confirmed they land in the environment; built it without and confirmed the
"unknown" fallback; then ran the container against a throwaway MariaDB and
`GET /api/version` returned the injected values as JSON. That last step matters
because `/api/version` previously returned index.html -- the SPA catch-all was
swallowing it -- so routing precedence was the one thing unit tests could not
prove.
---
.github/workflows/docker-publish.yml | 12 +++
.../Controllers/VersionController.cs | 53 ++++++++++++
CHANGELOG.md | 1 +
Dockerfile | 11 +++
.../Controllers/VersionControllerTests.cs | 83 +++++++++++++++++++
5 files changed, 160 insertions(+)
create mode 100644 Applications/Pgan.PoracleWebNet.Api/Controllers/VersionController.cs
create mode 100644 Tests/Pgan.PoracleWebNet.Tests/Controllers/VersionControllerTests.cs
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index f18fcd81..4fae341f 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -45,6 +45,12 @@ jobs:
type=sha,prefix=main-,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
type=sha,prefix=,enable=${{ github.event_name == 'release' }}
+ # Computed here rather than pulled out of the metadata-action JSON, so the value passed
+ # to the build is plainly visible in the log next to the one baked into the labels.
+ - name: Build timestamp
+ id: build
+ run: echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
+
- name: Build and push
uses: docker/build-push-action@v7
with:
@@ -52,6 +58,12 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+ # Mirrors the OCI labels above into the running container, where GET /api/version can
+ # read them. Labels alone are not visible from inside the container at runtime.
+ build-args: |
+ BUILD_VERSION=${{ steps.meta.outputs.version }}
+ BUILD_REVISION=${{ github.sha }}
+ BUILD_DATE=${{ steps.build.outputs.date }}
cache-from: type=gha
cache-to: type=gha,mode=max
diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/VersionController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/VersionController.cs
new file mode 100644
index 00000000..b65f644a
--- /dev/null
+++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/VersionController.cs
@@ -0,0 +1,53 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Pgan.PoracleWebNet.Api.Controllers;
+
+///
+/// Reports which build is actually running. The image already carries this in its OCI labels,
+/// but those are only readable via `docker inspect` on the host -- which is no help when you
+/// want to know what a deployed instance is serving from outside, or when the image was built
+/// locally and carries no labels at all.
+///
+[ApiController]
+[Route("api/version")]
+public class VersionController(IConfiguration configuration, IHostEnvironment environment) : ControllerBase
+{
+ /// Fallback when the build args were not supplied (local `docker build`, `dotnet run`).
+ internal const string Unknown = "unknown";
+
+ private readonly IConfiguration _configuration = configuration;
+ private readonly IHostEnvironment _environment = environment;
+
+ ///
+ /// Returns the running build's version, git revision and build timestamp.
+ ///
+ ///
+ /// Anonymous on purpose: the main use is checking a deployment from outside without
+ /// credentials. Nothing here is sensitive -- the repository is public, so the commit SHA
+ /// is already visible on GitHub, and no configuration or secret is exposed.
+ ///
+ [HttpGet]
+ [AllowAnonymous]
+ public IActionResult Get()
+ {
+ var revision = Value("BUILD_REVISION");
+
+ return this.Ok(new
+ {
+ version = Value("BUILD_VERSION"),
+ revision,
+ // Short form purely for convenience -- it is what you actually paste into `git log`.
+ revisionShort = revision == Unknown ? Unknown : revision[..Math.Min(7, revision.Length)],
+ buildDate = Value("BUILD_DATE"),
+ environment = this._environment.EnvironmentName,
+ });
+ }
+
+ private string Value(string key)
+ {
+ var value = this._configuration[key];
+
+ return string.IsNullOrWhiteSpace(value) ? Unknown : value;
+ }
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f9ad97f0..6c4d5fef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
+- **`GET /api/version` reports the running build.** Returns `version`, `revision` (git SHA), `revisionShort`, `buildDate` and `environment`, so you can confirm what a deployment is actually serving with a single request. The image's OCI labels already carried this, but labels are only readable via `docker inspect` on the host — no help for checking an instance from outside, and absent entirely from locally-built images. CI now passes `BUILD_VERSION` / `BUILD_REVISION` / `BUILD_DATE` as Docker build args from the same metadata that produces the labels; builds without them report `unknown` rather than failing. The endpoint is anonymous by design (the repository is public, so the commit SHA is not sensitive, and no configuration or secret is exposed).
- **Generic external SSO / OIDC login provider** ([#327](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/327)): PoracleWeb can now delegate login to any external OAuth2/OpenID Connect provider, in addition to the built-in Discord and Telegram methods. This enables single sign-on — e.g. pointing PoracleWeb (`alerts.pogoalerts.net`) at the PogoAlerts OAuth2 server so a user who is already signed into the main site lands in PoracleWeb without re-authenticating — but it is fully **provider-agnostic**: any self-hoster can configure their own IdP. The implementation is a configurable twin of the existing Discord flow. Two new endpoints (`GET /api/auth/oidc/login` and `GET /api/auth/oidc/callback`) handle the authorization-code exchange with **PKCE** (state + verifier persisted in HttpOnly cookies, same CSRF protection as the Discord path), then read a configurable **identity claim** (default `discord_id`, falling back to the standard `sub`) from the provider's UserInfo response and look it up in the Poracle `human` table exactly as a direct Discord login would — so existing admin resolution (`GetRolesAsync`), Discord guild-role gating, and the per-user enable/disable all apply unchanged, and PoracleWeb still mints and validates **its own** JWT (no change to token issuance). Provider config (provider name, authorize/token/userinfo URLs, client id/secret, scopes, claim mapping, PKCE flag) comes from `OIDC_*` env vars / `appsettings` — the secret is never stored in the database — and `OIDC_ENABLED` is auto-inferred when the client id and three URLs are all present (same first-time-setup safeguard as Telegram). A separate `enable_oidc` site setting gives admins a runtime on/off toggle (Features → *External SSO* group on the admin settings page; carried by `SettingsMigrationService`), while admins can always log in even when it's disabled so they can re-enable it. The login page renders a "Sign in with {provider}" button (with the same disabled-by-admin hint pattern as Discord/Telegram) whenever the provider is configured, driven by a new `oidc` block on `GET /api/auth/providers`; a new `/auth/oidc/callback` route reuses the existing token-fragment callback handler. New `OIDC_*` keys documented in `.env.example`, new `AUTH.SIGN_IN_OIDC` / `AUTH.ERR_OIDC_*` and `ADMIN_SETTINGS.*_OIDC` / `GROUP_OIDC` i18n keys added to English (other locales fall back to English until translated). Backend tests cover the `providers` oidc block (configured / not-configured / admin-disabled) and the `/oidc/login` redirect (state + PKCE cookies, provider URL + params); frontend tests cover the OIDC button visibility and click delegation. Wiring ReactMap and the PogoAlerts main site to the same provider, and PogoAlerts-side cross-subdomain session cookies, are separate follow-up work.
- **OIDC refresh-token consumption — silent session renewal + revocation propagation** (opt-in, provider-agnostic): building on the OIDC login above, PoracleWeb can now optionally consume the provider's **refresh token** instead of discarding it, so an SSO session renews silently in the background (no 24-hour hard re-login) and a disable/logout at the provider propagates to PoracleWeb within one short access-token lifetime. It is **off by default** (`OIDC_USE_REFRESH_TOKENS=false`) — existing deployments and providers that don't issue refresh tokens are completely unaffected (the login cleanly falls back to a standard full-lifetime session). The provider refresh token is brokered **entirely server-side**: it's encrypted at rest with DataProtection in a new `oidc_sessions` table (added via EF migration `AddOidcSessions`) and **never sent to the browser**; the browser instead holds an opaque PoracleWeb token in `localStorage` that keys a rotation **family**. A new `POST /api/auth/oidc/refresh` endpoint redeems the stored refresh token against the provider, **re-validates the user live** (existence, `enable_oidc` gate, role access, admin-disable) on every refresh, rotates both tokens, and family-revokes on replay/reuse or when the provider rejects the refresh (revocation propagation); `POST /api/auth/oidc/refresh/revoke` ends a session on logout, and an `OidcSessionCleanupService` reaps expired/stale rows. Refresh-backed OIDC sessions get a short **per-login** JWT (`OIDC_ACCESS_TOKEN_MINUTES`, default 30) while Discord/Telegram/local logins keep the 24-hour JWT — the lifetime override is scoped so non-refresh logins aren't shortened. The implementation is **fully OIDC-provider-agnostic**: `OIDC_OFFLINE_ACCESS_SCOPE` (default `offline_access`) is appended to the authorize request so standards-compliant providers issue a refresh token; `OIDC_TOKEN_AUTH_METHOD` supports both `client_secret_post` and `client_secret_basic`; non-rotating providers (no new refresh token on refresh) are handled by carrying the prior token forward; and nothing relies on discovery/JWKS/`id_token`. The frontend adds a single-flight `TokenStoreService` + an `oidcRefreshInterceptor` (proactive pre-expiry refresh and reactive 401-retry, with a null-refresh-token guard so every non-refresh login keeps the existing "401 → logout" path). Refresh on/off is controlled solely by the `OIDC_USE_REFRESH_TOKENS` env flag — there is intentionally **no** runtime admin toggle, since refresh is coupled to the per-login JWT lifetime (disabling it mid-session would strand already-issued short-lived tokens); its active state is surfaced read-only on `GET /api/auth/providers` (`oidc.refresh`) and `GET /api/settings/oidc-config`. New `OIDC_*` keys documented in `.env.example` with a per-provider config matrix (PogoAlerts, Keycloak, Authentik, Auth0, Google, Azure AD/Entra, Okta), and a full **OIDC Refresh Tokens** documentation page (configuration reference, five Mermaid flow diagrams, the provider matrix, and the security model) added to the docs site. Backend tests cover the session rotation/replay/cap/cleanup mechanics and the provider-agnostic client (auth method, optional/non-rotating refresh tokens); frontend tests cover the token store's single-flight refresh and the interceptor's proactive/reactive/loop-guard behavior.
diff --git a/Dockerfile b/Dockerfile
index 962929aa..c3e3127d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -53,5 +53,16 @@ ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
ENV DATA_DIR=/app/data
+# Build provenance, surfaced at runtime by GET /api/version. The image's OCI labels already
+# carry this, but labels are only readable via `docker inspect` on the host -- useless for
+# checking a deployed instance from outside. CI passes these from the same metadata that
+# produces the labels; local builds leave them "unknown".
+ARG BUILD_VERSION=unknown
+ARG BUILD_REVISION=unknown
+ARG BUILD_DATE=unknown
+ENV BUILD_VERSION=$BUILD_VERSION
+ENV BUILD_REVISION=$BUILD_REVISION
+ENV BUILD_DATE=$BUILD_DATE
+
USER appuser
ENTRYPOINT ["dotnet", "Pgan.PoracleWebNet.Api.dll"]
diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/VersionControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/VersionControllerTests.cs
new file mode 100644
index 00000000..afd9bdcf
--- /dev/null
+++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/VersionControllerTests.cs
@@ -0,0 +1,83 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+using Moq;
+using Pgan.PoracleWebNet.Api.Controllers;
+
+namespace Pgan.PoracleWebNet.Tests.Controllers;
+
+public class VersionControllerTests
+{
+ private const string Sha = "3f8d38aa4724209ec7ebaf4f0a1053d063008734";
+
+ [Fact]
+ public void ReturnsBuildMetadataFromConfiguration()
+ {
+ var sut = Build(new()
+ {
+ ["BUILD_VERSION"] = "beta",
+ ["BUILD_REVISION"] = Sha,
+ ["BUILD_DATE"] = "2026-08-05T14:22:16Z",
+ });
+
+ var payload = Payload(sut.Get());
+
+ Assert.Equal("beta", Read(payload, "version"));
+ Assert.Equal(Sha, Read(payload, "revision"));
+ Assert.Equal("2026-08-05T14:22:16Z", Read(payload, "buildDate"));
+ Assert.Equal("Production", Read(payload, "environment"));
+ }
+
+ [Fact]
+ public void ShortensRevisionToSevenCharacters()
+ {
+ var sut = Build(new() { ["BUILD_REVISION"] = Sha });
+
+ Assert.Equal("3f8d38a", Read(Payload(sut.Get()), "revisionShort"));
+ }
+
+ [Fact]
+ public void FallsBackToUnknownWhenBuildArgsWereNotSupplied()
+ {
+ // A local `docker build` or `dotnet run` passes no build args at all.
+ var payload = Payload(Build([]).Get());
+
+ Assert.Equal(VersionController.Unknown, Read(payload, "version"));
+ Assert.Equal(VersionController.Unknown, Read(payload, "revision"));
+ Assert.Equal(VersionController.Unknown, Read(payload, "revisionShort"));
+ Assert.Equal(VersionController.Unknown, Read(payload, "buildDate"));
+ }
+
+ [Fact]
+ public void TreatsBlankValuesAsUnknown()
+ {
+ // An unset build arg reaches the container as an empty string, not a missing key.
+ var payload = Payload(Build(new() { ["BUILD_VERSION"] = "", ["BUILD_REVISION"] = " " }).Get());
+
+ Assert.Equal(VersionController.Unknown, Read(payload, "version"));
+ Assert.Equal(VersionController.Unknown, Read(payload, "revision"));
+ }
+
+ [Fact]
+ public void DoesNotTruncateARevisionShorterThanSevenCharacters()
+ {
+ var payload = Payload(Build(new() { ["BUILD_REVISION"] = "abc" }).Get());
+
+ Assert.Equal("abc", Read(payload, "revisionShort"));
+ }
+
+ private static VersionController Build(Dictionary values)
+ {
+ var environment = new Mock();
+ environment.SetupGet(e => e.EnvironmentName).Returns("Production");
+
+ return new VersionController(
+ new ConfigurationBuilder().AddInMemoryCollection(values).Build(),
+ environment.Object);
+ }
+
+ private static object Payload(IActionResult result) => Assert.IsType(result).Value!;
+
+ private static string Read(object payload, string property) =>
+ payload.GetType().GetProperty(property)!.GetValue(payload)!.ToString()!;
+}