fix(security): require a configured session secret in production

session_secret defaulted to a random per-process value, which silently
invalidates all sessions on restart and rotates the management client secret.
Add _resolve_session_secret(): use the configured secret; allow a generated
one only in debug or for a localhost issuer; otherwise fail startup. The
management client secret is now tied to the resolved session secret.

Refs: porchlight-wvx

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan Lundberg 2026-06-05 14:12:54 +02:00
parent c175633980
commit cf2754f302
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
3 changed files with 40 additions and 5 deletions

View file

@ -82,8 +82,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
}
oidc_server.keyjar.add_symmetric(client_id, client_cfg.client_secret)
# Register management client
manage_secret = settings.session_secret or secrets.token_hex(32)
# Register management client (stable secret tied to the session secret)
manage_secret = app.state.session_secret
oidc_server.context.cdb[settings.manage_client_id] = {
"client_id": settings.manage_client_id,
"client_secret": manage_secret,
@ -101,6 +101,21 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
raise NotImplementedError("MongoDB backend not yet implemented")
def _resolve_session_secret(settings: Settings) -> str:
"""Return the session signing secret, requiring one in production.
A random per-process secret silently invalidates sessions on restart and
rotates the management client secret, so it is only acceptable for local
development (debug or a localhost issuer).
"""
if settings.session_secret:
return settings.session_secret
host = urlparse(settings.issuer).hostname or ""
if settings.debug or host in ("localhost", "127.0.0.1", "::1"):
return secrets.token_hex(32)
raise RuntimeError("OIDC_OP_SESSION_SECRET must be set in production (non-debug, non-localhost issuer).")
def create_app(settings: Settings | None = None) -> FastAPI:
if settings is None:
settings = Settings()
@ -116,7 +131,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.state.settings = settings
# Session middleware
session_secret = settings.session_secret or secrets.token_hex(32)
session_secret = _resolve_session_secret(settings)
app.state.session_secret = session_secret
app.add_middleware(
CSRFMiddleware, # ty: ignore[invalid-argument-type]
exempt_paths={"/token", "/userinfo"},