Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
84cb220
feat(auth): add generic OpenID Connect (OIDC) single sign-on
holden093 Jun 7, 2026
d1d2b67
fix: add OIDC env vars to standalone GPU compose files
holden093 Jun 7, 2026
f75e4bd
fix(oidc): address review items — aud arrays, JWKS cache, alg pinning…
holden093 Jun 9, 2026
873fd19
fix(oidc): address second-pass review — CSRF cookie, admin demotion, …
holden093 Jun 9, 2026
879a1fe
Potential fix for pull request finding
holden093 Jun 10, 2026
7c3c4db
fix(oidc): address RaresKeY review — bootstrap gating and UserInfo su…
holden093 Jun 13, 2026
b966632
fix(oidc): add missing OIDC_REDIRECT_URI and OIDC_FIRST_USER_IS_ADMIN…
holden093 Jun 13, 2026
e1e82d5
fix(oidc): surface callback errors on login page from URL query param
holden093 Jun 15, 2026
c87bcfb
fix(oidc): address remaining review items — issuer fail-closed, multi…
holden093 Jun 15, 2026
d303739
fix(oidc): wrap JWKS errors as OidcError, bind token exchange to stor…
holden093 Jun 18, 2026
e1863e9
fix(oidc): offload code exchange off the event loop to avoid blocking
holden093 Jun 24, 2026
9215195
fix(oidc): throttle failed JWKS refreshes and persist state key acros…
holden093 Jun 24, 2026
739eb0f
fix(oidc): serialize admin bootstrap, atomic key creation, guard agai…
holden093 Jun 26, 2026
8375322
fix(oidc): serialize auth across workers, guard UserInfo demotion, at…
holden093 Jun 26, 2026
d47c607
fix(oidc): add intra-process lock to _interprocess_auth_lock
holden093 Jun 29, 2026
c87fcc7
fix(oidc): address four current-head blockers from RaresKeY review
holden093 Jun 30, 2026
8172817
fix(oidc): serialize create_user() across workers
holden093 Jun 30, 2026
352f4cf
fix(oidc): enforce reserved-username check in _create_user_locked
holden093 Jun 30, 2026
2001bf7
fix(auth): serialize all auth.json writers under inter-process lock
holden093 Jul 4, 2026
9e6e8b6
fix(oidc): security hardening — 11 fixes from multi-model review
holden093 Jul 12, 2026
12d3368
fix(oidc): address all MEDIUM findings from 4-model security review
holden093 Jul 12, 2026
bcef54a
test(oidc): add regression test for last-admin demotion guard
holden093 Jul 12, 2026
c8e537a
fix(oidc): address review findings — PKCE, iat, TLS, sub, cookies, se…
holden093 Jul 18, 2026
e3e1694
fix(auth): proactive hardening — cross-worker revocation, file perms,…
holden093 Jul 18, 2026
99a1a10
Merge branch 'odysseus-dev:dev' into feat/oidc-sso-reopen
holden093 Jul 30, 2026
378669c
Merge branch 'odysseus-dev:dev' into feat/oidc-sso-reopen
holden093 Aug 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,51 @@ SEARXNG_INSTANCE=http://localhost:8080
# CORS allowed origins (default: localhost-only; restrict to your public origin in production)
# ALLOWED_ORIGINS=http://localhost:7000,http://localhost:8000

# ============================================================
# OpenID Connect (OIDC) — Single Sign-On
# ============================================================
# Enable OIDC authentication alongside the existing password login.
# OIDC_ENABLED=false
#
# OIDC provider issuer URL (must expose .well-known/openid-configuration).
# OIDC_ISSUER=https://keycloak.example.com/realms/myrealm
#
# Client credentials registered with the OIDC provider.
# OIDC_CLIENT_ID=odysseus
# OIDC_CLIENT_SECRET=your_client_secret_here
#
# Scopes to request (openid is required; profile and email are recommended).
# OIDC_SCOPES=openid profile email
#
# Optional fixed redirect URI — use when behind a proxy to avoid
# trusting the Host header. If unset, derived from the inbound request.
# OIDC_REDIRECT_URI=https://odysseus.example.com/api/auth/oidc/callback
#
# Comma-separated list of OIDC group names that grant admin privileges.
# When a user's `groups` claim includes one of these values, they become
# an admin on every login. Removing the group in the IdP revokes admin
# on the next login, so access follows the IdP.
# OIDC_ADMIN_GROUPS=odysseus-admins
#
# When true (default), the first OIDC user becomes admin if no users
# exist yet and OIDC_ADMIN_GROUPS is unset — prevents zero-admin lockout.
# OIDC_FIRST_USER_IS_ADMIN=true
#
# Session cookies use SameSite=Lax (HTTP-only, SameSite=Lax). This
# supports top-level GET redirects (normal OIDC flow) but not cross-site
# iframe/subresource callbacks. When deploying behind a reverse proxy,
# use OIDC_REDIRECT_URI to ensure the callback origin matches the
# browser-visible origin.
# SECURE_COOKIES=true should be set for HTTPS deployments. Set
# TRUST_PROXY_HEADERS=true only when the app is behind a trusted proxy that
# strips/replaces inbound X-Forwarded-Proto; otherwise client-supplied
# forwarded headers are ignored for cookie security decisions.
#
# OIDC session and CSRF cookies always carry the Secure flag, regardless
# of SECURE_COOKIES — SSO implies a TLS deployment. The only opt-out is
# the development-only override below; never enable it in production.
# OIDC_ALLOW_INSECURE_COOKIES=false

# ============================================================
# ChromaDB (vector store)
# ============================================================
Expand Down
23 changes: 22 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ async def dispatch(self, request, call_next):

# ========= AUTH =========
from routes.auth_routes import setup_auth_routes, SESSION_COOKIE
from core.oidc import init_oidc_manager

auth_manager = AuthManager()
app.state.auth_manager = auth_manager
Expand All @@ -263,6 +264,9 @@ async def dispatch(self, request, call_next):
"/api/auth/features",
"/api/auth/settings",
"/api/auth/integrations/presets",
"/api/auth/oidc/login",
"/api/auth/oidc/callback",
"/api/auth/oidc/config",
"/api/health",
"/api/version",
"/login",
Expand Down Expand Up @@ -627,7 +631,6 @@ async def web_search_error_handler(request: Request, exc: WebSearchError):
auth_router = setup_auth_routes(auth_manager)
app.include_router(auth_router)


@app.post("/api/activity/heartbeat")
async def activity_heartbeat():
from src.interactive_gate import mark_browser_activity
Expand All @@ -641,6 +644,24 @@ async def _stop_background():
return {"ok": True}


# OIDC (single sign-on) — initialised after auth_manager so the OIDC routes
# can look up / create users.
# Register routes whenever OIDC_ENABLED=true (even if provider discovery
# hasn't succeeded yet) so the /config endpoint can report the error to
# the login page instead of 404-ing silently.
_OIDC_ENABLED = os.getenv("OIDC_ENABLED", "false").lower() == "true"
oidc_manager = init_oidc_manager() if _OIDC_ENABLED else None
if _OIDC_ENABLED:
from routes.oidc_routes import setup_oidc_routes
oidc_router = setup_oidc_routes(auth_manager, oidc_manager)
app.include_router(oidc_router)
if oidc_manager is not None:
logger.info("OIDC routes registered — provider discovered")
else:
logger.info("OIDC routes registered — provider not yet reachable")
else:
logger.info("OIDC disabled (set OIDC_ENABLED=true and provider vars to enable)")

# Uploads
from routes.upload_routes import setup_upload_routes
upload_router, upload_cleanup_func = setup_upload_routes(upload_handler)
Expand Down
14 changes: 13 additions & 1 deletion core/atomic_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,27 @@
from typing import Any, Optional


def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) -> None:
def atomic_write_json(
path: str, data: Any, *, indent: Optional[int] = None, mode: Optional[int] = None
) -> None:
"""Atomically persist `data` as JSON at `path`.

The temp file uses the live PID as a suffix so two processes saving the
same file (e.g. unit tests) don't collide on the rename target.

When *mode* is given (e.g. ``0o600`` for files holding secrets), the
temp file is chmod'ed before the rename so the restricted permissions
are in place atomically with the content — there is no window where
the target exists with default-umask permissions.
"""
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = f"{path}.tmp.{os.getpid()}"
with open(tmp, "w", encoding="utf-8") as f:
if mode is not None:
try:
os.fchmod(f.fileno(), mode)
except AttributeError: # Windows has no fchmod
os.chmod(tmp, mode)
json.dump(data, f, indent=indent)
f.flush()
os.fsync(f.fileno())
Expand Down
Loading