feat(oidc): configurable per-login session duration override (bookshelf-tm38.11) #1395
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-tm38.11"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
OIDC_SESSION_DURATION_HOURSapp_settings value (mirrors Grimmory's setting of the same name) that overrides the refresh-token (session) lifetime for OIDC/SSO logins only.--refresh-token-ttldefault, same as today. Local username/password logins are always unaffected.OIDC_PROVIDER_DETAILSapp_settings JSON blob — no new Grimmory-table column.oidc_settings_controller.js(reuses canonical.provider-settings-key-row/.provider-settings-key-inputclasses, no inlinestyle=).docs/content/docs/administering/oidc.md.Test plan
internal/settings: SaveOIDCSettings/GetOIDCConfig round-trip tests — override applied when set, zero (default) when unset, clamped at the 1-year ceiling; handler pass-through test.internal/users: OIDCHandleCallback full-flow test asserts the refresh-token expiry reflects the override when configured, and the global default when not; access-token expiry is unaffected by the override.static/js/test/oidc_settings_controller.test.js: new SESSION DURATION describe block (parsed int, blank→0, non-numeric→0) + no-target false-arm coverage.e2e/browser/journey_settings_test.go(existing Ordered OIDC journey, no new Describe): extended to assert the new field renders, round-trips through save+reload, and is captured in the existing OIDC screenshots.go build ./...,make lint,make test,make coverage(100% oninternal/),npm run coverage(100% on JS) all green locally.Closes bead bookshelf-tm38.11 on merge.
Security review of #1395 (bookshelf-tm38.11) — OIDC session/token TTL configurable.
Scope reviewed: internal/settings/oidc_settings.go, oidc_handler.go, internal/users/oidc_service.go, internal/users/wire.go, internal/appwire/appwire.go, templates/pages/settings_shell.html, static/js/controllers/oidc_settings_controller.js, docs/content/docs/administering/oidc.md, e2e/browser/journey_settings_test.go.
Checks performed:
PUT /settings/oidc(routes.go:62) is wrapped inadminRequired(...), unchanged by this PR — the newsession_duration_hoursfield rides the existing admin-gated route. No new unauthenticated/non-admin surface.clampOIDCSessionDurationHours(oidc_settings.go) is applied both at save time (validateSaveOIDC) AND again at read time inGetOIDCConfigbefore converting totime.Duration— defense in depth, so a value written before the clamp existed (or via direct DB edit) is still re-clamped on every issuance read, not just on save. Negative/zero → treated as "unset" (0, falls back to global default), not clamped up to the 1h floor — verified by test"clamps a below-minimum value up to the minimum"(which actually asserts negative → zero/unset, correctly documented). Upper bound clamps to24*365hours. No overflow risk (inthours converted totime.Durationvia multiplication bytime.Hour; max value 8760 is far below overflow range).SessionDuration/effectiveRefreshTTL, which feeds the refresh-token row TTL (createRefreshToken/CreateRefreshTokenParams.ExpiryDate) — a DB-backed, revocable token. The access-token TTL (DefaultAccessTokenTTL= 15 min, JWT) is untouched by this change (confirmed in oidc_service.go:accessTTLpassed through unchanged; test"does not affect the access-token expiry"pins this). So a disabled/logged-out user's exposure window after revocation stays bounded by the 15-minute access-token TTL regardless of how long the admin sets the session/refresh-token duration — extending session length does not meaningfully undermine revocation.SessionDurationHourslives in the globalOIDC_PROVIDER_DETAILSapp_settings JSON blob (admin-only settings surface), not derived from any request/session value at issuance time (GetOIDCConfigreads only from stored app_settings). Local (non-OIDC) logins are explicitly unaffected (Login()always uses the global TTL directly per the code comment).oidc settings save started/completedlog lines (enabled/provider_name/issuer_uri) are unchanged.style=introduced in the template diff.No findings.
REVIEW VERDICT: 0 blocker, 0 major, 0 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.Durationtoappwire.OIDCRuntimeConfig(internal/appwire/appwire.go:854-856) and correctly threads it throughinternal/settings/oidc_settings.go'sGetOIDCConfigandinternal/users/wire.go:123(SessionDuration: rc.SessionDuration). But the one production call site that constructsappwire.OIDCRuntimeConfig— theGetOIDCConfigclosure ininternal/app/app.go(lines 354-373) — builds the struct with an explicit field list (Enabled,Issuer,ClientID, ...OIDCOnlyMode) and does not copyrc.SessionDurationfromsettings.OIDCRuntimeConfiginto it. Since this diff does not touchinternal/app/app.goat all, the field silently defaults to zero at runtime, soeffectiveRefreshTTL(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.SessionDurationdirectly (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 realapp.Newwiring closure in app.go that is the only place this actually gets consumed.Fix: add
SessionDuration: rc.SessionDuration,to the struct literal ininternal/app/app.go'sGetOIDCConfigclosure (~line 372, alongsideOIDCOnlyMode). Also worth flagging (pre-existing, out of scope for this bead but same shape of bug):DefaultPermissions/DefaultLibraryIDsare 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 (realapp.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'sItbodiesThree new
Itblocks assertcapturedRefreshTTL.ExpiryDate/result.AccessTokenExpiryagainsttime.Now().Add(...)computed at assertion time (not injected/frozen), with a 5-secondBeTemporally("~", ..., 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 (JustBeforeEachinvokinghandleCallback, which internally computesnow.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 tologger/config plumbed by closures — prefer injecting a fixednow(mirroring the pattern already used elsewhere in this codebase, e.g.internal/users/device_store_test.go:48fakeNow, andinternal/users/service_test.go:257fixedNow) rather than a wall-clock tolerance window.Fix: thread a fixed
now time.Timeinto whatever computesExpiryDate/AccessTokenExpiryin this flow (or, if that's out of reach without touching production code in this bead, at minimum capturetime.Now()once beforeJustBeforeEachruns and assert against that captured value with a tight tolerance, removing the skew between per-Ittime.Now()calls and the actual event).[MINOR] templates/pages/settings_shell.html:1361 — session-duration input has
min="1"but nomaxattributeThe clamp is enforced server-side (
oidcSessionDurationMaxHours= 8760h/1yr) so this is not a correctness issue, but addingmax="8760"to the<input type="number">would give the admin instant client-side feedback consistent with themin="1"already present, rather than only discovering the clamp after a round-trip save+reload.REVIEW VERDICT: 1 blocker, 1 major, 1 minor
Security re-review of PR #1395 (bookshelf-tm38.11) — now that
SessionDurationis actually wired intointernal/app/app.go'sGetOIDCConfigclosure and the clock is injected through token issuance.Re-verified the posture that was previously reviewed only in the abstract, now that it is live at runtime:
internal/settings/oidc_settings.goclampOIDCSessionDurationHours(≤0 → 0/"unset", >8760 → clamped to 8760) is applied both at save time (validateSaveOIDC) and again defensively at read time (GetOIDCConfig), soOIDCRuntimeConfig.SessionDurationreachingappwire.OIDCRuntimeConfig/users.OIDCConfigviainternal/app/app.go:374andinternal/users/wire.go:126is always already in[0, 8760h]— no unbounded/absurd TTL is reachable through the newly-live wiring.internal/users/oidc_service.goeffectiveRefreshTTL(cfg.SessionDuration, refreshTTL)is applied only to therefreshTTLargument passed intooidcFinalizeLogin/oidcIssueTokens(oidc_service.go:450);accessTTLis threaded through unchanged fromwire.go's fixedd.AccessTokenTTL(15 min) with nocfg.SessionDurationinvolvement anywhere in that path. Verified by the new table test atoidc_coverage_test.go("does not affect the access-token expiry"). A long session override still cannot defeat logout/disable/revocation, since the short-lived access JWT still expires on its fixed schedule and the revocable refresh token is the only thing lengthened.now func() time.Timeis threaded frominternal/users/wire.go:215as plaintime.Nowin production — not derived from any request header/claim/DB value. Only test callers substitute a fixed clock. No request can influence issued-token expiry via this seam.PUT /settings/oidcremains behindadminRequired(internal/settings/routes.go:88); the override value is never request-derived at issuance — it flows fromapp_settings(admin-controlled) throughGetOIDCConfig, never from the login request itself.SessionDurationHours(a plain integer duration setting, not a secret) is the only new field surfaced, and only via existing settings JSON, not logs.No regressions found from making the override live. The BLOCKER from the prior review (dead wiring) is resolved without reintroducing any bound-bypass or revocation-defeat.
REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Code Re-Review of PR #1395 (bookshelf-tm38.11) — verifying BLOCKER+MAJOR fix
BLOCKER re-check (SessionDuration dead-wiring): RESOLVED.
internal/app/app.go(realapp.Newclosure,GetOIDCConfig) now setsSessionDuration: rc.SessionDurationand threads it all the way tousers.Wire->OIDCHandleCallback->oidcFinalizeLogin->oidcIssueTokens, confirmed onorigin/bd-bookshelf-tm38.11(not the stale local checkout). The newe2e/api/journey_9_oidc_login_test.go"applies the configured OIDC session duration..." spec boots the real server viaapp.New(e2e/testutil/server.go:818), PUTs a livesession_duration_hours=2through/settings/oidc, drives a full OIDC login against the fake IdP, and readsrefresh_token.expiry_datestraight from MySQL, assertingttlbetween 90-150 min. This is a genuine regression guard: it exercises the real struct literal inapp.go, not a stub, and would fail against the unfixed code (which would produce ~720h). Good catch-proof test.MAJOR re-check (wall-clock assertions): RESOLVED.
oidcIssueTokens/OIDCHandleCallback/oidcFinalizeLoginnow take an injectednow func() time.Time(internal/users/oidc_service.golines ~341, 413, 483), wired from production viatime.Nowininternal/users/wire.go:215(confirmed correct positional order against theOIDCHandleCallbacksignature:secret, accessTTL, refreshTTL, now, logger). All 18 call-site updates (17 test stubs + 1 production wiring) are present and consistently ordered, verified by diffing every+time.Now,insertion against everyOIDCHandleCallback(call site inoidc_service_test.go,oidc_coverage_test.go,oidc_userinfo_test.go,oidc_cache_test.go. The "OIDCHandleCallback full flow" describe block now freezesfixedNowand assertscapturedRefreshTTL.ExpiryDatewith exactEqual(fixedNow.Add(...)), no tolerance window, fully deterministic. A siblingItalso confirms the access-token expiry is unaffected by the session-duration override (result.AccessTokenExpirystillfixedNow.Add(DefaultAccessTokenTTL)), so the clock threading did not regress access-token TTL behavior.MINOR re-check (missing max attr): RESOLVED.
templates/pages/settings_shell.htmlnow hasmax="8760"on the session-duration input, matchingoidcSessionDurationMaxHours = 24*365ininternal/settings/oidc_settings.go.Conventions: all touched test files are black-box (
package settings_test/package users_test); e2e journey uses the allowed multi-Expect-per-It relaxation; no.golangci.ymlorscripts/check-coverage.shchanges; docs updated in the same PR (docs/content/docs/administering/oidc.md).New finding (not present in the original review, spotted in the fix's new test)
[MINOR] internal/settings/oidc_settings_test.go:1303 - misleading It title for negative-value clamp test
The test is titled
It("clamps a below-minimum value up to the minimum", ...)but the body's own comment and assertion say the opposite: a negative value (-5) is treated as "unset" and returns 0, it is NOT clamped to a 1-hour minimum. There is in fact no minimum-clamp code path (clampOIDCSessionDurationHoursonly clampshours <= 0to 0 andhours > maxto max), so the title describes behavior the code doesn't implement. Fix: rename to something likeIt("treats a negative value as unset (0), not clamped to a minimum", ...)so a future reader isn't misled into thinking a minimum-clamp exists.REVIEW VERDICT: 0 blocker, 0 major, 1 minor
5cfd066ec650dc8f9605