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()!; +}