Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
fe8c8e4
feat(auth): add generic OpenID Connect (OIDC) single sign-on
holden093 Jun 7, 2026
d00f19d
fix: add OIDC env vars to standalone GPU compose files
holden093 Jun 7, 2026
186be4a
fix(oidc): address review items — aud arrays, JWKS cache, alg pinning…
holden093 Jun 9, 2026
c05a2df
fix(oidc): address second-pass review — CSRF cookie, admin demotion, …
holden093 Jun 9, 2026
e77062d
Potential fix for pull request finding
holden093 Jun 10, 2026
51c418f
fix(oidc): address RaresKeY review — bootstrap gating and UserInfo su…
holden093 Jun 13, 2026
80ac306
fix(oidc): add missing OIDC_REDIRECT_URI and OIDC_FIRST_USER_IS_ADMIN…
holden093 Jun 13, 2026
dd9132f
fix(oidc): surface callback errors on login page from URL query param
holden093 Jun 15, 2026
f89f9c3
fix(oidc): address remaining review items — issuer fail-closed, multi…
holden093 Jun 15, 2026
45ec4a4
fix(oidc): wrap JWKS errors as OidcError, bind token exchange to stor…
holden093 Jun 18, 2026
f384692
fix(oidc): offload code exchange off the event loop to avoid blocking
holden093 Jun 24, 2026
e164ec6
fix(oidc): throttle failed JWKS refreshes and persist state key acros…
holden093 Jun 24, 2026
67eb7b9
fix(oidc): serialize admin bootstrap, atomic key creation, guard agai…
holden093 Jun 26, 2026
3886403
fix(oidc): serialize auth across workers, guard UserInfo demotion, at…
holden093 Jun 26, 2026
d7f85c6
fix(oidc): add intra-process lock to _interprocess_auth_lock
holden093 Jun 29, 2026
54c3fe8
fix(oidc): address four current-head blockers from RaresKeY review
holden093 Jun 30, 2026
90b916e
fix(oidc): serialize create_user() across workers
holden093 Jun 30, 2026
29ab254
fix(oidc): enforce reserved-username check in _create_user_locked
holden093 Jun 30, 2026
233d2f7
fix(auth): serialize all auth.json writers under inter-process lock
holden093 Jul 4, 2026
71e9ebc
Merge branch 'pewdiepie-archdaemon:dev' into feat/oidc-sso
holden093 Jul 8, 2026
1f18fb6
Merge branch 'pewdiepie-archdaemon:dev' into feat/oidc-sso
holden093 Jul 12, 2026
ffe96c5
fix(oidc): security hardening — 11 fixes from multi-model review
holden093 Jul 12, 2026
28e3d79
fix(oidc): address all MEDIUM findings from 4-model security review
holden093 Jul 12, 2026
4cf9326
test(oidc): add regression test for last-admin demotion guard
holden093 Jul 12, 2026
0384bbe
Merge branch 'pewdiepie-archdaemon:dev' into feat/oidc-sso
holden093 Jul 13, 2026
e1cd0bb
Merge branch 'pewdiepie-archdaemon:dev' into feat/oidc-sso
holden093 Jul 14, 2026
025f251
Merge branch 'odysseus-dev:dev' into feat/oidc-sso
holden093 Jul 17, 2026
ad808d2
fix(oidc): address review findings — PKCE, iat, TLS, sub, cookies, se…
holden093 Jul 18, 2026
988b86b
fix(auth): proactive hardening — cross-worker revocation, file perms,…
holden093 Jul 18, 2026
94d882a
Merge branch 'odysseus-dev:dev' into feat/oidc-sso
holden093 Jul 18, 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")
Comment thread
holden093 marked this conversation as resolved.
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