feat(users): OIDC RP-initiated single logout [shot:settings] (bookshelf-tm38.9) #1398

Merged
zombor merged 4 commits from bd-bookshelf-tm38.9 into main 2026-08-08 16:16:58 +00:00
Owner

Summary

On local /logout, redirect the browser to the OIDC provider's end_session_endpoint
(OpenID Connect RP-Initiated Logout 1.0) so the IdP session ends too, when:

  • the user's session came from OIDC (detected via a short-lived HttpOnly ID-token
    cookie set at OIDC callback, cleared on local login and on logout),
  • the new RP-initiated logout admin toggle in Settings → OIDC is on (default
    off — not all IdPs support it and some operators don't want the extra redirect
    hop), and
  • the provider's discovery document exposes an end_session_endpoint.

post_logout_redirect_uri is derived from the trusted request host (never from
user input, mirroring oidcRedirectURL) so it is same-origin by construction.
Falls back to the existing local-only /login redirect when any condition above
isn't met, or when discovery fails transiently — a user's logout is never
blocked by an IdP hiccup.

Adds the admin toggle to the OIDC settings page (canonical .provider-settings-*
/ .form-hint components, no inline style=) and updates the OIDC docs page
with a Single Logout section.

Test plan

  • internal/users: unit tests for OIDCLogoutRedirectURL, logoutHandler
    (with/without OIDC session, JSON client, discovery failure), OIDC callback
    sets/omits the ID-token cookie, local login clears a stale one — 100%
    coverage on all new/changed functions.
  • internal/settings: unit tests for the new RPInitiatedLogout setting
    round-tripping through save/load/GetOIDCConfig — 100% coverage.
  • static/js: Vitest coverage for the new rpInitiatedLogout controller
    target/payload field — 100% coverage.
  • e2e/browser: extended the existing Journey: Settings OIDC spec to
    assert the toggle renders, gets saved, and persists across reload — ran
    locally (ginkgo --focus "Journey: Settings"), 14/14 passed.
  • make lint / make test / go build ./... all green locally.
  • Docs: updated docs/content/docs/administering/oidc.md with a Single
    Logout section (end-user/admin framing).

Closes bead bookshelf-tm38.9 on merge.

## Summary On local `/logout`, redirect the browser to the OIDC provider's `end_session_endpoint` (OpenID Connect RP-Initiated Logout 1.0) so the IdP session ends too, when: - the user's session came from OIDC (detected via a short-lived HttpOnly ID-token cookie set at OIDC callback, cleared on local login and on logout), - the new **RP-initiated logout** admin toggle in Settings → OIDC is on (default off — not all IdPs support it and some operators don't want the extra redirect hop), and - the provider's discovery document exposes an `end_session_endpoint`. `post_logout_redirect_uri` is derived from the trusted request host (never from user input, mirroring `oidcRedirectURL`) so it is same-origin by construction. Falls back to the existing local-only `/login` redirect when any condition above isn't met, or when discovery fails transiently — a user's logout is never blocked by an IdP hiccup. Adds the admin toggle to the OIDC settings page (canonical `.provider-settings-*` / `.form-hint` components, no inline `style=`) and updates the OIDC docs page with a Single Logout section. ## Test plan - [x] `internal/users`: unit tests for `OIDCLogoutRedirectURL`, `logoutHandler` (with/without OIDC session, JSON client, discovery failure), OIDC callback sets/omits the ID-token cookie, local login clears a stale one — 100% coverage on all new/changed functions. - [x] `internal/settings`: unit tests for the new `RPInitiatedLogout` setting round-tripping through save/load/`GetOIDCConfig` — 100% coverage. - [x] `static/js`: Vitest coverage for the new `rpInitiatedLogout` controller target/payload field — 100% coverage. - [x] `e2e/browser`: extended the existing `Journey: Settings` OIDC spec to assert the toggle renders, gets saved, and persists across reload — ran locally (`ginkgo --focus "Journey: Settings"`), 14/14 passed. - [x] `make lint` / `make test` / `go build ./...` all green locally. - [x] Docs: updated `docs/content/docs/administering/oidc.md` with a Single Logout section (end-user/admin framing). Closes bead bookshelf-tm38.9 on merge.
feat(users): OIDC RP-initiated single logout (bookshelf-tm38.9)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m3s
/ Hugo build (pull_request) Successful in 1m7s
/ E2E API (pull_request) Successful in 1m43s
/ Test Race (pull_request) Successful in 2m18s
/ Coverage (pull_request) Successful in 2m48s
/ Integration (pull_request) Successful in 2m53s
/ Lint (pull_request) Successful in 3m5s
/ E2E Browser (pull_request) Successful in 4m42s
797929974f
On local /logout, redirect the browser to the OIDC provider's
end_session_endpoint (OpenID Connect RP-Initiated Logout 1.0) so the IdP
session ends too, when:
  - the user's session came from OIDC (detected via a short-lived
    HttpOnly ID-token cookie set at OIDC callback, cleared on local login
    and on logout),
  - the new "RP-initiated logout" admin toggle in Settings → OIDC is on
    (default off — not all IdPs support it and some operators don't want
    the extra redirect hop), and
  - the provider's discovery document exposes an end_session_endpoint.

post_logout_redirect_uri is derived from the trusted request host (never
from user input, mirroring oidcRedirectURL) so it is same-origin by
construction. Falls back to the existing local-only /login redirect when
any condition above isn't met, or when discovery fails transiently — a
user's logout is never blocked by an IdP hiccup.

Adds the admin toggle to the OIDC settings page and updates the OIDC docs
page with a Single Logout section.

Closes bead bookshelf-tm38.9 on merge.

Settings journey screenshot (settings-shell-email-tab-modal)

settings-shell-email-tab-modal

**Settings journey screenshot** (settings-shell-email-tab-modal) ![settings-shell-email-tab-modal](/attachments/634dd96b-4308-4a8f-9a7c-4f1ab1a3759f)

Settings journey screenshot (settings-shell-metadata-tab)

settings-shell-metadata-tab

**Settings journey screenshot** (settings-shell-metadata-tab) ![settings-shell-metadata-tab](/attachments/62222b84-1bf8-45be-8cce-c2cea17d0827)

Settings journey screenshot (settings-ratelimits-comicvine-throttled)

settings-ratelimits-comicvine-throttled

**Settings journey screenshot** (settings-ratelimits-comicvine-throttled) ![settings-ratelimits-comicvine-throttled](/attachments/5a75c43b-cf17-41d1-800d-8e116c027ea9)

Settings journey screenshot (settings-comic-weights-max-saves)

settings-comic-weights-max-saves

**Settings journey screenshot** (settings-comic-weights-max-saves) ![settings-comic-weights-max-saves](/attachments/d11e09a9-cabd-473d-b3c7-8f576d9a6822)

Settings journey screenshot (settings-comic-field-priority-matrix-saved)

settings-comic-field-priority-matrix-saved

**Settings journey screenshot** (settings-comic-field-priority-matrix-saved) ![settings-comic-field-priority-matrix-saved](/attachments/5dd7075a-789d-4f7c-a976-d53a68c01cd2)

Settings journey screenshot (oidc-settings-form-fields)

oidc-settings-form-fields

**Settings journey screenshot** (oidc-settings-form-fields) ![oidc-settings-form-fields](/attachments/dfcdc7e6-b4c6-44c7-be85-79e967985da2)

Settings journey screenshot (oidc-rp-initiated-logout-toggle-checked)

oidc-rp-initiated-logout-toggle-checked

**Settings journey screenshot** (oidc-rp-initiated-logout-toggle-checked) ![oidc-rp-initiated-logout-toggle-checked](/attachments/f83722e6-6b1f-4ece-b8f8-d49ed37c8d54)

Settings journey screenshot (oidc-settings-after-reload)

oidc-settings-after-reload

**Settings journey screenshot** (oidc-settings-after-reload) ![oidc-settings-after-reload](/attachments/3eef127e-904f-4013-845d-925938b70e41)

Settings journey screenshot (oidc-test-connection-result)

oidc-test-connection-result

**Settings journey screenshot** (oidc-test-connection-result) ![oidc-test-connection-result](/attachments/91bcdd87-a74f-4d63-9eb8-7d7bf9f86248)

Settings journey screenshot (oidc-group-mapping-modal-open)

oidc-group-mapping-modal-open

**Settings journey screenshot** (oidc-group-mapping-modal-open) ![oidc-group-mapping-modal-open](/attachments/5bb8f0ba-2d1c-4fbb-aeec-eaef25277bd1)

Settings journey screenshot (oidc-group-mapping-after-create)

oidc-group-mapping-after-create

**Settings journey screenshot** (oidc-group-mapping-after-create) ![oidc-group-mapping-after-create](/attachments/98855624-5da3-46e2-8daa-90f809547f94)

Settings journey screenshot (oidc-group-mapping-after-edit)

oidc-group-mapping-after-edit

**Settings journey screenshot** (oidc-group-mapping-after-edit) ![oidc-group-mapping-after-edit](/attachments/226e6766-c39c-4981-97bd-7106d4a612c2)

Settings journey screenshot (oidc-group-mapping-after-delete)

oidc-group-mapping-after-delete

**Settings journey screenshot** (oidc-group-mapping-after-delete) ![oidc-group-mapping-after-delete](/attachments/8504287d-5f78-4c57-b35d-e2a8338e290d)

Settings journey screenshot (sidecar-settings-toggle-saved)

sidecar-settings-toggle-saved

**Settings journey screenshot** (sidecar-settings-toggle-saved) ![sidecar-settings-toggle-saved](/attachments/a90869b9-dcae-4f6a-b341-9c678fe7a1af)

Settings journey screenshot (sidecar-settings-toggle-persisted)

sidecar-settings-toggle-persisted

**Settings journey screenshot** (sidecar-settings-toggle-persisted) ![sidecar-settings-toggle-persisted](/attachments/0f3741be-fec2-403e-b610-350c84269b7d)
Author
Owner

[MAJOR] internal/users/oidc_handler.go:107-125,238 — ID-token cookie is not "short-lived": it lives as long as the refresh token (default 30 days)
setOIDCIDTokenCookie is documented as stashing the raw OIDC ID token "in a short-lived, HttpOnly cookie", but oidcCallbackHandler (line 238) calls it with expiry = result.RefreshTokenExpiry, and RefreshTokenTTL defaults to 720h/30 days (internal/config/config.go:444). The ID token contains PII/claims (sub, email, name, groups, aud, iss) and its own exp claim is normally minutes-to-hours — far shorter than 30 days. Storing the raw ID token in a cookie for the full refresh-token lifetime (a) needlessly widens the PII-exposure window (30 days of disk/browser-cookie-store persistence for a token whose IdP-side validity is long past), and (b) is likely to break the very feature it supports: several IdPs (Keycloak, Auth0, etc.) reject an id_token_hint whose own exp has elapsed, so RP-initiated logout will silently fall back to local-only logout for any session older than the ID token's real TTL — which for most providers is much less than 30 days. Fix: bind the cookie's expiry to the ID token's own exp claim (already verified in verifyIDToken/oidcVerifyAndProvision) or to AccessTokenExpiry (short), not RefreshTokenExpiry.

[MINOR] internal/users/oidc_service.go — state generated for RP-initiated logout is never validated on return
OIDCLogoutRedirectURL calls generateOIDCState() and appends state to the end_session_endpoint redirect, but the IdP's post-logout callback lands on the trusted /login route (oidcPostLogoutRedirectURI), which does not read or verify any state query parameter. The value is emitted per spec but is otherwise dead protection — harmless, but worth a short comment noting it's intentionally unverified (no CSRF-relevant action happens at /login) so a future reader doesn't assume it's checked somewhere.

[MINOR] internal/settings/oidc_handler.go:158 — stray trailing blank-line removal is unrelated churn
Line 158 removes a trailing blank line at EOF unrelated to this feature; harmless but adds unrelated diff noise. No action needed, just noting for a tighter diff next time.

Wiring, admin gating, fallback correctness, cookie flags (HttpOnly/Secure/SameSite=Lax), same-origin post_logout_redirect_uri construction, curried DI, black-box tests, JS/CSS convention reuse, and docs are all verified correct — see notes below.

Verified as correct (no findings):

  • Wiring: internal/users/wire.go:249-253 binds OIDCLogoutRedirectURL into Deps; logoutHandler (internal/users/handler.go) reads the bookshelf_oidc_idtoken cookie, calls logoutOIDCRedirectURL, and redirects (HTML) or returns logout_url (JSON) when non-empty, else falls back to /login. Not inert.
  • Admin gating: PUT /settings/oidc stays behind adminRequired (internal/settings/routes.go:62) — unchanged, still applies to the new rp_initiated_logout field.
  • Fallback: OIDCLogoutRedirectURL returns "", nil (not an error) on disabled feature, missing id token, discovery failure, or missing end_session_endpoint — logout never hangs or 500s in those cases.
  • Cookie clearing: clearOIDCIDTokenCookie is called on both local login (handler.go) and logout, preventing stale id_token_hint leakage across session types; covered by tests.
  • post_logout_redirect_uri: built only from r.Host/AllowedHost + hardcoded /login path, mirroring the existing oidcRedirectURL pattern — not attacker-influenced beyond the same Host-header trust boundary that already exists for the OIDC callback URL.
  • Tests: black-box (package users_test/settings_test), curried DI preserved, JS controller has new Vitest coverage for the checkbox, browser e2e journey extended to persist+reload the toggle.
  • No .golangci.yml/coverage-exclusion changes.
  • Docs: docs/content/docs/administering/oidc.md updated with end-user admin instructions, no source-code references.

REVIEW VERDICT: 0 blocker, 1 major, 2 minor

[MAJOR] internal/users/oidc_handler.go:107-125,238 — ID-token cookie is not "short-lived": it lives as long as the refresh token (default 30 days) `setOIDCIDTokenCookie` is documented as stashing the raw OIDC ID token "in a short-lived, HttpOnly cookie", but `oidcCallbackHandler` (line 238) calls it with `expiry = result.RefreshTokenExpiry`, and `RefreshTokenTTL` defaults to 720h/30 days (internal/config/config.go:444). The ID token contains PII/claims (sub, email, name, groups, aud, iss) and its own `exp` claim is normally minutes-to-hours — far shorter than 30 days. Storing the raw ID token in a cookie for the full refresh-token lifetime (a) needlessly widens the PII-exposure window (30 days of disk/browser-cookie-store persistence for a token whose IdP-side validity is long past), and (b) is likely to break the very feature it supports: several IdPs (Keycloak, Auth0, etc.) reject an `id_token_hint` whose own `exp` has elapsed, so RP-initiated logout will silently fall back to local-only logout for any session older than the ID token's real TTL — which for most providers is much less than 30 days. Fix: bind the cookie's expiry to the ID token's own `exp` claim (already verified in `verifyIDToken`/`oidcVerifyAndProvision`) or to `AccessTokenExpiry` (short), not `RefreshTokenExpiry`. [MINOR] internal/users/oidc_service.go — `state` generated for RP-initiated logout is never validated on return `OIDCLogoutRedirectURL` calls `generateOIDCState()` and appends `state` to the end_session_endpoint redirect, but the IdP's post-logout callback lands on the trusted `/login` route (`oidcPostLogoutRedirectURI`), which does not read or verify any `state` query parameter. The value is emitted per spec but is otherwise dead protection — harmless, but worth a short comment noting it's intentionally unverified (no CSRF-relevant action happens at `/login`) so a future reader doesn't assume it's checked somewhere. [MINOR] internal/settings/oidc_handler.go:158 — stray trailing blank-line removal is unrelated churn Line 158 removes a trailing blank line at EOF unrelated to this feature; harmless but adds unrelated diff noise. No action needed, just noting for a tighter diff next time. Wiring, admin gating, fallback correctness, cookie flags (HttpOnly/Secure/SameSite=Lax), same-origin post_logout_redirect_uri construction, curried DI, black-box tests, JS/CSS convention reuse, and docs are all verified correct — see notes below. Verified as correct (no findings): - Wiring: internal/users/wire.go:249-253 binds `OIDCLogoutRedirectURL` into `Deps`; `logoutHandler` (internal/users/handler.go) reads the `bookshelf_oidc_idtoken` cookie, calls `logoutOIDCRedirectURL`, and redirects (HTML) or returns `logout_url` (JSON) when non-empty, else falls back to `/login`. Not inert. - Admin gating: `PUT /settings/oidc` stays behind `adminRequired` (internal/settings/routes.go:62) — unchanged, still applies to the new `rp_initiated_logout` field. - Fallback: `OIDCLogoutRedirectURL` returns `"", nil` (not an error) on disabled feature, missing id token, discovery failure, or missing `end_session_endpoint` — logout never hangs or 500s in those cases. - Cookie clearing: `clearOIDCIDTokenCookie` is called on both local login (handler.go) and logout, preventing stale `id_token_hint` leakage across session types; covered by tests. - post_logout_redirect_uri: built only from `r.Host`/`AllowedHost` + hardcoded `/login` path, mirroring the existing `oidcRedirectURL` pattern — not attacker-influenced beyond the same Host-header trust boundary that already exists for the OIDC callback URL. - Tests: black-box (`package users_test`/`settings_test`), curried DI preserved, JS controller has new Vitest coverage for the checkbox, browser e2e journey extended to persist+reload the toggle. - No `.golangci.yml`/coverage-exclusion changes. - Docs: `docs/content/docs/administering/oidc.md` updated with end-user admin instructions, no source-code references. REVIEW VERDICT: 0 blocker, 1 major, 2 minor
Author
Owner

Security review — PR #1398 (bookshelf-tm38.9, OIDC RP-initiated single logout)

[MAJOR] internal/users/oidc_service.go:804-826 — discovery-supplied end_session_endpoint is redirected to with only url.Parse validation
OIDCLogoutRedirectURL takes meta.EndSessionEndpoint straight from the provider's /.well-known/openid-configuration discovery document and, after only a syntactic url.Parse check, builds a URL that includes the raw id_token_hint (the user's OIDC ID token — a PII-bearing artifact) and 302-redirects the browser to it (internal/users/handler.go:369-372). Unlike the JWKS/token endpoints (which are only ever used for a server-side fetch), this is a browser redirect target, so if the configured issuer's discovery document is ever compromised or malicious (rotated infra, on-path MITM outside TLS pinning, or an intentionally hostile "identity provider" an admin points at), end_session_endpoint can be set to an arbitrary off-origin URL. Pergamum will then redirect every logging-out user's browser to that URL with their raw ID token attached as a query parameter — a discovery-driven open redirect that also exfiltrates the ID token to an attacker-controlled host (classic phishing/token-leak vector, and the same trust-boundary gap flagged for jwks_uri in #1396). RFC 8414 §3.3/OIDC Discovery's issuer-match check (already present in DiscoverOIDCMeta) only prevents a substituted document from a different issuer being accepted — it does nothing to constrain what the legitimate configured issuer's document is allowed to put in end_session_endpoint.
Fix: validate endSessionURL.Scheme == "https" (reject non-https before redirecting) and, ideally, that endSessionURL.Host matches (or is a subdomain of) the configured issuer's host — mirroring whatever guard is chosen for the jwks_uri finding in #1396 so both discovery-sourced endpoints get the same allowlisting treatment.

[MINOR] internal/users/oidc_service.go:814,823 — state is generated for the logout redirect but never verified on return
A state parameter is generated and attached to the end_session_endpoint redirect, but the post_logout_redirect_uri is a static /login route with no logout-callback handler that checks the returned state matches what was issued (no cookie/session correlation is set up before the redirect). This has no real security impact here since the local Pergamum session is already fully torn down (cookies cleared) before the redirect fires, so there's nothing sensitive left to protect on return — but the state param as implemented provides no actual CSRF/binding value, just spec-compliance decoration. Consider either wiring a matching cookie + verification on /login?state=... or noting in the code comment that state here is emitted for spec compliance only and not verified.

What was checked and found OK:

  • Discovery fetch reuses the existing shared discoveryCached.discover (same cache/singleflight used by login), fetching from the admin-configured issuer URL — not a new unguarded server-side fetch, and not attacker-influenceable input (unlike a per-request URL). No new SSRF surface introduced by this diff.
  • ID-token cookie (bookshelf_oidc_idtoken, internal/users/oidc_handler.go:614-643) is HttpOnly, Secure (mirrors SecureCookies config), SameSite=Lax, Path=/ — not JS-readable, matches the existing access/refresh cookie posture. Properly cleared (MaxAge=-1) on both logout and local login (defends against a stale OIDC cookie surviving into a fresh local-only session and leaking as id_token_hint).
  • post_logout_redirect_uri is built exclusively from the trusted request-host derivation (oidcPostLogoutRedirectURI, mirrors the existing oidcRedirectURL pattern) — never from request body/query input — so it is same-origin by construction; not attacker-controllable.
  • /settings/oidc PUT (the admin toggle) is unchanged and still wrapped in adminRequired (internal/settings/routes.go:88); no gating regression.
  • /logout remains POST-only, unchanged route registration; no new unauthenticated surface.
  • No secrets/PII (raw ID token, discovery response, etc.) are logged — the slog.Warn calls on discovery/parse failure log only err, not the token or URL content.
  • Discovery-failure and invalid-end_session_endpoint paths degrade gracefully to local-only logout rather than blocking or erroring the user's logout (fail-open on the feature, not fail-open on security).
  • No inline style= / CSP issues in the new template block (templates/pages/settings_shell.html).

REVIEW VERDICT: 0 blocker, 1 major, 1 minor

## Security review — PR #1398 (bookshelf-tm38.9, OIDC RP-initiated single logout) [MAJOR] internal/users/oidc_service.go:804-826 — discovery-supplied `end_session_endpoint` is redirected to with only `url.Parse` validation `OIDCLogoutRedirectURL` takes `meta.EndSessionEndpoint` straight from the provider's `/.well-known/openid-configuration` discovery document and, after only a syntactic `url.Parse` check, builds a URL that includes the raw `id_token_hint` (the user's OIDC ID token — a PII-bearing artifact) and 302-redirects the browser to it (`internal/users/handler.go:369-372`). Unlike the JWKS/token endpoints (which are only ever used for a *server-side* fetch), this is a **browser redirect target**, so if the configured issuer's discovery document is ever compromised or malicious (rotated infra, on-path MITM outside TLS pinning, or an intentionally hostile "identity provider" an admin points at), `end_session_endpoint` can be set to an arbitrary off-origin URL. Pergamum will then redirect every logging-out user's browser to that URL with their raw ID token attached as a query parameter — a discovery-driven open redirect that also exfiltrates the ID token to an attacker-controlled host (classic phishing/token-leak vector, and the same trust-boundary gap flagged for `jwks_uri` in #1396). RFC 8414 §3.3/OIDC Discovery's issuer-match check (already present in `DiscoverOIDCMeta`) only prevents a *substituted* document from a *different* issuer being accepted — it does nothing to constrain what the *legitimate configured* issuer's document is allowed to put in `end_session_endpoint`. Fix: validate `endSessionURL.Scheme == "https"` (reject non-https before redirecting) and, ideally, that `endSessionURL.Host` matches (or is a subdomain of) the configured issuer's host — mirroring whatever guard is chosen for the `jwks_uri` finding in #1396 so both discovery-sourced endpoints get the same allowlisting treatment. [MINOR] internal/users/oidc_service.go:814,823 — `state` is generated for the logout redirect but never verified on return A `state` parameter is generated and attached to the `end_session_endpoint` redirect, but the `post_logout_redirect_uri` is a static `/login` route with no logout-callback handler that checks the returned `state` matches what was issued (no cookie/session correlation is set up before the redirect). This has no real security impact here since the local Pergamum session is already fully torn down (cookies cleared) before the redirect fires, so there's nothing sensitive left to protect on return — but the `state` param as implemented provides no actual CSRF/binding value, just spec-compliance decoration. Consider either wiring a matching cookie + verification on `/login?state=...` or noting in the code comment that `state` here is emitted for spec compliance only and not verified. **What was checked and found OK:** - Discovery fetch reuses the existing shared `discoveryCached.discover` (same cache/singleflight used by login), fetching from the **admin-configured** `issuer` URL — not a new unguarded server-side fetch, and not attacker-influenceable input (unlike a per-request URL). No new SSRF surface introduced by this diff. - ID-token cookie (`bookshelf_oidc_idtoken`, `internal/users/oidc_handler.go:614-643`) is `HttpOnly`, `Secure` (mirrors `SecureCookies` config), `SameSite=Lax`, `Path=/` — not JS-readable, matches the existing access/refresh cookie posture. Properly cleared (`MaxAge=-1`) on both logout and local login (defends against a stale OIDC cookie surviving into a fresh local-only session and leaking as `id_token_hint`). - `post_logout_redirect_uri` is built exclusively from the trusted request-host derivation (`oidcPostLogoutRedirectURI`, mirrors the existing `oidcRedirectURL` pattern) — never from request body/query input — so it is same-origin by construction; not attacker-controllable. - `/settings/oidc` PUT (the admin toggle) is unchanged and still wrapped in `adminRequired` (`internal/settings/routes.go:88`); no gating regression. - `/logout` remains POST-only, unchanged route registration; no new unauthenticated surface. - No secrets/PII (raw ID token, discovery response, etc.) are logged — the `slog.Warn` calls on discovery/parse failure log only `err`, not the token or URL content. - Discovery-failure and invalid-`end_session_endpoint` paths degrade gracefully to local-only logout rather than blocking or erroring the user's logout (fail-open on the *feature*, not fail-open on security). - No inline `style=` / CSP issues in the new template block (`templates/pages/settings_shell.html`). REVIEW VERDICT: 0 blocker, 1 major, 1 minor
fix(review): address PR #1398 MAJORs — end_session_endpoint SSRF/open-redirect + ID-token cookie TTL
All checks were successful
/ E2E API (pull_request) Successful in 1m50s
/ JS Unit Tests (pull_request) Successful in 1m30s
/ Test Race (pull_request) Successful in 2m24s
/ Hugo build (pull_request) Successful in 1m47s
/ Integration (pull_request) Successful in 2m42s
/ Coverage (pull_request) Successful in 2m48s
/ Lint (pull_request) Successful in 3m10s
/ E2E Browser (pull_request) Successful in 4m52s
232bccb8e9
Two review findings on the OIDC RP-initiated logout feature (bookshelf-tm38.9):

- [MAJOR security] OIDCLogoutRedirectURL took end_session_endpoint from the
  issuer's discovery document and, after only a syntactic url.Parse, 302'd the
  user's browser there with id_token_hint (the raw ID token) attached. A
  compromised/malicious issuer could point end_session_endpoint at an
  arbitrary off-origin host, turning RP-initiated logout into an open redirect
  that also exfiltrates the ID token. Added isTrustedEndSessionEndpoint:
  requires https and a host matching the configured issuer's host; on
  mismatch, no redirect is built and logout falls back to local-only (same
  fallback path as a discovery failure).

- [MAJOR correctness] The OIDC ID-token cookie was set with
  expiry=RefreshTokenExpiry (~30 days). The ID token carries PII
  (sub/email/name/groups) and its own exp is minutes-to-hours, so storing it
  for 30 days both widens the PII-retention window and breaks the feature (an
  IdP rejects an id_token_hint whose exp has elapsed, so RP-logout silently
  degrades to local-only for older sessions). Added LoginResult.IDTokenExpiry,
  populated from the verified ID token's own 'exp' claim
  (idTokenExpiryFromClaims, falling back to now+accessTTL if exp is somehow
  absent) and threaded through oidcIssueTokens; the callback handler now binds
  the cookie to it instead of RefreshTokenExpiry.

Also: documented that the logout `state` param is emitted per RP-Initiated
Logout 1.0 §2 but not verified on return (local session is already torn down
by then), and reverted an unrelated trailing-blank-line diff in
internal/settings/oidc_handler.go.

Regression tests: end_session_endpoint on a different host, http instead of
https, and an unparseable configured issuer all fall back to local-only
logout without ever building an off-site redirect; ID-token cookie expiry is
bound to the token's own exp claim (not the 30-day refresh TTL), including
the defensive fallback when exp is absent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tRKybTpfjQ4SxmNdVFLHi

Settings journey screenshot (settings-shell-email-tab-modal)

settings-shell-email-tab-modal

**Settings journey screenshot** (settings-shell-email-tab-modal) ![settings-shell-email-tab-modal](/attachments/290ad794-2a68-4bbd-b7ce-d5b026dfe3ca)

Settings journey screenshot (settings-shell-metadata-tab)

settings-shell-metadata-tab

**Settings journey screenshot** (settings-shell-metadata-tab) ![settings-shell-metadata-tab](/attachments/edc8c681-c89a-449d-b411-ee5526697e48)

Settings journey screenshot (settings-ratelimits-comicvine-throttled)

settings-ratelimits-comicvine-throttled

**Settings journey screenshot** (settings-ratelimits-comicvine-throttled) ![settings-ratelimits-comicvine-throttled](/attachments/2183c888-d01a-494c-8a0a-8bac8a411336)

Settings journey screenshot (settings-comic-weights-max-saves)

settings-comic-weights-max-saves

**Settings journey screenshot** (settings-comic-weights-max-saves) ![settings-comic-weights-max-saves](/attachments/e5c962d8-400c-4983-9fa3-2ec1d809654c)

Settings journey screenshot (settings-comic-field-priority-matrix-saved)

settings-comic-field-priority-matrix-saved

**Settings journey screenshot** (settings-comic-field-priority-matrix-saved) ![settings-comic-field-priority-matrix-saved](/attachments/9fd9fefc-d46f-401c-b9fd-b53dfd60f700)

Settings journey screenshot (oidc-settings-form-fields)

oidc-settings-form-fields

**Settings journey screenshot** (oidc-settings-form-fields) ![oidc-settings-form-fields](/attachments/d7a6ad5c-7988-461b-a5ed-b96b038d595f)

Settings journey screenshot (oidc-rp-initiated-logout-toggle-checked)

oidc-rp-initiated-logout-toggle-checked

**Settings journey screenshot** (oidc-rp-initiated-logout-toggle-checked) ![oidc-rp-initiated-logout-toggle-checked](/attachments/89da68c3-f117-4376-83f3-e0cf4cc15392)

Settings journey screenshot (oidc-settings-after-reload)

oidc-settings-after-reload

**Settings journey screenshot** (oidc-settings-after-reload) ![oidc-settings-after-reload](/attachments/4ef9e800-fa5a-442c-8a9c-0842b51e090e)

Settings journey screenshot (oidc-test-connection-result)

oidc-test-connection-result

**Settings journey screenshot** (oidc-test-connection-result) ![oidc-test-connection-result](/attachments/5b9a6c41-9b2a-45f2-9f93-d0ad7c89e192)

Settings journey screenshot (oidc-group-mapping-modal-open)

oidc-group-mapping-modal-open

**Settings journey screenshot** (oidc-group-mapping-modal-open) ![oidc-group-mapping-modal-open](/attachments/92fe1f00-5431-49d3-a01c-f5bb57251d03)

Settings journey screenshot (oidc-group-mapping-after-create)

oidc-group-mapping-after-create

**Settings journey screenshot** (oidc-group-mapping-after-create) ![oidc-group-mapping-after-create](/attachments/0c1a8b16-8cbc-4a16-97b6-3cbcf7d43acd)

Settings journey screenshot (oidc-group-mapping-after-edit)

oidc-group-mapping-after-edit

**Settings journey screenshot** (oidc-group-mapping-after-edit) ![oidc-group-mapping-after-edit](/attachments/e82e793e-cb87-45b6-9bba-51058d125fde)

Settings journey screenshot (oidc-group-mapping-after-delete)

oidc-group-mapping-after-delete

**Settings journey screenshot** (oidc-group-mapping-after-delete) ![oidc-group-mapping-after-delete](/attachments/95133c04-54d5-42e7-a3aa-f27293fcff5c)

Settings journey screenshot (sidecar-settings-toggle-saved)

sidecar-settings-toggle-saved

**Settings journey screenshot** (sidecar-settings-toggle-saved) ![sidecar-settings-toggle-saved](/attachments/105886e9-bcef-4a7b-b486-3fbdb6a4b8e5)

Settings journey screenshot (sidecar-settings-toggle-persisted)

sidecar-settings-toggle-persisted

**Settings journey screenshot** (sidecar-settings-toggle-persisted) ![sidecar-settings-toggle-persisted](/attachments/0991036c-716a-4de5-9f2a-2e8fe021b883)
Author
Owner

Security re-review — PR #1398 (bookshelf-tm38.9), commit 232bccb8

Re-reviewed the fix for the end_session_endpoint open-redirect/token-exfil MAJOR against internal/users/oidc_service.go, oidc_handler.go, service.go, and the accompanying tests.

Verification of the fix

Host-trust gate is enforced before any browser redirect. logoutHandler (internal/users/handler.go:284) → logoutOIDCRedirectURLd.OIDCLogoutRedirectURLOIDCLogoutRedirectURL (oidc_service.go:1269) parses meta.EndSessionEndpoint and calls isTrustedEndSessionEndpoint(endSessionURL, cfg.Issuer) (oidc_service.go:~1340) before the id_token_hint/redirect URL is ever built. On any failure it returns ("", nil), and the handler falls back to http.Redirect(w, r, "/login", ...) — a fail-safe, non-erroring fallback with no off-origin exposure of the ID token.

Adversarially tested the host-compare (strings.EqualFold(endSessionURL.Hostname(), issuerURL.Hostname()) after requiring Scheme == "https") against the classic bypass set:

Payload Result
https://provider.example.com.evil.com/logout (suffix trick) rejected (host = provider.example.com.evil.com, no match)
https://evil.com/logout?x=provider.example.com (query trick) rejected
https://PROVIDER.EXAMPLE.COM/logout (case) accepted — correct, EqualFold handles it
https://provider.example.com./logout (trailing dot) rejected — fails closed (falls back to local logout, not a bypass)
https://provider.example.com:8443/logout (port) accepted — Hostname() strips port, correct per RFC (port doesn't affect origin-trust intent here)
https://user@provider.example.com@evil.com/logout (userinfo confusion) rejected — Go's net/url resolves the rightmost @ as the userinfo/host separator, so Hostname() correctly returns evil.com
https://provider.example.com:443@evil.com/logout rejected — same userinfo handling, Hostname() = evil.com
https://xn--80ak6aa92e.com/logout (punycode) rejected — no accidental match; homograph risk only exists if the admin's own configured issuer were the confusable string, which is out of this threat model (issuer is admin-configured, not attacker input)
https:evil.com/logout (opaque/no-host) rejected — Hostname() = "", no match
non-https scheme (http://, javascript:, ://bad) rejected by the Scheme != "https" guard / parse-error fallback

No bypass found. The comparison is an exact hostname match, not strings.Contains/strings.HasSuffix, and url.Hostname() already strips port + userinfo per Go's net/url semantics, so none of the classic parser-confusion tricks (userinfo-as-host, subdomain suffix, query-param spoofing) get through. This matches the unit tests added in oidc_service_test.go (different-host, http-scheme, unparseable endpoint, unparseable issuer all assert Expect(url, err).To(BeEmpty())).

One structural note (not a bypass, just worth naming): the trust anchor is cfg.Issuer — the admin-configured issuer string — not the issuer field returned inside the discovery document itself. That's the correct trust root (admin config is trusted input; the discovery response's end_session_endpoint is the untrusted field being validated), so this is not a finding, just confirming the threat model is right.

ID-token cookie expiry. idTokenExpiryFromClaims (oidc_service.go) now derives the cookie's Expires from the verified ID token's own exp claim, with a defensive fallback to now + accessTTL (short) if exp is absent — never the 30-day refresh TTL. This is a genuine reduction of the PII-exposure window: previously the cookie (carrying sub/email/name/groups via the raw ID token) could persist up to 30 days; now it's bounded by the token's own lifetime (typically minutes-to-an-hour), and even the defensive fallback only extends to the access-token TTL, not the refresh TTL. Covered by oidc_coverage_test.go's new "binds the ID-token cookie expiry to the token's own exp claim" and "ID token without an exp claim" specs.

Cookie flags on oidcIDTokenCookieName (oidc_handler.go:setOIDCIDTokenCookie) are unchanged from the pre-fix shape: HttpOnly: true, Secure: secure (wired from d.SecureCookies), SameSite: http.SameSiteLaxMode, Path: "/". Appropriately locked down for a token that's read server-side only (never JS-accessible) and only leaves the origin via the now-validated end_session_endpoint redirect.

Fallback correctness confirmed for every failure branch: OIDC disabled, RPInitiatedLogout off, empty idTokenHint, discovery failure, missing end_session_endpoint, unparseable endpoint/issuer, non-https, different host — all return ("", nil) and the caller redirects to local /login with no ID token ever attached to an off-origin URL. getConfig failure and generateOIDCState failure are the only paths that propagate a real error (both pre-redirect, no ID token exposure either way).

Findings

No new blockers or majors found. The original open-redirect/token-exfil MAJOR is closed by isTrustedEndSessionEndpoint, and the ID-token cookie expiry MAJOR is closed by idTokenExpiryFromClaims.

[MINOR] internal/users/oidc_service.go:1349 (isTrustedEndSessionEndpoint) — Consider also requiring issuerURL.Scheme == "https" (or otherwise validating cfg.Issuer is well-formed with a host) so a misconfigured http:// issuer can't accidentally validate; today it doesn't matter because endSessionURL.Scheme is independently forced to https, but pinning both sides makes the invariant self-evident to a future reader. Not exploitable as written — pure defense-in-depth/readability.

[MINOR] internal/users/oidc_handler.go:118 (setOIDCIDTokenCookie) — SameSite: http.SameSiteLaxMode is fine (cookie is HttpOnly and never needed cross-site), but since this cookie is only ever read server-side within same-site navigations (login/logout POST + callback redirect), SameSite: http.SameSiteStrictMode would tighten it further with no functional loss. Not a vulnerability as shipped.

REVIEW VERDICT: 0 blocker, 0 major, 2 minor

## Security re-review — PR #1398 (bookshelf-tm38.9), commit 232bccb8 Re-reviewed the fix for the `end_session_endpoint` open-redirect/token-exfil MAJOR against `internal/users/oidc_service.go`, `oidc_handler.go`, `service.go`, and the accompanying tests. ### Verification of the fix **Host-trust gate is enforced before any browser redirect.** `logoutHandler` (`internal/users/handler.go:284`) → `logoutOIDCRedirectURL` → `d.OIDCLogoutRedirectURL` → `OIDCLogoutRedirectURL` (`oidc_service.go:1269`) parses `meta.EndSessionEndpoint` and calls `isTrustedEndSessionEndpoint(endSessionURL, cfg.Issuer)` (`oidc_service.go:~1340`) *before* the `id_token_hint`/redirect URL is ever built. On any failure it returns `("", nil)`, and the handler falls back to `http.Redirect(w, r, "/login", ...)` — a fail-safe, non-erroring fallback with no off-origin exposure of the ID token. **Adversarially tested the host-compare** (`strings.EqualFold(endSessionURL.Hostname(), issuerURL.Hostname())` after requiring `Scheme == "https"`) against the classic bypass set: | Payload | Result | |---|---| | `https://provider.example.com.evil.com/logout` (suffix trick) | rejected (host = `provider.example.com.evil.com`, no match) | | `https://evil.com/logout?x=provider.example.com` (query trick) | rejected | | `https://PROVIDER.EXAMPLE.COM/logout` (case) | accepted — correct, `EqualFold` handles it | | `https://provider.example.com./logout` (trailing dot) | rejected — fails closed (falls back to local logout, not a bypass) | | `https://provider.example.com:8443/logout` (port) | accepted — `Hostname()` strips port, correct per RFC (port doesn't affect origin-trust intent here) | | `https://user@provider.example.com@evil.com/logout` (userinfo confusion) | rejected — Go's `net/url` resolves the *rightmost* `@` as the userinfo/host separator, so `Hostname()` correctly returns `evil.com` | | `https://provider.example.com:443@evil.com/logout` | rejected — same userinfo handling, `Hostname()` = `evil.com` | | `https://xn--80ak6aa92e.com/logout` (punycode) | rejected — no accidental match; homograph risk only exists if the *admin's own configured issuer* were the confusable string, which is out of this threat model (issuer is admin-configured, not attacker input) | | `https:evil.com/logout` (opaque/no-host) | rejected — `Hostname()` = `""`, no match | | non-https scheme (`http://`, `javascript:`, `://bad`) | rejected by the `Scheme != "https"` guard / parse-error fallback | No bypass found. The comparison is an **exact hostname match**, not `strings.Contains`/`strings.HasSuffix`, and `url.Hostname()` already strips port + userinfo per Go's `net/url` semantics, so none of the classic parser-confusion tricks (userinfo-as-host, subdomain suffix, query-param spoofing) get through. This matches the unit tests added in `oidc_service_test.go` (different-host, http-scheme, unparseable endpoint, unparseable issuer all assert `Expect(url, err).To(BeEmpty())`). One structural note (not a bypass, just worth naming): the trust anchor is `cfg.Issuer` — the **admin-configured** issuer string — not the `issuer` field returned inside the discovery document itself. That's the correct trust root (admin config is trusted input; the discovery response's `end_session_endpoint` is the untrusted field being validated), so this is not a finding, just confirming the threat model is right. **ID-token cookie expiry.** `idTokenExpiryFromClaims` (`oidc_service.go`) now derives the cookie's `Expires` from the verified ID token's own `exp` claim, with a defensive fallback to `now + accessTTL` (short) if `exp` is absent — never the 30-day refresh TTL. This is a genuine reduction of the PII-exposure window: previously the cookie (carrying `sub`/`email`/`name`/`groups` via the raw ID token) could persist up to 30 days; now it's bounded by the token's own lifetime (typically minutes-to-an-hour), and even the defensive fallback only extends to the access-token TTL, not the refresh TTL. Covered by `oidc_coverage_test.go`'s new "binds the ID-token cookie expiry to the token's own exp claim" and "ID token without an exp claim" specs. Cookie flags on `oidcIDTokenCookieName` (`oidc_handler.go:setOIDCIDTokenCookie`) are unchanged from the pre-fix shape: `HttpOnly: true`, `Secure: secure` (wired from `d.SecureCookies`), `SameSite: http.SameSiteLaxMode`, `Path: "/"`. Appropriately locked down for a token that's read server-side only (never JS-accessible) and only leaves the origin via the now-validated end_session_endpoint redirect. **Fallback correctness confirmed for every failure branch:** OIDC disabled, `RPInitiatedLogout` off, empty `idTokenHint`, discovery failure, missing `end_session_endpoint`, unparseable endpoint/issuer, non-https, different host — all return `("", nil)` and the caller redirects to local `/login` with no ID token ever attached to an off-origin URL. `getConfig` failure and `generateOIDCState` failure are the only paths that propagate a real `error` (both pre-redirect, no ID token exposure either way). ### Findings No new blockers or majors found. The original open-redirect/token-exfil MAJOR is closed by `isTrustedEndSessionEndpoint`, and the ID-token cookie expiry MAJOR is closed by `idTokenExpiryFromClaims`. [MINOR] internal/users/oidc_service.go:1349 (isTrustedEndSessionEndpoint) — Consider also requiring `issuerURL.Scheme == "https"` (or otherwise validating `cfg.Issuer` is well-formed with a host) so a misconfigured `http://` issuer can't accidentally validate; today it doesn't matter because `endSessionURL.Scheme` is independently forced to `https`, but pinning both sides makes the invariant self-evident to a future reader. Not exploitable as written — pure defense-in-depth/readability. [MINOR] internal/users/oidc_handler.go:118 (setOIDCIDTokenCookie) — `SameSite: http.SameSiteLaxMode` is fine (cookie is HttpOnly and never needed cross-site), but since this cookie is only ever read server-side within same-site navigations (login/logout POST + callback redirect), `SameSite: http.SameSiteStrictMode` would tighten it further with no functional loss. Not a vulnerability as shipped. REVIEW VERDICT: 0 blocker, 0 major, 2 minor
Author
Owner

Code Review — bookshelf-tm38.11 (configurable OIDC session duration)

[BLOCKER] internal/app/app.go:359-372 — SessionDuration override is never wired into production, feature is dead code
This PR adds SessionDuration time.Duration to appwire.OIDCRuntimeConfig (internal/appwire/appwire.go:854-856) and correctly threads it through internal/settings/oidc_settings.go's GetOIDCConfig and internal/users/wire.go:123 (SessionDuration: rc.SessionDuration). But the one production call site that constructs appwire.OIDCRuntimeConfig — the GetOIDCConfig closure in internal/app/app.go (lines 354-373) — builds the struct with an explicit field list (Enabled, Issuer, ClientID, ... OIDCOnlyMode) and does not copy rc.SessionDuration from settings.OIDCRuntimeConfig into it. Since this diff does not touch internal/app/app.go at all, the field silently defaults to zero at runtime, so effectiveRefreshTTL (internal/users/oidc_service.go:452) always falls back to the global TTL — an admin who sets a session duration override in the settings UI has it saved and re-displayed (round-trips through the DB/UI, per the new e2e journey step), but it never actually changes any issued OIDC session's expiry.
This is uncaught because every test that exercises the new behavior stubs GetOIDCConfig/cfg.SessionDuration directly (internal/settings/oidc_settings_test.go, internal/users/oidc_coverage_test.go) and the new browser e2e journey only asserts the form value persists (e2e/browser/journey_settings_test.go:417-419) — none of them go through the real app.New wiring closure in app.go that is the only place this actually gets consumed.
Fix: add SessionDuration: rc.SessionDuration, to the struct literal in internal/app/app.go's GetOIDCConfig closure (~line 372, alongside OIDCOnlyMode). Also worth flagging (pre-existing, out of scope for this bead but same shape of bug): DefaultPermissions / DefaultLibraryIDs are also missing from that same struct literal — a prior feature (JIT auto-provision defaults) may have the identical wiring gap; suggest filing a follow-up bead to audit/complete that mapping and consider adding a genuine end-to-end (real app.New) e2e/integration assertion that an OIDC login actually respects a configured session-duration override, so this class of gap can't recur silently.

[MAJOR] internal/users/oidc_coverage_test.go:2338-2361 — new wall-clock (time.Now()) assertions in the test's It bodies
Three new It blocks assert capturedRefreshTTL.ExpiryDate / result.AccessTokenExpiry against time.Now().Add(...) computed at assertion time (not injected/frozen), with a 5-second BeTemporally("~", ..., 5*time.Second) tolerance. Per .claude/rules/review-standard.md → "Flake prevention" this is exactly the banned pattern: asserting on real elapsed time between the action (JustBeforeEach invoking handleCallback, which internally computes now.Add(refreshTTL)) and the assertion. 5s is generous for a fast unit test, but under CI load/parallel test contention it is not impossible to exceed, and the fix is cheap. The file already has access to logger/config plumbed by closures — prefer injecting a fixed now (mirroring the pattern already used elsewhere in this codebase, e.g. internal/users/device_store_test.go:48 fakeNow, and internal/users/service_test.go:257 fixedNow) rather than a wall-clock tolerance window.
Fix: thread a fixed now time.Time into whatever computes ExpiryDate/AccessTokenExpiry in this flow (or, if that's out of reach without touching production code in this bead, at minimum capture time.Now() once before JustBeforeEach runs and assert against that captured value with a tight tolerance, removing the skew between per-It time.Now() calls and the actual event).

[MINOR] templates/pages/settings_shell.html:1361 — session-duration input has min="1" but no max attribute
The clamp is enforced server-side (oidcSessionDurationMaxHours = 8760h/1yr) so this is not a correctness issue, but adding max="8760" to the <input type="number"> would give the admin instant client-side feedback consistent with the min="1" already present, rather than only discovering the clamp after a round-trip save+reload.

REVIEW VERDICT: 1 blocker, 1 major, 1 minor

## Code Review — bookshelf-tm38.11 (configurable OIDC session duration) [BLOCKER] internal/app/app.go:359-372 — SessionDuration override is never wired into production, feature is dead code This PR adds `SessionDuration time.Duration` to `appwire.OIDCRuntimeConfig` (internal/appwire/appwire.go:854-856) and correctly threads it through `internal/settings/oidc_settings.go`'s `GetOIDCConfig` and `internal/users/wire.go:123` (`SessionDuration: rc.SessionDuration`). But the **one production call site** that constructs `appwire.OIDCRuntimeConfig` — the `GetOIDCConfig` closure in `internal/app/app.go` (lines 354-373) — builds the struct with an explicit field list (`Enabled`, `Issuer`, `ClientID`, ... `OIDCOnlyMode`) and does **not** copy `rc.SessionDuration` from `settings.OIDCRuntimeConfig` into it. Since this diff does not touch `internal/app/app.go` at all, the field silently defaults to zero at runtime, so `effectiveRefreshTTL` (internal/users/oidc_service.go:452) always falls back to the global TTL — an admin who sets a session duration override in the settings UI has it saved and re-displayed (round-trips through the DB/UI, per the new e2e journey step), but it **never actually changes any issued OIDC session's expiry**. This is uncaught because every test that exercises the new behavior stubs `GetOIDCConfig`/`cfg.SessionDuration` directly (internal/settings/oidc_settings_test.go, internal/users/oidc_coverage_test.go) and the new browser e2e journey only asserts the form value persists (e2e/browser/journey_settings_test.go:417-419) — none of them go through the real `app.New` wiring closure in app.go that is the only place this actually gets consumed. Fix: add `SessionDuration: rc.SessionDuration,` to the struct literal in `internal/app/app.go`'s `GetOIDCConfig` closure (~line 372, alongside `OIDCOnlyMode`). Also worth flagging (pre-existing, out of scope for this bead but same shape of bug): `DefaultPermissions` / `DefaultLibraryIDs` are also missing from that same struct literal — a prior feature (JIT auto-provision defaults) may have the identical wiring gap; suggest filing a follow-up bead to audit/complete that mapping and consider adding a genuine end-to-end (real `app.New`) e2e/integration assertion that an OIDC login actually respects a configured session-duration override, so this class of gap can't recur silently. [MAJOR] internal/users/oidc_coverage_test.go:2338-2361 — new wall-clock (`time.Now()`) assertions in the test's `It` bodies Three new `It` blocks assert `capturedRefreshTTL.ExpiryDate` / `result.AccessTokenExpiry` against `time.Now().Add(...)` computed *at assertion time* (not injected/frozen), with a 5-second `BeTemporally("~", ..., 5*time.Second)` tolerance. Per `.claude/rules/review-standard.md` → "Flake prevention" this is exactly the banned pattern: asserting on real elapsed time between the action (`JustBeforeEach` invoking `handleCallback`, which internally computes `now.Add(refreshTTL)`) and the assertion. 5s is generous for a fast unit test, but under CI load/parallel test contention it is not impossible to exceed, and the fix is cheap. The file already has access to `logger`/config plumbed by closures — prefer injecting a fixed `now` (mirroring the pattern already used elsewhere in this codebase, e.g. `internal/users/device_store_test.go:48` `fakeNow`, and `internal/users/service_test.go:257` `fixedNow`) rather than a wall-clock tolerance window. Fix: thread a fixed `now time.Time` into whatever computes `ExpiryDate`/`AccessTokenExpiry` in this flow (or, if that's out of reach without touching production code in this bead, at minimum capture `time.Now()` once *before* `JustBeforeEach` runs and assert against that captured value with a tight tolerance, removing the skew between per-`It` `time.Now()` calls and the actual event). [MINOR] templates/pages/settings_shell.html:1361 — session-duration input has `min="1"` but no `max` attribute The clamp is enforced server-side (`oidcSessionDurationMaxHours` = 8760h/1yr) so this is not a correctness issue, but adding `max="8760"` to the `<input type="number">` would give the admin instant client-side feedback consistent with the `min="1"` already present, rather than only discovering the clamp after a round-trip save+reload. REVIEW VERDICT: 1 blocker, 1 major, 1 minor
Author
Owner

Code Review — bookshelf-tm38.11 (configurable OIDC session duration)

[BLOCKER] internal/app/app.go:359-372 — SessionDuration override is never wired into production, feature is dead code
This PR adds SessionDuration time.Duration to appwire.OIDCRuntimeConfig (internal/appwire/appwire.go:854-856) and correctly threads it through internal/settings/oidc_settings.go's GetOIDCConfig and internal/users/wire.go:123 (SessionDuration: rc.SessionDuration). But the one production call site that constructs appwire.OIDCRuntimeConfig — the GetOIDCConfig closure in internal/app/app.go (lines 354-373) — builds the struct with an explicit field list (Enabled, Issuer, ClientID, ... OIDCOnlyMode) and does not copy rc.SessionDuration from settings.OIDCRuntimeConfig into it. Since this diff does not touch internal/app/app.go at all, the field silently defaults to zero at runtime, so effectiveRefreshTTL (internal/users/oidc_service.go:452) always falls back to the global TTL — an admin who sets a session duration override in the settings UI has it saved and re-displayed (round-trips through the DB/UI, per the new e2e journey step), but it never actually changes any issued OIDC session's expiry.
This is uncaught because every test that exercises the new behavior stubs GetOIDCConfig/cfg.SessionDuration directly (internal/settings/oidc_settings_test.go, internal/users/oidc_coverage_test.go) and the new browser e2e journey only asserts the form value persists (e2e/browser/journey_settings_test.go:417-419) — none of them go through the real app.New wiring closure in app.go that is the only place this actually gets consumed.
Fix: add SessionDuration: rc.SessionDuration, to the struct literal in internal/app/app.go's GetOIDCConfig closure (~line 372, alongside OIDCOnlyMode). Also worth flagging (pre-existing, out of scope for this bead but same shape of bug): DefaultPermissions / DefaultLibraryIDs are also missing from that same struct literal — a prior feature (JIT auto-provision defaults) may have the identical wiring gap; suggest filing a follow-up bead to audit/complete that mapping and consider adding a genuine end-to-end (real app.New) e2e/integration assertion that an OIDC login actually respects a configured session-duration override, so this class of gap can't recur silently.

[MAJOR] internal/users/oidc_coverage_test.go:2338-2361 — new wall-clock (time.Now()) assertions in the test's It bodies
Three new It blocks assert capturedRefreshTTL.ExpiryDate / result.AccessTokenExpiry against time.Now().Add(...) computed at assertion time (not injected/frozen), with a 5-second BeTemporally("~", ..., 5*time.Second) tolerance. Per .claude/rules/review-standard.md → "Flake prevention" this is exactly the banned pattern: asserting on real elapsed time between the action (JustBeforeEach invoking handleCallback, which internally computes now.Add(refreshTTL)) and the assertion. 5s is generous for a fast unit test, but under CI load/parallel test contention it is not impossible to exceed, and the fix is cheap. The file already has access to logger/config plumbed by closures — prefer injecting a fixed now (mirroring the pattern already used elsewhere in this codebase, e.g. internal/users/device_store_test.go:48 fakeNow, and internal/users/service_test.go:257 fixedNow) rather than a wall-clock tolerance window.
Fix: thread a fixed now time.Time into whatever computes ExpiryDate/AccessTokenExpiry in this flow (or, if that's out of reach without touching production code in this bead, at minimum capture time.Now() once before JustBeforeEach runs and assert against that captured value with a tight tolerance, removing the skew between per-It time.Now() calls and the actual event).

[MINOR] templates/pages/settings_shell.html:1361 — session-duration input has min="1" but no max attribute
The clamp is enforced server-side (oidcSessionDurationMaxHours = 8760h/1yr) so this is not a correctness issue, but adding max="8760" to the <input type="number"> would give the admin instant client-side feedback consistent with the min="1" already present, rather than only discovering the clamp after a round-trip save+reload.

REVIEW VERDICT: 1 blocker, 1 major, 1 minor

## Code Review — bookshelf-tm38.11 (configurable OIDC session duration) [BLOCKER] internal/app/app.go:359-372 — SessionDuration override is never wired into production, feature is dead code This PR adds `SessionDuration time.Duration` to `appwire.OIDCRuntimeConfig` (internal/appwire/appwire.go:854-856) and correctly threads it through `internal/settings/oidc_settings.go`'s `GetOIDCConfig` and `internal/users/wire.go:123` (`SessionDuration: rc.SessionDuration`). But the **one production call site** that constructs `appwire.OIDCRuntimeConfig` — the `GetOIDCConfig` closure in `internal/app/app.go` (lines 354-373) — builds the struct with an explicit field list (`Enabled`, `Issuer`, `ClientID`, ... `OIDCOnlyMode`) and does **not** copy `rc.SessionDuration` from `settings.OIDCRuntimeConfig` into it. Since this diff does not touch `internal/app/app.go` at all, the field silently defaults to zero at runtime, so `effectiveRefreshTTL` (internal/users/oidc_service.go:452) always falls back to the global TTL — an admin who sets a session duration override in the settings UI has it saved and re-displayed (round-trips through the DB/UI, per the new e2e journey step), but it **never actually changes any issued OIDC session's expiry**. This is uncaught because every test that exercises the new behavior stubs `GetOIDCConfig`/`cfg.SessionDuration` directly (internal/settings/oidc_settings_test.go, internal/users/oidc_coverage_test.go) and the new browser e2e journey only asserts the form value persists (e2e/browser/journey_settings_test.go:417-419) — none of them go through the real `app.New` wiring closure in app.go that is the only place this actually gets consumed. Fix: add `SessionDuration: rc.SessionDuration,` to the struct literal in `internal/app/app.go`'s `GetOIDCConfig` closure (~line 372, alongside `OIDCOnlyMode`). Also worth flagging (pre-existing, out of scope for this bead but same shape of bug): `DefaultPermissions` / `DefaultLibraryIDs` are also missing from that same struct literal — a prior feature (JIT auto-provision defaults) may have the identical wiring gap; suggest filing a follow-up bead to audit/complete that mapping and consider adding a genuine end-to-end (real `app.New`) e2e/integration assertion that an OIDC login actually respects a configured session-duration override, so this class of gap can't recur silently. [MAJOR] internal/users/oidc_coverage_test.go:2338-2361 — new wall-clock (`time.Now()`) assertions in the test's `It` bodies Three new `It` blocks assert `capturedRefreshTTL.ExpiryDate` / `result.AccessTokenExpiry` against `time.Now().Add(...)` computed *at assertion time* (not injected/frozen), with a 5-second `BeTemporally("~", ..., 5*time.Second)` tolerance. Per `.claude/rules/review-standard.md` → "Flake prevention" this is exactly the banned pattern: asserting on real elapsed time between the action (`JustBeforeEach` invoking `handleCallback`, which internally computes `now.Add(refreshTTL)`) and the assertion. 5s is generous for a fast unit test, but under CI load/parallel test contention it is not impossible to exceed, and the fix is cheap. The file already has access to `logger`/config plumbed by closures — prefer injecting a fixed `now` (mirroring the pattern already used elsewhere in this codebase, e.g. `internal/users/device_store_test.go:48` `fakeNow`, and `internal/users/service_test.go:257` `fixedNow`) rather than a wall-clock tolerance window. Fix: thread a fixed `now time.Time` into whatever computes `ExpiryDate`/`AccessTokenExpiry` in this flow (or, if that's out of reach without touching production code in this bead, at minimum capture `time.Now()` once *before* `JustBeforeEach` runs and assert against that captured value with a tight tolerance, removing the skew between per-`It` `time.Now()` calls and the actual event). [MINOR] templates/pages/settings_shell.html:1361 — session-duration input has `min="1"` but no `max` attribute The clamp is enforced server-side (`oidcSessionDurationMaxHours` = 8760h/1yr) so this is not a correctness issue, but adding `max="8760"` to the `<input type="number">` would give the admin instant client-side feedback consistent with the `min="1"` already present, rather than only discovering the clamp after a round-trip save+reload. REVIEW VERDICT: 1 blocker, 1 major, 1 minor
Author
Owner

Code re-review of the fix commit (232bccb8, bookshelf-tm38.9)

Reviewed the delta commit fix(review): address PR #1398 MAJORs — end_session_endpoint SSRF/open-redirect + ID-token cookie TTL against origin/main and origin/bd-bookshelf-tm38.9, plus the surrounding diff for consistency.

Confirmed resolved

  • ID-token cookie TTL MAJOR is genuinely fixed. Traced end-to-end: idTokenExpiryFromClaims(claims, now, accessTTL) reads claims["exp"].(float64) (correct — encoding/json decodes JWT NumericDate to float64) and returns time.Unix(int64(exp), 0), falling back to now.Add(accessTTL) only when exp is absent/non-numeric (internal/users/oidc_service.go:878-883). oidcFinalizeLogin computes it (oidc_service.go:893) and threads it into oidcIssueTokens(..., idTokenExpiry, ...) (oidc_service.go:894), which sets LoginResult.IDTokenExpiry = idTokenExpiry (oidc_service.go:868). oidcCallbackHandler now calls setOIDCIDTokenCookie(w, result.IDToken, secureCookies, result.IDTokenExpiry) (internal/users/oidc_handler.go:764) — confirmed via git show 232bccb8e -- internal/users/oidc_handler.go that this line changed from result.RefreshTokenExpiry to result.IDTokenExpiry, and it is the only caller of oidcIssueTokens/setOIDCIDTokenCookie. Value is genuinely bound to the token's own exp, not the 30-day refresh TTL.
  • isTrustedEndSessionEndpoint (SSRF/open-redirect MAJOR) is correct. Requires https scheme and strings.EqualFold(endSessionURL.Hostname(), issuerURL.Hostname()) (oidc_service.go:991-999). All non-matching/malformed cases (getConfig error, disabled/off, discovery failure, missing/unparseable end_session_endpoint, off-host, http-not-https, unparseable issuer) return ("", nil) cleanly and fall back to local-only logout — verified in OIDCLogoutRedirectURL (oidc_service.go:918-981) and covered by the new oidc_service_test.go contexts (off-host, http, unparseable issuer).
  • Blank-line churn revert is clean. git diff origin/main 797929974 -- internal/settings/oidc_handler.go shows the base commit accidentally stripped the file's trailing newline; the fix commit's + blank line at EOF restores it. Confirmed git diff origin/main origin/bd-bookshelf-tm38.9 -- internal/settings/oidc_handler.go now shows only the intended RPInitiatedLogout field addition — byte-identical elsewhere.
  • Threading through oidcIssueTokens/oidcFinalizeLogin/LoginResult is consistent; single call site, no other caller broken. No .golangci.yml or scripts/check-coverage.sh exclusions added. New tests are black-box and one-Expect-per-It.

Findings

[MAJOR] internal/users/oidc_handler_test.go:213-266 — no test locks the exact call site that was just fixed (cookie Expiry ← IDTokenExpiry, not RefreshTokenExpiry)
The oidcCallbackHandler Describe block's callbackResult only sets AccessToken/RefreshToken/IDTokenIDTokenExpiry and RefreshTokenExpiry are both left at their zero value. The only handler-level assertion is that a cookie named bookshelf_oidc_idtoken is present (oidc_handler_test.go:265-271); nothing asserts its Expires/MaxAge. The regression coverage added in this fix commit (oidc_coverage_test.go result.IDTokenExpiry assertion, oidc_service_test.go) only proves LoginResult.IDTokenExpiry is computed correctly from claims — it never exercises the setOIDCIDTokenCookie(w, result.IDToken, secureCookies, result.IDTokenExpiry) call site in the handler. If a future edit silently reverted result.IDTokenExpiry back to result.RefreshTokenExpiry at that call site (the exact bug this fix addresses), no test in the suite would fail — the 30-day-cookie regression could ship undetected.
Fix: in the oidcCallbackHandler Describe, set callbackResult.IDTokenExpiry and callbackResult.RefreshTokenExpiry to two distinct, distinguishable times in BeforeEach, then add an It asserting the bookshelf_oidc_idtoken cookie's Expires equals callbackResult.IDTokenExpiry (and, ideally, that it does NOT equal RefreshTokenExpiry) so this exact regression is test-locked at the layer where it actually manifests (the cookie on the wire), not just at the LoginResult computation layer.

[MINOR] internal/users/oidc_service.go:966 — comment grammar
"Do not assume a future reader that it is checked." reads awkwardly (subject/object swapped). Suggested: "Do not let a future reader assume it is checked."

REVIEW VERDICT: 0 blocker, 1 major, 1 minor

## Code re-review of the fix commit (232bccb8, bookshelf-tm38.9) Reviewed the delta commit `fix(review): address PR #1398 MAJORs — end_session_endpoint SSRF/open-redirect + ID-token cookie TTL` against `origin/main` and `origin/bd-bookshelf-tm38.9`, plus the surrounding diff for consistency. ### Confirmed resolved - **ID-token cookie TTL MAJOR is genuinely fixed.** Traced end-to-end: `idTokenExpiryFromClaims(claims, now, accessTTL)` reads `claims["exp"].(float64)` (correct — `encoding/json` decodes JWT `NumericDate` to `float64`) and returns `time.Unix(int64(exp), 0)`, falling back to `now.Add(accessTTL)` only when `exp` is absent/non-numeric (`internal/users/oidc_service.go:878-883`). `oidcFinalizeLogin` computes it (`oidc_service.go:893`) and threads it into `oidcIssueTokens(..., idTokenExpiry, ...)` (`oidc_service.go:894`), which sets `LoginResult.IDTokenExpiry = idTokenExpiry` (`oidc_service.go:868`). `oidcCallbackHandler` now calls `setOIDCIDTokenCookie(w, result.IDToken, secureCookies, result.IDTokenExpiry)` (`internal/users/oidc_handler.go:764`) — confirmed via `git show 232bccb8e -- internal/users/oidc_handler.go` that this line changed from `result.RefreshTokenExpiry` to `result.IDTokenExpiry`, and it is the only caller of `oidcIssueTokens`/`setOIDCIDTokenCookie`. Value is genuinely bound to the token's own `exp`, not the 30-day refresh TTL. - **`isTrustedEndSessionEndpoint` (SSRF/open-redirect MAJOR) is correct.** Requires `https` scheme and `strings.EqualFold(endSessionURL.Hostname(), issuerURL.Hostname())` (`oidc_service.go:991-999`). All non-matching/malformed cases (`getConfig` error, disabled/off, discovery failure, missing/unparseable `end_session_endpoint`, off-host, http-not-https, unparseable issuer) return `("", nil)` cleanly and fall back to local-only logout — verified in `OIDCLogoutRedirectURL` (`oidc_service.go:918-981`) and covered by the new `oidc_service_test.go` contexts (off-host, http, unparseable issuer). - **Blank-line churn revert is clean.** `git diff origin/main 797929974 -- internal/settings/oidc_handler.go` shows the base commit accidentally *stripped* the file's trailing newline; the fix commit's `+` blank line at EOF restores it. Confirmed `git diff origin/main origin/bd-bookshelf-tm38.9 -- internal/settings/oidc_handler.go` now shows only the intended `RPInitiatedLogout` field addition — byte-identical elsewhere. - Threading through `oidcIssueTokens`/`oidcFinalizeLogin`/`LoginResult` is consistent; single call site, no other caller broken. No `.golangci.yml` or `scripts/check-coverage.sh` exclusions added. New tests are black-box and one-Expect-per-It. ### Findings [MAJOR] internal/users/oidc_handler_test.go:213-266 — no test locks the exact call site that was just fixed (cookie Expiry ← IDTokenExpiry, not RefreshTokenExpiry) The `oidcCallbackHandler` `Describe` block's `callbackResult` only sets `AccessToken`/`RefreshToken`/`IDToken` — `IDTokenExpiry` and `RefreshTokenExpiry` are both left at their zero value. The only handler-level assertion is that a cookie named `bookshelf_oidc_idtoken` is *present* (`oidc_handler_test.go:265-271`); nothing asserts its `Expires`/`MaxAge`. The regression coverage added in this fix commit (`oidc_coverage_test.go` `result.IDTokenExpiry` assertion, `oidc_service_test.go`) only proves `LoginResult.IDTokenExpiry` is computed correctly from claims — it never exercises the `setOIDCIDTokenCookie(w, result.IDToken, secureCookies, result.IDTokenExpiry)` call site in the handler. If a future edit silently reverted `result.IDTokenExpiry` back to `result.RefreshTokenExpiry` at that call site (the exact bug this fix addresses), no test in the suite would fail — the 30-day-cookie regression could ship undetected. Fix: in the `oidcCallbackHandler` `Describe`, set `callbackResult.IDTokenExpiry` and `callbackResult.RefreshTokenExpiry` to two distinct, distinguishable times in `BeforeEach`, then add an `It` asserting the `bookshelf_oidc_idtoken` cookie's `Expires` equals `callbackResult.IDTokenExpiry` (and, ideally, that it does NOT equal `RefreshTokenExpiry`) so this exact regression is test-locked at the layer where it actually manifests (the cookie on the wire), not just at the `LoginResult` computation layer. [MINOR] internal/users/oidc_service.go:966 — comment grammar `"Do not assume a future reader that it is checked."` reads awkwardly (subject/object swapped). Suggested: `"Do not let a future reader assume it is checked."` REVIEW VERDICT: 0 blocker, 1 major, 1 minor
fix(review): address PR #1398 code re-review MAJOR+MINOR
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m44s
/ E2E API (pull_request) Successful in 1m55s
/ Integration (pull_request) Successful in 2m27s
/ Coverage (pull_request) Successful in 2m34s
/ Test Race (pull_request) Successful in 2m41s
/ Lint (pull_request) Successful in 3m19s
/ E2E Browser (pull_request) Successful in 4m38s
/ Hugo build (pull_request) Successful in 9m48s
c89340c476
Add a handler test that pins oidcCallbackHandler's ID-token cookie
Expires to LoginResult.IDTokenExpiry (not RefreshTokenExpiry) by using
distinct fixture values for the two, so a regression to the previous
call site fails the test. Also fix a grammar nit in an oidc_service.go
comment.

Settings journey screenshot (settings-shell-email-tab-modal)

settings-shell-email-tab-modal

**Settings journey screenshot** (settings-shell-email-tab-modal) ![settings-shell-email-tab-modal](/attachments/4db15606-2494-4493-96ad-0f1c37b11934)

Settings journey screenshot (settings-shell-metadata-tab)

settings-shell-metadata-tab

**Settings journey screenshot** (settings-shell-metadata-tab) ![settings-shell-metadata-tab](/attachments/fe947006-7e95-440f-bef7-32fb695002e0)

Settings journey screenshot (settings-ratelimits-comicvine-throttled)

settings-ratelimits-comicvine-throttled

**Settings journey screenshot** (settings-ratelimits-comicvine-throttled) ![settings-ratelimits-comicvine-throttled](/attachments/c74b3aa3-d2c6-49a7-90f7-03cab10fc409)

Settings journey screenshot (settings-comic-weights-max-saves)

settings-comic-weights-max-saves

**Settings journey screenshot** (settings-comic-weights-max-saves) ![settings-comic-weights-max-saves](/attachments/301ae007-3614-4b3a-8ffa-23b618ed3696)

Settings journey screenshot (settings-comic-field-priority-matrix-saved)

settings-comic-field-priority-matrix-saved

**Settings journey screenshot** (settings-comic-field-priority-matrix-saved) ![settings-comic-field-priority-matrix-saved](/attachments/f257a390-49ea-43f4-8c71-2b3f21a9c5e9)

Settings journey screenshot (oidc-settings-form-fields)

oidc-settings-form-fields

**Settings journey screenshot** (oidc-settings-form-fields) ![oidc-settings-form-fields](/attachments/6b7d3b71-6abe-4282-abfe-797f90f32cb8)

Settings journey screenshot (oidc-rp-initiated-logout-toggle-checked)

oidc-rp-initiated-logout-toggle-checked

**Settings journey screenshot** (oidc-rp-initiated-logout-toggle-checked) ![oidc-rp-initiated-logout-toggle-checked](/attachments/3a5d5777-5bc4-4b1b-9824-16792ec422ad)

Settings journey screenshot (oidc-settings-after-reload)

oidc-settings-after-reload

**Settings journey screenshot** (oidc-settings-after-reload) ![oidc-settings-after-reload](/attachments/b9499736-46cd-4976-9e42-6a4facc8600d)

Settings journey screenshot (oidc-test-connection-result)

oidc-test-connection-result

**Settings journey screenshot** (oidc-test-connection-result) ![oidc-test-connection-result](/attachments/07a72f15-b8c6-4222-8a9e-97b48e0368fd)

Settings journey screenshot (oidc-group-mapping-modal-open)

oidc-group-mapping-modal-open

**Settings journey screenshot** (oidc-group-mapping-modal-open) ![oidc-group-mapping-modal-open](/attachments/2f5c71f1-80b1-440b-b7b4-514958393113)

Settings journey screenshot (oidc-group-mapping-after-create)

oidc-group-mapping-after-create

**Settings journey screenshot** (oidc-group-mapping-after-create) ![oidc-group-mapping-after-create](/attachments/28394711-be72-4ea4-947c-0585a0dce1ce)

Settings journey screenshot (oidc-group-mapping-after-edit)

oidc-group-mapping-after-edit

**Settings journey screenshot** (oidc-group-mapping-after-edit) ![oidc-group-mapping-after-edit](/attachments/4210fb92-57db-4005-9c6f-85f386904fd4)

Settings journey screenshot (oidc-group-mapping-after-delete)

oidc-group-mapping-after-delete

**Settings journey screenshot** (oidc-group-mapping-after-delete) ![oidc-group-mapping-after-delete](/attachments/98fa4130-769e-4fb4-b6af-0c80524f245b)

Settings journey screenshot (sidecar-settings-toggle-saved)

sidecar-settings-toggle-saved

**Settings journey screenshot** (sidecar-settings-toggle-saved) ![sidecar-settings-toggle-saved](/attachments/c76e6b6b-2f95-4a24-a60e-275269e16f2b)

Settings journey screenshot (sidecar-settings-toggle-persisted)

sidecar-settings-toggle-persisted

**Settings journey screenshot** (sidecar-settings-toggle-persisted) ![sidecar-settings-toggle-persisted](/attachments/fc4d1df7-ea2b-4e06-a7b5-e5c6dac86d94)
zombor force-pushed bd-bookshelf-tm38.9 from c89340c476
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m44s
/ E2E API (pull_request) Successful in 1m55s
/ Integration (pull_request) Successful in 2m27s
/ Coverage (pull_request) Successful in 2m34s
/ Test Race (pull_request) Successful in 2m41s
/ Lint (pull_request) Successful in 3m19s
/ E2E Browser (pull_request) Successful in 4m38s
/ Hugo build (pull_request) Successful in 9m48s
to 8a746189f3
All checks were successful
/ Hugo build (pull_request) Successful in 1m5s
/ JS Unit Tests (pull_request) Successful in 1m28s
/ E2E API (pull_request) Successful in 1m34s
/ Test Race (pull_request) Successful in 2m1s
/ Lint (pull_request) Successful in 2m22s
/ Coverage (pull_request) Successful in 2m33s
/ Integration (pull_request) Successful in 2m35s
/ E2E Browser (pull_request) Successful in 4m20s
2026-08-08 16:12:04 +00:00
Compare

Settings journey screenshot (settings-shell-email-tab-modal)

settings-shell-email-tab-modal

**Settings journey screenshot** (settings-shell-email-tab-modal) ![settings-shell-email-tab-modal](/attachments/ed443ad9-be0c-424d-aa8a-80f08da9d7ab)

Settings journey screenshot (settings-shell-metadata-tab)

settings-shell-metadata-tab

**Settings journey screenshot** (settings-shell-metadata-tab) ![settings-shell-metadata-tab](/attachments/45249aba-44d2-4c40-bf4d-d6e70c0e66b4)

Settings journey screenshot (settings-ratelimits-comicvine-throttled)

settings-ratelimits-comicvine-throttled

**Settings journey screenshot** (settings-ratelimits-comicvine-throttled) ![settings-ratelimits-comicvine-throttled](/attachments/77268f5e-271a-4bd0-8d64-e0d3075f9ffe)

Settings journey screenshot (settings-comic-weights-max-saves)

settings-comic-weights-max-saves

**Settings journey screenshot** (settings-comic-weights-max-saves) ![settings-comic-weights-max-saves](/attachments/8d6004c1-a296-4c76-8b31-062dd1d41850)

Settings journey screenshot (settings-comic-field-priority-matrix-saved)

settings-comic-field-priority-matrix-saved

**Settings journey screenshot** (settings-comic-field-priority-matrix-saved) ![settings-comic-field-priority-matrix-saved](/attachments/960faca5-14d1-4a4e-a6b5-2ae56759977b)

Settings journey screenshot (oidc-settings-form-fields)

oidc-settings-form-fields

**Settings journey screenshot** (oidc-settings-form-fields) ![oidc-settings-form-fields](/attachments/3d128f09-4ae1-4d9a-b546-77264b1fd390)

Settings journey screenshot (oidc-rp-initiated-logout-toggle-checked)

oidc-rp-initiated-logout-toggle-checked

**Settings journey screenshot** (oidc-rp-initiated-logout-toggle-checked) ![oidc-rp-initiated-logout-toggle-checked](/attachments/ea15e4e1-c39d-4acc-9737-808a43d58985)

Settings journey screenshot (oidc-settings-after-reload)

oidc-settings-after-reload

**Settings journey screenshot** (oidc-settings-after-reload) ![oidc-settings-after-reload](/attachments/dc4cabba-b4d1-49d1-905f-e04991bd2e10)

Settings journey screenshot (oidc-test-connection-structured-diagnostic)

oidc-test-connection-structured-diagnostic

**Settings journey screenshot** (oidc-test-connection-structured-diagnostic) ![oidc-test-connection-structured-diagnostic](/attachments/4777985a-de8e-4e99-935b-f4f2c546b1e0)

Settings journey screenshot (oidc-group-mapping-modal-open)

oidc-group-mapping-modal-open

**Settings journey screenshot** (oidc-group-mapping-modal-open) ![oidc-group-mapping-modal-open](/attachments/0a40a44a-4210-490f-adcc-92fe41c6b087)

Settings journey screenshot (oidc-group-mapping-after-create)

oidc-group-mapping-after-create

**Settings journey screenshot** (oidc-group-mapping-after-create) ![oidc-group-mapping-after-create](/attachments/4b9984d7-125b-40a8-879a-196de400c200)

Settings journey screenshot (oidc-group-mapping-after-edit)

oidc-group-mapping-after-edit

**Settings journey screenshot** (oidc-group-mapping-after-edit) ![oidc-group-mapping-after-edit](/attachments/a679a75a-28bf-46f3-8878-bbaa0492f165)

Settings journey screenshot (oidc-group-mapping-after-delete)

oidc-group-mapping-after-delete

**Settings journey screenshot** (oidc-group-mapping-after-delete) ![oidc-group-mapping-after-delete](/attachments/bc9ecd20-006e-411e-ab9c-dab30aef8b49)

Settings journey screenshot (sidecar-settings-toggle-saved)

sidecar-settings-toggle-saved

**Settings journey screenshot** (sidecar-settings-toggle-saved) ![sidecar-settings-toggle-saved](/attachments/f2a574ab-f2c0-43fd-9f00-386c2e32224d)

Settings journey screenshot (sidecar-settings-toggle-persisted)

sidecar-settings-toggle-persisted

**Settings journey screenshot** (sidecar-settings-toggle-persisted) ![sidecar-settings-toggle-persisted](/attachments/755c204f-60b5-44ae-b243-f633ae4ce751)
zombor merged commit cbd2c0a65e into main 2026-08-08 16:16:58 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
zombor/pergamum!1398
No description provided.