feat(auth): add native OIDC login for WebUI sessions (#3825)
This commit is contained in:
1 parent
ae8f42ec13
commit
e0e10cb106
4 files changed
+869
-6
No files matched your search
+19
-3
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Hermes Web UI -- optional authentication.
|
||||
Off by default. Enable by setting HERMES_WEBUI_PASSWORD, configuring a
|
||||
password in Settings, or registering passkeys and then going passwordless.
|
||||
password in Settings, registering passkeys, or configuring native OIDC SSO.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
@@ -51,6 +51,7 @@ def _resolve_session_ttl() -> int:
|
||||
PUBLIC_PATHS = frozenset({
|
||||
'/login', '/health', '/favicon.ico', '/sw.js',
|
||||
'/api/auth/login', '/api/auth/status',
|
||||
'/api/auth/oidc/start', '/api/auth/oidc/callback',
|
||||
'/api/auth/passkey/options', '/api/auth/passkey/login',
|
||||
'/manifest.json', '/manifest.webmanifest',
|
||||
'/session/manifest.json', '/session/manifest.webmanifest',
|
||||
@@ -443,9 +444,24 @@ def are_passkeys_enabled() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def is_oidc_auth_enabled() -> bool:
|
||||
"""True if native OIDC login is configured for WebUI sessions."""
|
||||
try:
|
||||
from api.auth_oidc import is_oidc_enabled
|
||||
|
||||
return is_oidc_enabled()
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to inspect OIDC availability: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def is_auth_enabled() -> bool:
|
||||
"""True if password auth or passkey-only auth is configured."""
|
||||
return is_password_auth_enabled() or are_passkeys_enabled()
|
||||
"""True if password auth, passkeys, or OIDC login is configured."""
|
||||
return (
|
||||
is_password_auth_enabled()
|
||||
or are_passkeys_enabled()
|
||||
or is_oidc_auth_enabled()
|
||||
)
|
||||
|
||||
|
||||
def verify_password(plain: str) -> bool:
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
|
||||
|
||||
from api.config import get_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_SCOPES = ("openid", "profile", "email")
|
||||
_PENDING_TTL_SECONDS = 600
|
||||
_CLOCK_SKEW_SECONDS = 60
|
||||
_CACHE_TTL_SECONDS = 300
|
||||
|
||||
_pending_lock = threading.Lock()
|
||||
_pending_flows: dict[str, dict[str, Any]] = {}
|
||||
|
||||
_discovery_lock = threading.Lock()
|
||||
_discovery_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
|
||||
_jwks_lock = threading.Lock()
|
||||
_jwks_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
|
||||
|
||||
class OIDCConfigError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class OIDCAuthError(Exception):
|
||||
def __init__(self, message: str, *, status_code: int = 401):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def is_oidc_enabled() -> bool:
|
||||
cfg = _resolve_oidc_config()
|
||||
return bool(cfg.get("issuer") and cfg.get("client_id"))
|
||||
|
||||
|
||||
def build_authorization_redirect(
|
||||
request_base_url: str,
|
||||
next_path: str | None = None,
|
||||
) -> str:
|
||||
cfg = _require_oidc_config()
|
||||
discovery = _get_discovery_document(cfg["issuer"])
|
||||
authorization_endpoint = str(discovery.get("authorization_endpoint") or "").strip()
|
||||
if not authorization_endpoint:
|
||||
raise OIDCConfigError("OIDC discovery document is missing authorization_endpoint")
|
||||
redirect_uri = _resolve_redirect_uri(cfg, request_base_url)
|
||||
state = secrets.token_urlsafe(24)
|
||||
nonce = secrets.token_urlsafe(24)
|
||||
verifier = secrets.token_urlsafe(48)
|
||||
challenge = _b64u(hashlib.sha256(verifier.encode("ascii")).digest())
|
||||
_store_pending_flow(
|
||||
state,
|
||||
{
|
||||
"created_at": time.time(),
|
||||
"nonce": nonce,
|
||||
"code_verifier": verifier,
|
||||
"next_path": _safe_next_path(next_path),
|
||||
},
|
||||
)
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": cfg["client_id"],
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": " ".join(cfg["scopes"]),
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
return authorization_endpoint + "?" + urllib.parse.urlencode(params)
|
||||
|
||||
|
||||
def complete_authorization_code_flow(
|
||||
request_base_url: str,
|
||||
state: str,
|
||||
code: str,
|
||||
) -> dict[str, Any]:
|
||||
cfg = _require_oidc_config()
|
||||
pending = _consume_pending_flow(state)
|
||||
if pending is None:
|
||||
raise OIDCAuthError("Invalid OIDC state", status_code=401)
|
||||
discovery = _get_discovery_document(cfg["issuer"])
|
||||
token_endpoint = str(discovery.get("token_endpoint") or "").strip()
|
||||
if not token_endpoint:
|
||||
raise OIDCConfigError("OIDC discovery document is missing token_endpoint")
|
||||
redirect_uri = _resolve_redirect_uri(cfg, request_base_url)
|
||||
token_response = _post_form_json(
|
||||
token_endpoint,
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": cfg["client_id"],
|
||||
"code": code,
|
||||
"code_verifier": pending["code_verifier"],
|
||||
"redirect_uri": redirect_uri,
|
||||
**({"client_secret": cfg["client_secret"]} if cfg.get("client_secret") else {}),
|
||||
},
|
||||
)
|
||||
id_token = str(token_response.get("id_token") or "").strip()
|
||||
if not id_token:
|
||||
raise OIDCAuthError("OIDC token response did not include an id_token", status_code=502)
|
||||
claims = _validate_id_token(
|
||||
id_token,
|
||||
client_id=cfg["client_id"],
|
||||
issuer=str(discovery.get("issuer") or cfg["issuer"]).strip(),
|
||||
nonce=pending["nonce"],
|
||||
jwks_uri=str(discovery.get("jwks_uri") or "").strip(),
|
||||
)
|
||||
_enforce_allowlist(
|
||||
claims,
|
||||
allow_claim=cfg.get("allow_claim"),
|
||||
allow_values=cfg.get("allow_values") or [],
|
||||
)
|
||||
return {
|
||||
"next_path": pending["next_path"],
|
||||
"subject": str(claims.get("sub") or ""),
|
||||
"email": str(claims.get("email") or ""),
|
||||
"claims": claims,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_oidc_config() -> dict[str, Any]:
|
||||
raw = {}
|
||||
try:
|
||||
cfg = get_config()
|
||||
value = cfg.get("webui_oidc") if isinstance(cfg, dict) else None
|
||||
if isinstance(value, dict):
|
||||
raw.update(value)
|
||||
except Exception:
|
||||
logger.debug("Failed to read webui_oidc config", exc_info=True)
|
||||
|
||||
def pick(name: str, env_name: str) -> Any:
|
||||
env_value = os.getenv(env_name)
|
||||
return env_value if env_value is not None else raw.get(name)
|
||||
|
||||
scopes = _normalize_scopes(pick("scopes", "HERMES_WEBUI_OIDC_SCOPES"))
|
||||
allow_values = _normalize_allow_values(
|
||||
pick("allow_values", "HERMES_WEBUI_OIDC_ALLOW_VALUES")
|
||||
)
|
||||
return {
|
||||
"issuer": str(pick("issuer", "HERMES_WEBUI_OIDC_ISSUER") or "").strip(),
|
||||
"client_id": str(pick("client_id", "HERMES_WEBUI_OIDC_CLIENT_ID") or "").strip(),
|
||||
"client_secret": str(pick("client_secret", "HERMES_WEBUI_OIDC_CLIENT_SECRET") or "").strip(),
|
||||
"redirect_uri": str(pick("redirect_uri", "HERMES_WEBUI_OIDC_REDIRECT_URI") or "").strip(),
|
||||
"scopes": scopes,
|
||||
"allow_claim": str(pick("allow_claim", "HERMES_WEBUI_OIDC_ALLOW_CLAIM") or "").strip(),
|
||||
"allow_values": allow_values,
|
||||
}
|
||||
|
||||
|
||||
def _require_oidc_config() -> dict[str, Any]:
|
||||
cfg = _resolve_oidc_config()
|
||||
if not cfg.get("issuer") or not cfg.get("client_id"):
|
||||
raise OIDCConfigError("Native OIDC login is not configured")
|
||||
return cfg
|
||||
|
||||
|
||||
def _normalize_scopes(raw: Any) -> list[str]:
|
||||
items = _normalize_text_list(raw)
|
||||
if not items:
|
||||
return list(_DEFAULT_SCOPES)
|
||||
if "openid" not in items:
|
||||
items.insert(0, "openid")
|
||||
deduped = []
|
||||
seen = set()
|
||||
for item in items:
|
||||
if item not in seen:
|
||||
seen.add(item)
|
||||
deduped.append(item)
|
||||
return deduped
|
||||
|
||||
|
||||
def _normalize_allow_values(raw: Any) -> list[str]:
|
||||
return _normalize_text_list(raw)
|
||||
|
||||
|
||||
def _normalize_text_list(raw: Any) -> list[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, (list, tuple, set)):
|
||||
values = [str(item).strip() for item in raw]
|
||||
else:
|
||||
text = str(raw).replace("\n", ",")
|
||||
values = []
|
||||
for comma_part in text.split(","):
|
||||
values.extend(piece.strip() for piece in comma_part.split() if piece.strip())
|
||||
return [value for value in values if value]
|
||||
|
||||
|
||||
def _safe_next_path(raw_path: str | None) -> str:
|
||||
path = str(raw_path or "").strip()
|
||||
if not path:
|
||||
return "/"
|
||||
if path[0] != "/":
|
||||
return "/"
|
||||
if path[1:2] in {"/", "\\"}:
|
||||
return "/"
|
||||
if any(ord(ch) < 32 or ord(ch) == 127 or ch.isspace() for ch in path):
|
||||
return "/"
|
||||
return path
|
||||
|
||||
|
||||
def _resolve_redirect_uri(cfg: dict[str, Any], request_base_url: str) -> str:
|
||||
explicit = str(cfg.get("redirect_uri") or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
return request_base_url.rstrip("/") + "/api/auth/oidc/callback"
|
||||
|
||||
|
||||
def _store_pending_flow(state: str, payload: dict[str, Any]) -> None:
|
||||
now = time.time()
|
||||
with _pending_lock:
|
||||
_prune_pending_flows(now)
|
||||
_pending_flows[state] = payload
|
||||
|
||||
|
||||
def _consume_pending_flow(state: str) -> dict[str, Any] | None:
|
||||
now = time.time()
|
||||
with _pending_lock:
|
||||
_prune_pending_flows(now)
|
||||
payload = _pending_flows.pop(state, None)
|
||||
return payload
|
||||
|
||||
|
||||
def _prune_pending_flows(now: float) -> None:
|
||||
expired = [
|
||||
state
|
||||
for state, payload in _pending_flows.items()
|
||||
if now - float(payload.get("created_at") or 0) > _PENDING_TTL_SECONDS
|
||||
]
|
||||
for state in expired:
|
||||
_pending_flows.pop(state, None)
|
||||
|
||||
|
||||
def _get_discovery_document(issuer: str) -> dict[str, Any]:
|
||||
discovery_url = _discovery_url_for_issuer(issuer)
|
||||
cached = _cache_get(_discovery_lock, _discovery_cache, discovery_url)
|
||||
if cached is not None:
|
||||
return cached
|
||||
data = _fetch_json(discovery_url)
|
||||
if not isinstance(data, dict):
|
||||
raise OIDCAuthError("OIDC discovery response was not a JSON object", status_code=502)
|
||||
_cache_put(_discovery_lock, _discovery_cache, discovery_url, data)
|
||||
return data
|
||||
|
||||
|
||||
def _discovery_url_for_issuer(issuer: str) -> str:
|
||||
if issuer.endswith("/.well-known/openid-configuration"):
|
||||
return issuer
|
||||
return issuer.rstrip("/") + "/.well-known/openid-configuration"
|
||||
|
||||
|
||||
def _get_jwks_document(jwks_uri: str) -> dict[str, Any]:
|
||||
if not jwks_uri:
|
||||
raise OIDCConfigError("OIDC discovery document is missing jwks_uri")
|
||||
cached = _cache_get(_jwks_lock, _jwks_cache, jwks_uri)
|
||||
if cached is not None:
|
||||
return cached
|
||||
data = _fetch_json(jwks_uri)
|
||||
if not isinstance(data, dict):
|
||||
raise OIDCAuthError("OIDC JWKS response was not a JSON object", status_code=502)
|
||||
_cache_put(_jwks_lock, _jwks_cache, jwks_uri, data)
|
||||
return data
|
||||
|
||||
|
||||
def _cache_get(
|
||||
lock: threading.Lock,
|
||||
cache: dict[str, tuple[float, dict[str, Any]]],
|
||||
key: str,
|
||||
) -> dict[str, Any] | None:
|
||||
now = time.time()
|
||||
with lock:
|
||||
entry = cache.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
expires_at, value = entry
|
||||
if expires_at <= now:
|
||||
cache.pop(key, None)
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _cache_put(
|
||||
lock: threading.Lock,
|
||||
cache: dict[str, tuple[float, dict[str, Any]]],
|
||||
key: str,
|
||||
value: dict[str, Any],
|
||||
) -> None:
|
||||
with lock:
|
||||
cache[key] = (time.time() + _CACHE_TTL_SECONDS, value)
|
||||
|
||||
|
||||
def _fetch_json(url: str) -> dict[str, Any]:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.URLError as exc:
|
||||
raise OIDCAuthError(f"Failed to reach OIDC endpoint: {url}", status_code=502) from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise OIDCAuthError(f"OIDC endpoint returned invalid JSON: {url}", status_code=502) from exc
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _post_form_json(url: str, form_data: dict[str, Any]) -> dict[str, Any]:
|
||||
body = urllib.parse.urlencode(form_data).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.URLError as exc:
|
||||
raise OIDCAuthError("Failed to exchange the OIDC authorization code", status_code=502) from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise OIDCAuthError("OIDC token endpoint returned invalid JSON", status_code=502) from exc
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _validate_id_token(
|
||||
token: str,
|
||||
*,
|
||||
client_id: str,
|
||||
issuer: str,
|
||||
nonce: str,
|
||||
jwks_uri: str,
|
||||
) -> dict[str, Any]:
|
||||
header, claims, signed, signature = _parse_jwt(token)
|
||||
alg = str(header.get("alg") or "").strip()
|
||||
if not alg or alg == "none":
|
||||
raise OIDCAuthError("OIDC id_token uses an unsupported signing algorithm")
|
||||
jwks = _get_jwks_document(jwks_uri)
|
||||
public_key = _select_public_key(jwks, header)
|
||||
_verify_jwt_signature(public_key, alg, signed, signature)
|
||||
_validate_registered_claims(claims, client_id=client_id, issuer=issuer, nonce=nonce)
|
||||
return claims
|
||||
|
||||
|
||||
def _parse_jwt(token: str) -> tuple[dict[str, Any], dict[str, Any], bytes, bytes]:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise OIDCAuthError("OIDC id_token was not a JWT")
|
||||
header_b64, payload_b64, signature_b64 = parts
|
||||
try:
|
||||
header = json.loads(_b64u_decode(header_b64))
|
||||
claims = json.loads(_b64u_decode(payload_b64))
|
||||
signature = _b64u_decode_bytes(signature_b64)
|
||||
except Exception as exc:
|
||||
raise OIDCAuthError("OIDC id_token could not be decoded") from exc
|
||||
if not isinstance(header, dict) or not isinstance(claims, dict):
|
||||
raise OIDCAuthError("OIDC id_token payload was malformed")
|
||||
signed = f"{header_b64}.{payload_b64}".encode("ascii")
|
||||
return header, claims, signed, signature
|
||||
|
||||
|
||||
def _select_public_key(jwks: dict[str, Any], header: dict[str, Any]):
|
||||
keys = jwks.get("keys")
|
||||
if not isinstance(keys, list) or not keys:
|
||||
raise OIDCAuthError("OIDC JWKS did not contain any signing keys", status_code=502)
|
||||
kid = str(header.get("kid") or "").strip()
|
||||
alg = str(header.get("alg") or "").strip()
|
||||
matches = []
|
||||
for key in keys:
|
||||
if not isinstance(key, dict):
|
||||
continue
|
||||
if key.get("use") not in (None, "sig"):
|
||||
continue
|
||||
if kid and str(key.get("kid") or "").strip() != kid:
|
||||
continue
|
||||
if key.get("alg") not in (None, alg):
|
||||
continue
|
||||
matches.append(key)
|
||||
if not matches:
|
||||
raise OIDCAuthError("OIDC JWKS did not contain the signing key for this id_token", status_code=502)
|
||||
return _jwk_to_public_key(matches[0])
|
||||
|
||||
|
||||
def _jwk_to_public_key(jwk: dict[str, Any]):
|
||||
kty = str(jwk.get("kty") or "").strip()
|
||||
if kty == "RSA":
|
||||
n = _int_from_b64u(jwk.get("n"))
|
||||
e = _int_from_b64u(jwk.get("e"))
|
||||
return rsa.RSAPublicNumbers(e, n).public_key()
|
||||
if kty == "EC":
|
||||
crv = str(jwk.get("crv") or "").strip()
|
||||
curve = {
|
||||
"P-256": ec.SECP256R1(),
|
||||
"P-384": ec.SECP384R1(),
|
||||
"P-521": ec.SECP521R1(),
|
||||
}.get(crv)
|
||||
if curve is None:
|
||||
raise OIDCAuthError(f"Unsupported OIDC EC curve: {crv}", status_code=502)
|
||||
x = _int_from_b64u(jwk.get("x"))
|
||||
y = _int_from_b64u(jwk.get("y"))
|
||||
return ec.EllipticCurvePublicNumbers(x, y, curve).public_key()
|
||||
raise OIDCAuthError(f"Unsupported OIDC key type: {kty}", status_code=502)
|
||||
|
||||
|
||||
def _verify_jwt_signature(public_key, alg: str, signed: bytes, signature: bytes) -> None:
|
||||
try:
|
||||
if alg == "RS256":
|
||||
public_key.verify(signature, signed, padding.PKCS1v15(), hashes.SHA256())
|
||||
return
|
||||
if alg == "RS384":
|
||||
public_key.verify(signature, signed, padding.PKCS1v15(), hashes.SHA384())
|
||||
return
|
||||
if alg == "RS512":
|
||||
public_key.verify(signature, signed, padding.PKCS1v15(), hashes.SHA512())
|
||||
return
|
||||
if alg == "ES256":
|
||||
public_key.verify(signature, signed, ec.ECDSA(hashes.SHA256()))
|
||||
return
|
||||
if alg == "ES384":
|
||||
public_key.verify(signature, signed, ec.ECDSA(hashes.SHA384()))
|
||||
return
|
||||
if alg == "ES512":
|
||||
public_key.verify(signature, signed, ec.ECDSA(hashes.SHA512()))
|
||||
return
|
||||
except InvalidSignature as exc:
|
||||
raise OIDCAuthError("OIDC id_token signature verification failed") from exc
|
||||
raise OIDCAuthError(f"Unsupported OIDC signing algorithm: {alg}", status_code=502)
|
||||
|
||||
|
||||
def _validate_registered_claims(
|
||||
claims: dict[str, Any],
|
||||
*,
|
||||
client_id: str,
|
||||
issuer: str,
|
||||
nonce: str,
|
||||
) -> None:
|
||||
now = time.time()
|
||||
if str(claims.get("iss") or "").strip() != issuer:
|
||||
raise OIDCAuthError("OIDC id_token issuer did not match the configured issuer")
|
||||
aud = claims.get("aud")
|
||||
if isinstance(aud, list):
|
||||
audiences = [str(item) for item in aud]
|
||||
elif aud is None:
|
||||
audiences = []
|
||||
else:
|
||||
audiences = [str(aud)]
|
||||
if client_id not in audiences:
|
||||
raise OIDCAuthError("OIDC id_token audience did not include this client")
|
||||
if len(audiences) > 1 and str(claims.get("azp") or "").strip() not in {"", client_id}:
|
||||
raise OIDCAuthError("OIDC id_token azp did not match this client")
|
||||
exp = _coerce_numeric_claim(claims, "exp")
|
||||
if exp is None or exp < now - _CLOCK_SKEW_SECONDS:
|
||||
raise OIDCAuthError("OIDC id_token has expired")
|
||||
nbf = _coerce_numeric_claim(claims, "nbf")
|
||||
if nbf is not None and nbf > now + _CLOCK_SKEW_SECONDS:
|
||||
raise OIDCAuthError("OIDC id_token is not valid yet")
|
||||
iat = _coerce_numeric_claim(claims, "iat")
|
||||
if iat is not None and iat > now + _CLOCK_SKEW_SECONDS:
|
||||
raise OIDCAuthError("OIDC id_token has an invalid issued-at time")
|
||||
if str(claims.get("nonce") or "").strip() != nonce:
|
||||
raise OIDCAuthError("OIDC id_token nonce did not match the login request")
|
||||
|
||||
|
||||
def _coerce_numeric_claim(claims: dict[str, Any], name: str) -> float | None:
|
||||
value = claims.get(name)
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise OIDCAuthError(f"OIDC id_token claim {name} was not numeric")
|
||||
|
||||
|
||||
def _enforce_allowlist(
|
||||
claims: dict[str, Any],
|
||||
*,
|
||||
allow_claim: str,
|
||||
allow_values: list[str],
|
||||
) -> None:
|
||||
if not allow_claim:
|
||||
return
|
||||
claim_value = _get_claim_path(claims, allow_claim)
|
||||
if claim_value is None:
|
||||
raise OIDCAuthError("OIDC identity is not allowed", status_code=403)
|
||||
actual_values = _claim_values(claim_value)
|
||||
if allow_values:
|
||||
if not any(value in actual_values for value in allow_values):
|
||||
raise OIDCAuthError("OIDC identity is not allowed", status_code=403)
|
||||
return
|
||||
if not actual_values:
|
||||
raise OIDCAuthError("OIDC identity is not allowed", status_code=403)
|
||||
|
||||
|
||||
def _get_claim_path(claims: dict[str, Any], dotted_key: str) -> Any:
|
||||
current: Any = claims
|
||||
for part in dotted_key.split("."):
|
||||
if not isinstance(current, dict) or part not in current:
|
||||
return None
|
||||
current = current[part]
|
||||
return current
|
||||
|
||||
|
||||
def _claim_values(value: Any) -> set[str]:
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return {str(item) for item in value if str(item).strip()}
|
||||
if isinstance(value, dict):
|
||||
return {str(item) for item in value.values() if str(item).strip()}
|
||||
text = str(value or "").strip()
|
||||
return {text} if text else set()
|
||||
|
||||
|
||||
def _b64u(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64u_decode(data: str) -> str:
|
||||
return _b64u_decode_bytes(data).decode("utf-8")
|
||||
|
||||
|
||||
def _b64u_decode_bytes(data: str) -> bytes:
|
||||
padded = data + "=" * ((4 - len(data) % 4) % 4)
|
||||
return base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||
|
||||
|
||||
def _int_from_b64u(data: Any) -> int:
|
||||
if not data:
|
||||
raise OIDCAuthError("OIDC JWKS key was missing a required parameter", status_code=502)
|
||||
return int.from_bytes(_b64u_decode_bytes(str(data)), "big")
|
||||
+106
-3
@@ -28,7 +28,7 @@ import uuid
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from contextlib import closing
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
from urllib.parse import parse_qs, quote, urlsplit
|
||||
from api.agent_sessions import (
|
||||
MESSAGING_SOURCES,
|
||||
_looks_like_default_cli_title,
|
||||
@@ -8054,6 +8054,10 @@ button{width:100%;padding:10px;border-radius:10px;border:none;background:rgba(12
|
||||
border:1px solid rgba(124,185,255,.3);color:#7cb9ff;font-size:14px;font-weight:600;cursor:pointer;
|
||||
transition:all .15s}
|
||||
button:hover{background:rgba(124,185,255,.25)}
|
||||
.oidc-login{display:block;margin-top:10px;padding:10px;border-radius:10px;text-decoration:none;
|
||||
background:rgba(255,255,255,.04);border:1px solid rgba(111,214,164,.35);color:#6fd6a4;
|
||||
font-size:14px;font-weight:600;cursor:pointer;transition:all .15s}
|
||||
.oidc-login:hover{background:rgba(111,214,164,.12)}
|
||||
.passkey-login{margin-top:10px;background:rgba(255,255,255,.04);border-color:rgba(232,160,48,.35);color:#e8a030}
|
||||
.err{color:#e94560;font-size:12px;margin-top:10px;display:none}
|
||||
</style></head><body>
|
||||
@@ -8065,6 +8069,7 @@ button:hover{background:rgba(124,185,255,.25)}
|
||||
<input type="password" id="pw" placeholder="{{LOGIN_PLACEHOLDER}}" autofocus>
|
||||
<button type="submit">{{LOGIN_BTN}}</button>
|
||||
<button type="button" id="passkey-login" class="passkey-login" style="display:none">Sign in with passkey</button>
|
||||
{{OIDC_LOGIN_HTML}}
|
||||
</form>
|
||||
<div class="err" id="err"></div>
|
||||
</div>
|
||||
@@ -8073,6 +8078,46 @@ button:hover{background:rgba(124,185,255,.25)}
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def _safe_login_redirect_path(raw_path: str | None) -> str:
|
||||
path = str(raw_path or "").strip()
|
||||
if not path:
|
||||
return "/"
|
||||
if path[0] != "/":
|
||||
return "/"
|
||||
if path[1:2] in {"/", "\\"}:
|
||||
return "/"
|
||||
if re.search(r"[\x00-\x1f\x7f\s]", path):
|
||||
return "/"
|
||||
return path
|
||||
|
||||
|
||||
def _request_base_url(handler) -> str:
|
||||
from api.auth import _is_secure_context
|
||||
|
||||
scheme = "https" if _is_secure_context(handler) else "http"
|
||||
host = str(handler.headers.get("Host") or "").strip() or "127.0.0.1:8787"
|
||||
return f"{scheme}://{host}"
|
||||
|
||||
|
||||
def _oidc_login_html(parsed) -> str:
|
||||
try:
|
||||
from api.auth_oidc import is_oidc_enabled
|
||||
except Exception:
|
||||
return ""
|
||||
if not is_oidc_enabled():
|
||||
return ""
|
||||
next_path = _safe_login_redirect_path(
|
||||
parse_qs(parsed.query or "").get("next", [""])[0]
|
||||
)
|
||||
href = "api/auth/oidc/start"
|
||||
if next_path != "/":
|
||||
href += "?next=" + quote(next_path, safe="/")
|
||||
return (
|
||||
'<a id="oidc-login" class="oidc-login" '
|
||||
f'href="{_html.escape(href, quote=True)}">Continue with SSO</a>'
|
||||
)
|
||||
|
||||
|
||||
# ── Logs endpoint ─────────────────────────────────────────────────────────────
|
||||
_LOG_FILE_WHITELIST = {
|
||||
"agent": "agent.log",
|
||||
@@ -9926,7 +9971,6 @@ def handle_get(handler, parsed) -> bool:
|
||||
_login_strings = _LOGIN_LOCALE[
|
||||
_resolve_login_locale_key(_lang)
|
||||
]
|
||||
from urllib.parse import quote
|
||||
from api.updates import WEBUI_VERSION
|
||||
version_token = quote(WEBUI_VERSION, safe="")
|
||||
_page = (
|
||||
@@ -9944,15 +9988,73 @@ def handle_get(handler, parsed) -> bool:
|
||||
.replace(
|
||||
"{{LOGIN_CONN_FAILED}}", _html.escape(_login_strings["conn_failed"])
|
||||
)
|
||||
.replace("{{OIDC_LOGIN_HTML}}", _oidc_login_html(parsed))
|
||||
)
|
||||
return t(handler, _page, content_type="text/html; charset=utf-8")
|
||||
|
||||
if parsed.path == "/api/auth/oidc/start":
|
||||
from api.auth_oidc import OIDCAuthError, OIDCConfigError, build_authorization_redirect
|
||||
|
||||
next_path = _safe_login_redirect_path(
|
||||
parse_qs(parsed.query or "").get("next", [""])[0]
|
||||
)
|
||||
try:
|
||||
location = build_authorization_redirect(
|
||||
_request_base_url(handler), next_path
|
||||
)
|
||||
except OIDCConfigError as exc:
|
||||
return j(handler, {"error": str(exc)}, status=404)
|
||||
except OIDCAuthError as exc:
|
||||
return j(handler, {"error": str(exc)}, status=exc.status_code)
|
||||
handler.send_response(302)
|
||||
handler.send_header("Location", location)
|
||||
handler.send_header("Cache-Control", "no-store")
|
||||
handler.send_header("Content-Length", "0")
|
||||
_security_headers(handler)
|
||||
handler.end_headers()
|
||||
return True
|
||||
|
||||
if parsed.path == "/api/auth/oidc/callback":
|
||||
from api.auth import create_session, set_auth_cookie
|
||||
from api.auth_oidc import OIDCAuthError, OIDCConfigError, complete_authorization_code_flow
|
||||
|
||||
query = parse_qs(parsed.query or "")
|
||||
error = str(query.get("error", [""])[0] or "").strip()
|
||||
if error:
|
||||
description = str(query.get("error_description", [""])[0] or "").strip()
|
||||
return j(handler, {"error": description or error}, status=401)
|
||||
state = str(query.get("state", [""])[0] or "").strip()
|
||||
code = str(query.get("code", [""])[0] or "").strip()
|
||||
if not state or not code:
|
||||
return j(handler, {"error": "Missing OIDC callback state or code"}, status=400)
|
||||
try:
|
||||
result = complete_authorization_code_flow(
|
||||
_request_base_url(handler), state, code
|
||||
)
|
||||
except OIDCConfigError as exc:
|
||||
return j(handler, {"error": str(exc)}, status=404)
|
||||
except OIDCAuthError as exc:
|
||||
return j(handler, {"error": str(exc)}, status=exc.status_code)
|
||||
cookie_val = create_session()
|
||||
handler.send_response(302)
|
||||
handler.send_header(
|
||||
"Location",
|
||||
_safe_login_redirect_path(result.get("next_path")),
|
||||
)
|
||||
handler.send_header("Cache-Control", "no-store")
|
||||
_security_headers(handler)
|
||||
set_auth_cookie(handler, cookie_val)
|
||||
handler.send_header("Content-Length", "0")
|
||||
handler.end_headers()
|
||||
return True
|
||||
|
||||
if parsed.path == "/api/auth/status":
|
||||
from api.auth import _passkey_feature_flag_enabled, get_password_hash, is_auth_enabled, parse_cookie, verify_session
|
||||
from api.auth import _passkey_feature_flag_enabled, get_password_hash, is_auth_enabled, is_oidc_auth_enabled, parse_cookie, verify_session
|
||||
from api.passkeys import registered_credentials
|
||||
|
||||
logged_in = False
|
||||
auth_enabled = is_auth_enabled()
|
||||
oidc_enabled = is_oidc_auth_enabled()
|
||||
if auth_enabled:
|
||||
cv = parse_cookie(handler)
|
||||
logged_in = bool(cv and verify_session(cv))
|
||||
@@ -9962,6 +10064,7 @@ def handle_get(handler, parsed) -> bool:
|
||||
return j(handler, {
|
||||
"auth_enabled": auth_enabled,
|
||||
"logged_in": logged_in,
|
||||
"oidc_enabled": oidc_enabled,
|
||||
"password_auth_enabled": password_auth_enabled,
|
||||
"passwordless_enabled": bool(passkeys) and not password_auth_enabled,
|
||||
"passkeys_enabled": bool(passkeys),
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import io
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
|
||||
class FakeHeaders(dict):
|
||||
def get(self, key, default=None):
|
||||
return super().get(key, default)
|
||||
|
||||
|
||||
class RouteFakeHandler:
|
||||
def __init__(self):
|
||||
self.headers = FakeHeaders({"Host": "localhost:8787"})
|
||||
self.request = SimpleNamespace()
|
||||
self.wfile = io.BytesIO()
|
||||
self.status = None
|
||||
self.sent_headers = []
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, key, value):
|
||||
self.sent_headers.append((key, value))
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
def json_body(self):
|
||||
return json.loads(self.wfile.getvalue().decode("utf-8"))
|
||||
|
||||
def header_values(self, name):
|
||||
needle = name.lower()
|
||||
return [value for key, value in self.sent_headers if key.lower() == needle]
|
||||
|
||||
|
||||
def test_oidc_start_redirects_with_pkce_state_and_nonce(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_build_authorization_redirect(request_base_url, next_path):
|
||||
captured["request_base_url"] = request_base_url
|
||||
captured["next_path"] = next_path
|
||||
return (
|
||||
"https://idp.example/authorize"
|
||||
"?response_type=code"
|
||||
"&client_id=webui-client"
|
||||
"&redirect_uri=http%3A%2F%2Flocalhost%3A8787%2Fapi%2Fauth%2Foidc%2Fcallback"
|
||||
"&scope=openid+profile+email"
|
||||
"&state=state-token"
|
||||
"&nonce=nonce-token"
|
||||
"&code_challenge=challenge-token"
|
||||
"&code_challenge_method=S256"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"api.auth_oidc.build_authorization_redirect",
|
||||
fake_build_authorization_redirect,
|
||||
)
|
||||
|
||||
handler = RouteFakeHandler()
|
||||
routes.handle_get(
|
||||
handler,
|
||||
SimpleNamespace(path="/api/auth/oidc/start", query="next=%2Fprojects%3Fview%3Dgrid"),
|
||||
)
|
||||
|
||||
assert handler.status == 302
|
||||
assert captured == {
|
||||
"request_base_url": "http://localhost:8787",
|
||||
"next_path": "/projects?view=grid",
|
||||
}
|
||||
[location] = handler.header_values("Location")
|
||||
params = parse_qs(urlparse(location).query)
|
||||
assert params["response_type"] == ["code"]
|
||||
assert params["state"] == ["state-token"]
|
||||
assert params["nonce"] == ["nonce-token"]
|
||||
assert params["code_challenge"] == ["challenge-token"]
|
||||
assert params["code_challenge_method"] == ["S256"]
|
||||
|
||||
|
||||
def test_oidc_callback_exchanges_code_and_sets_existing_session_cookie(monkeypatch):
|
||||
import api.auth as auth
|
||||
import api.routes as routes
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_complete_authorization_code_flow(request_base_url, state, code):
|
||||
captured["request_base_url"] = request_base_url
|
||||
captured["state"] = state
|
||||
captured["code"] = code
|
||||
return {"next_path": "/chat/123"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"api.auth_oidc.complete_authorization_code_flow",
|
||||
fake_complete_authorization_code_flow,
|
||||
)
|
||||
monkeypatch.setattr(auth, "create_session", lambda: "session-token.signature")
|
||||
|
||||
handler = RouteFakeHandler()
|
||||
routes.handle_get(
|
||||
handler,
|
||||
SimpleNamespace(
|
||||
path="/api/auth/oidc/callback",
|
||||
query="state=state-token&code=code-token",
|
||||
),
|
||||
)
|
||||
|
||||
assert handler.status == 302
|
||||
assert captured == {
|
||||
"request_base_url": "http://localhost:8787",
|
||||
"state": "state-token",
|
||||
"code": "code-token",
|
||||
}
|
||||
assert handler.header_values("Location") == ["/chat/123"]
|
||||
cookie_headers = handler.header_values("Set-Cookie")
|
||||
assert len(cookie_headers) == 1
|
||||
assert auth.COOKIE_NAME in cookie_headers[0]
|
||||
assert "session-token.signature" in cookie_headers[0]
|
||||
|
||||
|
||||
def test_oidc_callback_rejects_invalid_state_without_setting_session_cookie(monkeypatch):
|
||||
import api.routes as routes
|
||||
from api.auth_oidc import OIDCAuthError
|
||||
|
||||
monkeypatch.setattr(
|
||||
"api.auth_oidc.complete_authorization_code_flow",
|
||||
lambda *_args: (_ for _ in ()).throw(OIDCAuthError("Invalid OIDC state", status_code=401)),
|
||||
)
|
||||
|
||||
handler = RouteFakeHandler()
|
||||
routes.handle_get(
|
||||
handler,
|
||||
SimpleNamespace(
|
||||
path="/api/auth/oidc/callback",
|
||||
query="state=missing-state&code=code-token",
|
||||
),
|
||||
)
|
||||
|
||||
assert handler.status == 401
|
||||
assert handler.json_body()["error"] == "Invalid OIDC state"
|
||||
assert handler.header_values("Set-Cookie") == []
|
||||
|
||||
|
||||
def test_oidc_callback_rejects_allowlist_failure_without_setting_session_cookie(monkeypatch):
|
||||
import api.routes as routes
|
||||
from api.auth_oidc import OIDCAuthError
|
||||
|
||||
monkeypatch.setattr(
|
||||
"api.auth_oidc.complete_authorization_code_flow",
|
||||
lambda *_args: (_ for _ in ()).throw(OIDCAuthError("OIDC identity is not allowed", status_code=403)),
|
||||
)
|
||||
|
||||
handler = RouteFakeHandler()
|
||||
routes.handle_get(
|
||||
handler,
|
||||
SimpleNamespace(
|
||||
path="/api/auth/oidc/callback",
|
||||
query="state=state-token&code=code-token",
|
||||
),
|
||||
)
|
||||
|
||||
assert handler.status == 403
|
||||
assert handler.json_body()["error"] == "OIDC identity is not allowed"
|
||||
assert handler.header_values("Set-Cookie") == []
|
||||
|
||||
|
||||
def test_auth_status_reports_oidc_capability_without_regressing_passkey_fields(monkeypatch):
|
||||
import api.auth as auth
|
||||
import api.passkeys as passkeys
|
||||
import api.routes as routes
|
||||
|
||||
monkeypatch.setattr(auth, "is_auth_enabled", lambda: True)
|
||||
monkeypatch.setattr(auth, "is_oidc_auth_enabled", lambda: True)
|
||||
monkeypatch.setattr(auth, "_passkey_feature_flag_enabled", lambda: False)
|
||||
monkeypatch.setattr(auth, "get_password_hash", lambda: None)
|
||||
monkeypatch.setattr(auth, "parse_cookie", lambda _handler: None)
|
||||
monkeypatch.setattr(
|
||||
auth,
|
||||
"verify_session",
|
||||
lambda _cookie: (_ for _ in ()).throw(AssertionError("verify_session should not run without a cookie")),
|
||||
)
|
||||
monkeypatch.setattr(passkeys, "registered_credentials", lambda: [])
|
||||
|
||||
handler = RouteFakeHandler()
|
||||
routes.handle_get(handler, urlparse("http://example.com/api/auth/status"))
|
||||
|
||||
assert handler.status == 200
|
||||
assert handler.json_body() == {
|
||||
"auth_enabled": True,
|
||||
"logged_in": False,
|
||||
"oidc_enabled": True,
|
||||
"password_auth_enabled": False,
|
||||
"passwordless_enabled": False,
|
||||
"passkeys_enabled": False,
|
||||
"passkeys_count": 0,
|
||||
"passkey_feature_flag": False,
|
||||
"auth_disabled_acknowledged": False,
|
||||
}
|
||||
Reference in new issue
Block a user