Skip to content

Commit ad808d2

Browse files
holden093claude
andcommitted
fix(oidc): address review findings — PKCE, iat, TLS, sub, cookies, sessions
Addresses RaresKeY's review (5 findings) and follow-up Basic RP validation comment on PR #3508: - Add PKCE (RFC 7636, S256): code_challenge in the authorization request, verifier carried in the Fernet-encrypted state, and code_verifier sent to the token endpoint. - Require the iat claim in id_tokens (OIDC Core §2); tokens without iat are now rejected. - Prefer client_secret_basic at the token endpoint per discovery (OIDC default), falling back to client_secret_post only when the provider excludes basic. - Require HTTPS for the issuer and authorization endpoint, not just the back-channel endpoints. - Preserve OIDC subs exactly (no strip) so distinct whitespace-bearing subjects can never collapse into one local account; same for the UserInfo sub-binding comparison. - Sync admin state only on a well-formed groups claim; UserInfo availability alone (or a malformed groups value) no longer demotes an existing admin. - OIDC session/CSRF cookies are Secure by default regardless of SECURE_COOKIES; explicit OIDC_ALLOW_INSECURE_COOKIES=true is the only (documented, dev-only) opt-out. - Make sessions issued by one uvicorn worker validate on others via an mtime-gated read-through reload of sessions.json. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GRiLb12nnLnBnYsg14oSWd
1 parent 025f251 commit ad808d2

7 files changed

Lines changed: 647 additions & 94 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ SEARXNG_INSTANCE=http://localhost:8080
130130
# TRUST_PROXY_HEADERS=true only when the app is behind a trusted proxy that
131131
# strips/replaces inbound X-Forwarded-Proto; otherwise client-supplied
132132
# forwarded headers are ignored for cookie security decisions.
133+
#
134+
# OIDC session and CSRF cookies always carry the Secure flag, regardless
135+
# of SECURE_COOKIES — SSO implies a TLS deployment. The only opt-out is
136+
# the development-only override below; never enable it in production.
137+
# OIDC_ALLOW_INSECURE_COOKIES=false
133138

134139
# ============================================================
135140
# ChromaDB (vector store)

core/auth.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ def __init__(self, auth_path: str = DEFAULT_AUTH_PATH):
136136
# all uvicorn workers so first-admin bootstrap and auth.json mutations
137137
# are serialised across processes, not just threads within one worker.
138138
self._ipc_lock_path = auth_path + ".lock"
139+
# mtime of sessions.json at last load — lets validate_token cheaply
140+
# detect sessions written by other uvicorn workers (see
141+
# _reload_sessions_if_changed).
142+
self._sessions_mtime_ns = -1
139143
self._load()
140144
self._load_sessions()
141145
self._migrate_single_user()
@@ -168,6 +172,7 @@ def _load_sessions(self):
168172
"""Load persisted session tokens from disk, pruning expired ones."""
169173
try:
170174
if os.path.exists(self._sessions_path):
175+
self._sessions_mtime_ns = os.stat(self._sessions_path).st_mtime_ns
171176
with open(self._sessions_path, "r", encoding="utf-8") as f:
172177
data = json.load(f)
173178
now = time.time()
@@ -180,6 +185,46 @@ def _load_sessions(self):
180185
logger.error(f"Failed to load sessions: {e}")
181186
self._sessions = {}
182187

188+
def _reload_sessions_if_changed(self):
189+
"""Merge sessions written by other uvicorn workers.
190+
191+
The OIDC callback (or a password login) may run on one worker while
192+
the browser's next request lands on another; each worker loads
193+
sessions.json only at startup, so the new token would be rejected.
194+
Called on a token miss: when the file's mtime has changed since the
195+
last load, re-read it and add unknown unexpired tokens to the
196+
in-memory map. The mtime gate keeps unknown-token spam at one
197+
os.stat per request, not a JSON parse.
198+
199+
Additive only — tokens missing from disk are NOT dropped from
200+
memory, so a token issued moments ago on this worker can't be lost
201+
to a reload racing its own _save_sessions.
202+
"""
203+
try:
204+
stat = os.stat(self._sessions_path)
205+
except OSError:
206+
return
207+
with self._sessions_lock:
208+
if stat.st_mtime_ns == self._sessions_mtime_ns:
209+
return
210+
try:
211+
with open(self._sessions_path, "r", encoding="utf-8") as f:
212+
data = json.load(f)
213+
except Exception as e:
214+
logger.error(f"Failed to reload sessions: {e}")
215+
return
216+
self._sessions_mtime_ns = stat.st_mtime_ns
217+
if not isinstance(data, dict):
218+
return
219+
now = time.time()
220+
for tok, sess in data.items():
221+
if (
222+
tok not in self._sessions
223+
and isinstance(sess, dict)
224+
and sess.get("expiry", 0) > now
225+
):
226+
self._sessions[tok] = sess
227+
183228
def _save_sessions(self):
184229
"""Persist session tokens to disk (atomic, lock-guarded)."""
185230
try:
@@ -878,6 +923,11 @@ def create_session_trusted(self, username: str) -> Optional[str]:
878923
def validate_token(self, token: Optional[str]) -> bool:
879924
if not token:
880925
return False
926+
with self._sessions_lock:
927+
known = token in self._sessions
928+
if not known:
929+
# May have been issued by another worker — read through to disk.
930+
self._reload_sessions_if_changed()
881931
expired = False
882932
deleted_user = False
883933
with self._sessions_lock:
@@ -904,6 +954,11 @@ def get_username_for_token(self, token: Optional[str]) -> Optional[str]:
904954
"""Return the username associated with a valid token."""
905955
if not token:
906956
return None
957+
with self._sessions_lock:
958+
known = token in self._sessions
959+
if not known:
960+
# May have been issued by another worker — read through to disk.
961+
self._reload_sessions_if_changed()
907962
expired = False
908963
deleted_user = False
909964
with self._sessions_lock:

core/oidc.py

Lines changed: 94 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
60-second cooldown throttles both successful and failed refreshes.
2727
"""
2828

29+
import base64
30+
import hashlib
2931
import json
3032
import logging
3133
import math
@@ -74,12 +76,13 @@ def _get_state_fernet():
7476
return _state_fernet
7577

7678

77-
def _encode_state(nonce: str, redirect_uri: str) -> str:
79+
def _encode_state(nonce: str, redirect_uri: str, code_verifier: str) -> str:
7880
"""Return a Fernet-encrypted state token containing nonce + metadata."""
7981
fernet = _get_state_fernet()
8082
payload = json.dumps({
8183
"nonce": nonce,
8284
"redirect_uri": redirect_uri,
85+
"code_verifier": code_verifier,
8386
"created": time.time(),
8487
})
8588
return fernet.encrypt(payload.encode()).decode()
@@ -97,11 +100,14 @@ def _decode_state(state: str) -> Optional[Dict[str, Any]]:
97100
return None
98101
nonce = data.get("nonce")
99102
redirect_uri = data.get("redirect_uri")
103+
code_verifier = data.get("code_verifier")
100104
created = data.get("created")
101105
if not isinstance(nonce, str) or not nonce:
102106
return None
103107
if not isinstance(redirect_uri, str):
104108
return None
109+
if not isinstance(code_verifier, str) or not code_verifier:
110+
return None
105111
if not _is_numericdate(created):
106112
return None
107113
now = time.time()
@@ -164,8 +170,19 @@ def __init__(
164170
self._jwks_cache: Dict[str, Dict[str, Any]] = {}
165171
self._jwks_cache_lock = threading.Lock()
166172
self._allowed_algs: Optional[List[str]] = None
173+
self._token_auth_methods: List[str] = ["client_secret_basic"]
167174
self._discover()
168175

176+
def _use_basic_auth(self) -> bool:
177+
"""True when the token endpoint should use client_secret_basic."""
178+
if "client_secret_basic" in self._token_auth_methods:
179+
return True
180+
if "client_secret_post" in self._token_auth_methods:
181+
return False
182+
# Provider advertises neither shared-secret method — use the OIDC
183+
# default rather than silently leaking the secret in the body.
184+
return True
185+
169186
# -- discovery -----------------------------------------------------------
170187

171188
def _discover(self) -> None:
@@ -176,6 +193,15 @@ def _discover(self) -> None:
176193
well_known_url = self.issuer + "/.well-known/openid-configuration"
177194
if not well_known_url.startswith(("http://", "https://")):
178195
well_known_url = f"https://{well_known_url}"
196+
# The issuer (and therefore the discovery document) must be HTTPS:
197+
# the authorization redirect carries state/nonce, and an http://
198+
# issuer lets an active network attacker substitute authorization
199+
# codes or rewrite the discovery document entirely.
200+
if not well_known_url.startswith("https://"):
201+
raise OidcError(
202+
f"OIDC issuer must use HTTPS, got {self.issuer!r}. "
203+
"Configure the IdP with TLS or set OIDC_ENABLED=false."
204+
)
179205

180206
try:
181207
resp = httpx.get(well_known_url, timeout=15.0)
@@ -204,10 +230,12 @@ def _discover(self) -> None:
204230
f"discovery doc returned {doc_issuer!r}"
205231
)
206232

207-
# Client credentials and bearer tokens must never be sent over
208-
# cleartext transport. Authorization is browser-facing and is not
209-
# included because it does not carry those secrets.
210-
for name in ("token_endpoint", "jwks_uri", "userinfo_endpoint"):
233+
# No OIDC endpoint may use cleartext transport. The back-channel
234+
# endpoints carry client credentials and bearer tokens; the browser-
235+
# facing authorization endpoint carries state/nonce and returns the
236+
# authorization code, so an http:// endpoint enables code
237+
# substitution by an active network observer.
238+
for name in ("authorization_endpoint", "token_endpoint", "jwks_uri", "userinfo_endpoint"):
211239
url = self._config.get(name)
212240
if url and not isinstance(url, str):
213241
raise OidcError(f"OIDC {name} must be a URL string")
@@ -224,6 +252,13 @@ def _discover(self) -> None:
224252
safe = [a for a in supported if a in ("RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512")]
225253
self._allowed_algs = safe or ["RS256"]
226254

255+
# Token-endpoint auth methods. Per OIDC Discovery §3, an omitted
256+
# token_endpoint_auth_methods_supported means client_secret_basic.
257+
methods = self._config.get("token_endpoint_auth_methods_supported")
258+
if not isinstance(methods, list) or not methods:
259+
methods = ["client_secret_basic"]
260+
self._token_auth_methods = methods
261+
227262
logger.info(
228263
"OIDC provider discovered: issuer=%r auth=%r token=%r algs=%s",
229264
self.issuer,
@@ -264,9 +299,19 @@ def get_authorization_url(self, redirect_uri: str) -> Tuple[str, str, str]:
264299
"""
265300
nonce = secrets.token_hex(32)
266301

302+
# PKCE (RFC 7636, S256). The verifier travels inside the encrypted
303+
# state token, so the callback can recover it without server-side
304+
# storage — same carrier as the nonce.
305+
code_verifier = secrets.token_urlsafe(64)
306+
code_challenge = (
307+
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("ascii")).digest())
308+
.rstrip(b"=")
309+
.decode("ascii")
310+
)
311+
267312
# Encode the nonce + metadata into the state parameter (Fernet-
268313
# encrypted, stateless — works across multiple workers/processes).
269-
state = _encode_state(nonce, redirect_uri)
314+
state = _encode_state(nonce, redirect_uri, code_verifier)
270315

271316
from urllib.parse import urlencode
272317
params = {
@@ -276,6 +321,8 @@ def get_authorization_url(self, redirect_uri: str) -> Tuple[str, str, str]:
276321
"scope": self.scopes,
277322
"state": state,
278323
"nonce": nonce,
324+
"code_challenge": code_challenge,
325+
"code_challenge_method": "S256",
279326
}
280327
# Request forced re-authentication when OIDC_MAX_AGE is configured.
281328
# The claim is later verified in _verify_id_token against auth_time.
@@ -300,6 +347,7 @@ def exchange_code(
300347
raise OidcError("OIDC state not found — may be expired, reused, or from a different worker")
301348
nonce = stored.get("nonce", "")
302349
stored_redirect_uri = stored.get("redirect_uri", "")
350+
code_verifier = stored.get("code_verifier", "")
303351

304352
# Bind the token exchange to the redirect_uri that was used in the
305353
# authorization request (carried in the signed state token). Reject
@@ -312,7 +360,9 @@ def exchange_code(
312360
)
313361

314362
# 2. Exchange code for tokens (using the stored redirect_uri)
315-
token_data = self._token_request(code, stored_redirect_uri or redirect_uri)
363+
token_data = self._token_request(
364+
code, stored_redirect_uri or redirect_uri, code_verifier
365+
)
316366

317367
# 3. Verify id_token
318368
id_token = token_data.get("id_token")
@@ -349,9 +399,13 @@ def exchange_code(
349399
)
350400
userinfo = {}
351401
else:
352-
# Require a non-empty sub that matches the verified
353-
# id_token subject before trusting any UserInfo claims.
354-
ui_sub = (userinfo.get("sub") or "").strip()
402+
# Require a non-empty sub that exactly matches the
403+
# verified id_token subject before trusting any UserInfo
404+
# claims. No normalization: subs are opaque identifiers
405+
# and trimming could equate two distinct subjects.
406+
ui_sub = userinfo.get("sub")
407+
if not isinstance(ui_sub, str):
408+
ui_sub = ""
355409
if not ui_sub:
356410
logger.warning(
357411
"UserInfo response missing sub claim — "
@@ -388,18 +442,34 @@ def exchange_code(
388442
claims["_userinfo_available"] = userinfo_available
389443
return claims
390444

391-
def _token_request(self, code: str, redirect_uri: str) -> Dict[str, Any]:
445+
def _token_request(
446+
self, code: str, redirect_uri: str, code_verifier: str
447+
) -> Dict[str, Any]:
392448
"""POST the token endpoint to exchange code for tokens."""
393449
token_endpoint = self._config["token_endpoint"]
394450
payload = {
395451
"grant_type": "authorization_code",
396452
"code": code,
397453
"redirect_uri": redirect_uri,
398-
"client_id": self.client_id,
399-
"client_secret": self.client_secret,
454+
"code_verifier": code_verifier,
400455
}
456+
# client_secret_basic is the OIDC default and the method the
457+
# conformance suite expects; use it whenever the provider supports
458+
# it (or doesn't advertise methods at all, which per Discovery §3
459+
# means client_secret_basic). Fall back to client_secret_post only
460+
# when the provider explicitly excludes basic.
461+
auth = None
462+
if self._use_basic_auth():
463+
from urllib.parse import quote
464+
auth = (
465+
quote(self.client_id, safe=""),
466+
quote(self.client_secret, safe=""),
467+
)
468+
else:
469+
payload["client_id"] = self.client_id
470+
payload["client_secret"] = self.client_secret
401471
try:
402-
resp = httpx.post(token_endpoint, data=payload, timeout=15.0)
472+
resp = httpx.post(token_endpoint, data=payload, auth=auth, timeout=15.0)
403473
resp.raise_for_status()
404474
data = resp.json()
405475
except httpx.HTTPStatusError as exc:
@@ -583,15 +653,17 @@ def _verify_id_token(self, id_token: str, nonce: str) -> Dict[str, Any]:
583653
f"{self.max_age} s (now={now:.0f}, age={now - auth_time:.0f} s)"
584654
)
585655

586-
# Verify iat (issued-at) is not in the far future.
656+
# Verify iat (issued-at): required by OIDC Core §2, must be numeric
657+
# and not in the far future.
587658
iat = claims.get("iat")
588-
if iat is not None:
589-
if not _is_numericdate(iat):
590-
raise OidcError(f"id_token iat claim is non-numeric: {iat!r}")
591-
if iat > time.time() + 60:
592-
raise OidcError(
593-
f"id_token iat {iat} is more than 60 s in the future"
594-
)
659+
if iat is None:
660+
raise OidcError("id_token missing iat claim")
661+
if not _is_numericdate(iat):
662+
raise OidcError(f"id_token iat claim is non-numeric: {iat!r}")
663+
if iat > time.time() + 60:
664+
raise OidcError(
665+
f"id_token iat {iat} is more than 60 s in the future"
666+
)
595667

596668
return claims
597669

0 commit comments

Comments
 (0)