Skip to content

v2.12.0

Choose a tag to compare

@hokiepokedad2 hokiepokedad2 released this 05 Aug 15:35
e358375

PoracleWeb.NET v2.12.0 — first release since v2.11.1 (2026-06-05), 39 commits.

Highlights

  • External SSO / OIDC login — delegate authentication to any OAuth2/OIDC provider, with optional refresh-token sessions for silent renewal and revocation propagation. Off by default; existing Discord/Telegram logins are unaffected.
  • GET /api/version — reports the running build (version, git revision, build date) so you can confirm what a deployment is serving with one request.
  • Security — patches a high-severity Microsoft.OpenApi advisory (GHSA-v5pm-xwqc-g5wc) and tightens Referrer-Policy to same-origin.
  • Dependencies — .NET 10.0.10, Angular 21.2.19, @ngx-translate v18, plus GitHub Actions majors.

Upgrade notes

One EF Core migration (AddOidcSessions) applies automatically on first start, creating the oidc_sessions table in the poracle_web database. No configuration changes are required — OIDC stays disabled unless you set the OIDC_* variables.

Changelog

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): 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.

Changed

  • Removed stale AutoMapper references from the docs site (#241): AutoMapper was dropped in v2.6.0 (#173) in favour of manual mapping extensions, but four docs pages still described it as the live mapping layer — architecture/backend.md even carried a .ForAllMembers(opts => opts.Condition(...)) snippet that exists nowhere in the codebase. The mapping sections in architecture/overview.md and architecture/backend.md now describe AlarmMappingExtensions (To*() / ApplyUpdate()) and EntityMappingExtensions (ToModel() / ToEntity() / ApplyTo()), with a real ApplyUpdate snippet showing the explicit null-skip guards; the Core.Mappings/ line in the solution tree, the test-coverage bullet in development/testing.md, and a passing mention in architecture/poracleng-proxy.md are corrected to match. The last piece of AutoMapper residue outside the docs goes with it: the mapping test file was still named PoracleMappingProfileTests.cs while the class inside it had been renamed to MappingExtensionTests, so the file is renamed to match. No behaviour change.
  • Renamed ScannerDbContext to ScannerContext (#240): the scanner DB context was the only one of the three DbContext subclasses carrying a Db in its name, out of step with its siblings PoracleContext and PoracleWebContext. Renamed the class, its file, the DbContextOptions<> type argument, the ScannerService constructor parameter and field, and the AddDbContext<> registration, plus the two doc mentions. Purely cosmetic — no functional change: IScannerService is untouched, the optional ConnectionStrings:ScannerDb key is unchanged, no EF migrations are involved (the scanner DB is read-only and never migrated by PoracleWeb), and there is no wire-contract or config impact for self-hosters.
  • Localized the external SSO / OIDC strings across all bundled locales. The SSO login feature added 30 i18n keys to English only, so every non-English locale fell back to English for the "Sign in with {provider}" button, the signed-out panel, the OIDC error messages, and the admin Authentication / External SSO settings group. These are now translated into Danish, German, Spanish, French, Italian, Dutch, Polish, Portuguese (PT & BR), and Swedish. Translation-only — no code or behavior change.
  • Admin Server Settings page UX overhaul. Adds a live search/filter (sticky bar, match highlighting, / or Ctrl/Cmd+K to focus), a sticky save + discard bar so saving is always reachable on the long page, sign-in providers grouped under Authentication (Telegram/Discord moved up), and collapsible sections (persisted) with per-section "unsaved" chips and state summaries (e.g. "7 of 9 enabled"). Headline fix: the alarm-type/feature toggles were a confusing double negative ("Disable X", ON = feature off) mixed with positive enable_* toggles; they are now uniformly positive (ON = enabled, labels are the feature name, descriptions are "Let users …"). The stored disable_* keys are unchanged — a presentation-only inversion — so backend feature-gating is unaffected. New UX i18n keys and the reframed positive labels/descriptions are translated across all 11 locales.

Fixed

  • Containers reported unhealthy while serving traffic normally (#239): the Compose healthcheck probes the app with curl -sf http://localhost:8080/, but the mcr.microsoft.com/dotnet/aspnet:10.0 runtime base (Ubuntu 24.04) ships neither curl nor wget, so every probe failed with /bin/sh: 1: curl: not found and Docker flipped the container to unhealthy after three tries. ASP.NET Core was live the whole time, so the impact was cosmetic on a single host — but Swarm, Kubernetes, and auto-healer scripts treat unhealthy as a restart/evict signal, so it would misfire in any real deployment. The runtime stage now installs curl (--no-install-recommends, apt lists removed, ~6.5 MB) before dropping to the appuser account. Verified in a built image: curl 8.5.0 resolves and runs as appuser.
  • Role gating required every listed role, and quoted values locked everyone out (#367): allowed_role_ids is described everywhere as an allow-list of Discord roles that grant access, but CheckRoleAccessAsync compared with HashSet.IsSubsetOf, which only returned true when the user held all of the listed roles. Configuring 123,456 therefore denied anyone who had just one of them. It now matches on Overlaps — holding any one listed role is enough. Two smaller problems around the same setting are fixed with it. The setting's tooltip rendered its example wrapped in quotes ("123456789,987654321"), so admins pasted the quotes in and the comma-split produced entries like "123456789 that can never equal a Discord role ID; every non-admin was then denied with nothing but an info-level log to explain it (admins bypass the role check, which is why the site looked admin-only). Values are now parsed with surrounding quotes stripped — straight, curly, guillemet, and low-9 variants, matching the quote styles used across the translated tooltips — and entries that aren't numeric snowflakes are dropped and logged as a warning instead of being kept as unmatchable garbage. If a non-empty setting yields no usable IDs at all, non-admin logins are refused with role_check_failed and an error log rather than silently falling open to "allow everyone". Finally the tooltip copy (all 11 locales) and the settings/SSO docs now drop the misleading quotes and state the any-of semantics outright. Both callers are affected — the Discord OAuth callback and the OIDC/external-SSO path share this check. Unit tests cover the parser (quote styles, whitespace, non-snowflake entries, dedup, empty values) and the any-of grant/deny decision.

Security

  • Gym-picker images no longer send a Referer header to third-party hosts (#242): the gym picker renders two kinds of remote image — the scanner DB's gym.url photo (a Niantic CDN URL in stock Golbat/RDM deployments, though an operator can rewrite the column to point at a self-hosted mirror) and the team-icon fallback from raw.githubusercontent.com. Neither carried a referrer policy, so every image request told the remote host which PoracleWeb instance the user was browsing. All four <img> tags in gym-picker.component.html now set referrerpolicy="no-referrer". Modern browsers already default to strict-origin-when-cross-origin, so the pre-existing leak was the origin rather than the full URL — this closes the remainder. Presentation-only: no API, model, or scanner-query change, and GymSearchResult.Url still carries the raw scanner URL as before. The photo-proxy endpoint floated in the original issue was not implemented: server-side fetching of a URL supplied by a database PoracleWeb does not own would turn a passive disclosure into an authenticated outbound-request primitive from a host that can reach Poracle, Koji, Golbat, and both MySQL servers. A host allowlist applied at projection remains the cheaper option if a deployment ever needs the mirror case handled.
  • App-wide Referrer-Policy tightened to same-origin, so no remote host learns the instance origin (#383): the per-element fix above covered the gym picker, but the same leak existed everywhere else the SPA loads a remote resource — uicons from raw.githubusercontent.com (icon.service.ts, operator-overridable, so possibly a self-hosted mirror) across the Pokémon/raid/egg/lure/invasion/gym/quick-pick lists and dialogs, Discord avatars from cdn.discordapp.com, and the Google Fonts stylesheets in index.html. Each request disclosed the origin of the PoracleWeb instance being browsed, which for a private or invite-only deployment is the part worth withholding. The security-headers middleware previously sent strict-origin-when-cross-origin (the browser default, which sends the origin cross-origin); it now sends same-origin — full referrer within the site, nothing at all to third parties — fixing every case in one place rather than annotating tags individually. no-referrer was considered and rejected: AuthController reads the Referer header on DiscordLogin, the OIDC login path, and OIDC RP-initiated logout to recover which frontend origin the user came from, validate it against the configured CORS origins, and redirect back there after the provider callback — blanking the same-origin referrer would degrade all three to this host's own origin and bounce users to the wrong place. The header values moved out of the inline lambda in Program.cs into a SecurityHeaders class so they're assertable without booting the app; the CSP is carried over byte-identical (a test pins it against the original literal). Tests cover the policy value, a guard that it never becomes no-referrer or any of the origin-leaking values, and the previously untested AuthController origin recovery it depends on (allowed referer honored, disallowed and non-absolute referers rejected, absent referer falling back to self). The per-element referrerpolicy attributes from #242 are left in place as defence-in-depth.