v2.12.0
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.OpenApiadvisory (GHSA-v5pm-xwqc-g5wc) and tightensReferrer-Policytosame-origin. - Dependencies — .NET 10.0.10, Angular 21.2.19,
@ngx-translatev18, 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/versionreports the running build. Returnsversion,revision(git SHA),revisionShort,buildDateandenvironment, 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 viadocker inspecton the host — no help for checking an instance from outside, and absent entirely from locally-built images. CI now passesBUILD_VERSION/BUILD_REVISION/BUILD_DATEas Docker build args from the same metadata that produces the labels; builds without them reportunknownrather 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/loginandGET /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 (defaultdiscord_id, falling back to the standardsub) from the provider's UserInfo response and look it up in the Poraclehumantable 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 fromOIDC_*env vars /appsettings— the secret is never stored in the database — andOIDC_ENABLEDis auto-inferred when the client id and three URLs are all present (same first-time-setup safeguard as Telegram). A separateenable_oidcsite setting gives admins a runtime on/off toggle (Features → External SSO group on the admin settings page; carried bySettingsMigrationService), 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 newoidcblock onGET /api/auth/providers; a new/auth/oidc/callbackroute reuses the existing token-fragment callback handler. NewOIDC_*keys documented in.env.example, newAUTH.SIGN_IN_OIDC/AUTH.ERR_OIDC_*andADMIN_SETTINGS.*_OIDC/GROUP_OIDCi18n keys added to English (other locales fall back to English until translated). Backend tests cover theprovidersoidc block (configured / not-configured / admin-disabled) and the/oidc/loginredirect (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 newoidc_sessionstable (added via EF migrationAddOidcSessions) and never sent to the browser; the browser instead holds an opaque PoracleWeb token inlocalStoragethat keys a rotation family. A newPOST /api/auth/oidc/refreshendpoint redeems the stored refresh token against the provider, re-validates the user live (existence,enable_oidcgate, 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/revokeends a session on logout, and anOidcSessionCleanupServicereaps 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(defaultoffline_access) is appended to the authorize request so standards-compliant providers issue a refresh token;OIDC_TOKEN_AUTH_METHODsupports bothclient_secret_postandclient_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-flightTokenStoreService+ anoidcRefreshInterceptor(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 theOIDC_USE_REFRESH_TOKENSenv 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 onGET /api/auth/providers(oidc.refresh) andGET /api/settings/oidc-config. NewOIDC_*keys documented in.env.examplewith 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.mdeven carried a.ForAllMembers(opts => opts.Condition(...))snippet that exists nowhere in the codebase. The mapping sections inarchitecture/overview.mdandarchitecture/backend.mdnow describeAlarmMappingExtensions(To*()/ApplyUpdate()) andEntityMappingExtensions(ToModel()/ToEntity()/ApplyTo()), with a realApplyUpdatesnippet showing the explicit null-skip guards; theCore.Mappings/line in the solution tree, the test-coverage bullet indevelopment/testing.md, and a passing mention inarchitecture/poracleng-proxy.mdare corrected to match. The last piece of AutoMapper residue outside the docs goes with it: the mapping test file was still namedPoracleMappingProfileTests.cswhile the class inside it had been renamed toMappingExtensionTests, so the file is renamed to match. No behaviour change. - Renamed
ScannerDbContexttoScannerContext(#240): the scanner DB context was the only one of the threeDbContextsubclasses carrying aDbin its name, out of step with its siblingsPoracleContextandPoracleWebContext. Renamed the class, its file, theDbContextOptions<>type argument, theScannerServiceconstructor parameter and field, and theAddDbContext<>registration, plus the two doc mentions. Purely cosmetic — no functional change:IScannerServiceis untouched, the optionalConnectionStrings:ScannerDbkey 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 positiveenable_*toggles; they are now uniformly positive (ON = enabled, labels are the feature name, descriptions are "Let users …"). The storeddisable_*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
unhealthywhile serving traffic normally (#239): the Compose healthcheck probes the app withcurl -sf http://localhost:8080/, but themcr.microsoft.com/dotnet/aspnet:10.0runtime base (Ubuntu 24.04) ships neithercurlnorwget, so every probe failed with/bin/sh: 1: curl: not foundand Docker flipped the container tounhealthyafter 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 treatunhealthyas a restart/evict signal, so it would misfire in any real deployment. The runtime stage now installscurl(--no-install-recommends, apt lists removed, ~6.5 MB) before dropping to theappuseraccount. Verified in a built image:curl 8.5.0resolves and runs asappuser. - Role gating required every listed role, and quoted values locked everyone out (#367):
allowed_role_idsis described everywhere as an allow-list of Discord roles that grant access, butCheckRoleAccessAsynccompared withHashSet.IsSubsetOf, which only returned true when the user held all of the listed roles. Configuring123,456therefore denied anyone who had just one of them. It now matches onOverlaps— 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"123456789that 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 withrole_check_failedand 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
Refererheader to third-party hosts (#242): the gym picker renders two kinds of remote image — the scanner DB'sgym.urlphoto (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 fromraw.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 ingym-picker.component.htmlnow setreferrerpolicy="no-referrer". Modern browsers already default tostrict-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, andGymSearchResult.Urlstill 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-Policytightened tosame-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 fromraw.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 fromcdn.discordapp.com, and the Google Fonts stylesheets inindex.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 sentstrict-origin-when-cross-origin(the browser default, which sends the origin cross-origin); it now sendssame-origin— full referrer within the site, nothing at all to third parties — fixing every case in one place rather than annotating tags individually.no-referrerwas considered and rejected:AuthControllerreads theRefererheader onDiscordLogin, 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 inProgram.csinto aSecurityHeadersclass 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 becomesno-referreror any of the origin-leaking values, and the previously untestedAuthControllerorigin recovery it depends on (allowed referer honored, disallowed and non-absolute referers rejected, absent referer falling back to self). The per-elementreferrerpolicyattributes from #242 are left in place as defence-in-depth.