Publish curated Unraid-AppOps-Gateway source from 034c9f844d2455850ba37b31379b7edaa786583e
This commit is contained in:
1 parent
c61fbfd87f
commit
1a54836efc
101 files changed
+4973
-1298
No files matched your search
@@ -0,0 +1,163 @@
|
||||
"""Authorization-code login with PKCE, nonce and browser-bound single-use state."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
from time import time
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import delete
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..core.config import settings
|
||||
from ..db.models import LoginFlow
|
||||
from .user_access import digest
|
||||
|
||||
FLOW_COOKIE = "appops_login_flow"
|
||||
SIGNING_ALGORITHMS = ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"]
|
||||
|
||||
|
||||
def _https_url(value: str) -> str:
|
||||
try:
|
||||
parts = urlsplit(value)
|
||||
_ = parts.port
|
||||
except ValueError:
|
||||
raise HTTPException(503, "OIDC URL configuration is invalid") from None
|
||||
if parts.scheme != "https" or not parts.hostname or parts.username or parts.password or parts.fragment or parts.query:
|
||||
raise HTTPException(503, "OIDC requires a configured HTTPS URL")
|
||||
return value
|
||||
|
||||
|
||||
def provider_config(provider: str) -> dict:
|
||||
if provider == "authentik":
|
||||
issuer, client_id, secret = settings.authentik_issuer, settings.authentik_client_id, settings.authentik_client_secret
|
||||
elif provider == "entra":
|
||||
try:
|
||||
tenant = str(UUID(settings.entra_tenant_id))
|
||||
except ValueError:
|
||||
raise HTTPException(503, "Configure a specific Microsoft Entra tenant") from None
|
||||
issuer = f"https://login.microsoftonline.com/{tenant}/v2.0"
|
||||
client_id, secret = settings.entra_client_id, settings.entra_client_secret
|
||||
else:
|
||||
raise HTTPException(404, "Unknown identity provider")
|
||||
if not issuer or not client_id or not secret or not settings.auth_public_url:
|
||||
raise HTTPException(503, "Identity provider is not fully configured")
|
||||
_https_url(issuer)
|
||||
_https_url(settings.auth_public_url)
|
||||
return {"issuer": issuer, "client_id": client_id, "secret": secret,
|
||||
"redirect_uri": f"{settings.auth_public_url}/api/auth/callback/{provider}"}
|
||||
|
||||
|
||||
def login_provider(provider: str) -> str:
|
||||
"""Use the configured Entra federation without weakening native validation."""
|
||||
try:
|
||||
provider_config(provider)
|
||||
return provider
|
||||
except HTTPException as exc:
|
||||
if provider != "entra" or exc.status_code != 503:
|
||||
raise
|
||||
if not re.fullmatch(r"[a-zA-Z0-9_-]{1,100}", settings.authentik_entra_source):
|
||||
raise HTTPException(503, "Microsoft Entra login is not configured")
|
||||
provider_config("authentik")
|
||||
return "authentik"
|
||||
|
||||
|
||||
def provider_status() -> list[dict]:
|
||||
result = []
|
||||
for provider, label in [("authentik", "Authentik"), ("entra", "Microsoft Entra")]:
|
||||
try:
|
||||
effective_provider = login_provider(provider)
|
||||
if provider == "entra" and effective_provider == "authentik":
|
||||
label = "Microsoft Entra via Authentik"
|
||||
configured = True
|
||||
except HTTPException:
|
||||
configured = False
|
||||
result.append({"id": provider, "label": label, "configured": configured})
|
||||
return result
|
||||
|
||||
|
||||
def discovery(config: dict) -> dict:
|
||||
with httpx.Client(timeout=10, follow_redirects=False) as client:
|
||||
response = client.get(f"{config['issuer'].rstrip('/')}/.well-known/openid-configuration")
|
||||
response.raise_for_status()
|
||||
metadata = response.json()
|
||||
if metadata.get("issuer") != config["issuer"]:
|
||||
raise HTTPException(503, "Identity provider issuer mismatch")
|
||||
for key in ["authorization_endpoint", "token_endpoint", "jwks_uri"]:
|
||||
_https_url(str(metadata.get(key) or ""))
|
||||
return metadata
|
||||
|
||||
|
||||
def start_login(db: Session, provider: str) -> tuple[str, str]:
|
||||
config = provider_config(provider)
|
||||
metadata = discovery(config)
|
||||
state, browser, nonce, verifier = (secrets.token_urlsafe(48) for _ in range(4))
|
||||
db.exec(delete(LoginFlow).where(LoginFlow.expires_at <= int(time())))
|
||||
db.add(LoginFlow(id=digest(state), provider=provider, browser_hash=digest(browser), nonce=nonce,
|
||||
verifier=verifier, expires_at=int(time()) + 600))
|
||||
db.commit()
|
||||
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||||
params = {"client_id": config["client_id"], "redirect_uri": config["redirect_uri"],
|
||||
"response_type": "code", "scope": "openid profile email", "state": state,
|
||||
"nonce": nonce, "code_challenge": challenge, "code_challenge_method": "S256"}
|
||||
return f"{metadata['authorization_endpoint']}?{urlencode(params)}", browser
|
||||
|
||||
|
||||
def consume_flow(db: Session, provider: str, state: str, browser: str) -> LoginFlow:
|
||||
flow = db.get(LoginFlow, digest(state))
|
||||
if not flow or flow.provider != provider or flow.expires_at <= time() or not browser or not hmac.compare_digest(flow.browser_hash, digest(browser)):
|
||||
raise HTTPException(401, "Login expired or browser validation failed")
|
||||
# Atomic consumption prevents concurrent callbacks replaying the same flow.
|
||||
result = db.exec(delete(LoginFlow).where(LoginFlow.id == flow.id))
|
||||
db.commit()
|
||||
if result.rowcount != 1:
|
||||
raise HTTPException(401, "Login has already been used")
|
||||
return flow
|
||||
|
||||
|
||||
def verify_id_token(encoded: str, jwks: dict, config: dict, nonce: str, provider: str) -> dict:
|
||||
header = jwt.get_unverified_header(encoded)
|
||||
algorithm = header.get("alg")
|
||||
if algorithm not in SIGNING_ALGORITHMS:
|
||||
raise HTTPException(401, "Unsupported identity token signature")
|
||||
keys = jwt.PyJWKSet.from_dict(jwks)
|
||||
matches = [key for key in keys.keys if key.key_id == header.get("kid") and key.algorithm_name == algorithm]
|
||||
if len(matches) != 1:
|
||||
raise HTTPException(401, "Identity provider signing key unavailable")
|
||||
claims = jwt.decode(encoded, matches[0].key, algorithms=SIGNING_ALGORITHMS,
|
||||
audience=config["client_id"], issuer=config["issuer"], leeway=30,
|
||||
options={"require": ["iss", "sub", "aud", "exp", "iat", "nonce"]})
|
||||
if not isinstance(claims["nonce"], str) or not hmac.compare_digest(claims["nonce"], nonce):
|
||||
raise HTTPException(401, "Identity token nonce mismatch")
|
||||
if claims.get("azp", config["client_id"]) != config["client_id"]:
|
||||
raise HTTPException(401, "Identity token client mismatch")
|
||||
if isinstance(claims["aud"], list) and len(claims["aud"]) > 1 and claims.get("azp") != config["client_id"]:
|
||||
raise HTTPException(401, "Identity token authorized party missing")
|
||||
if provider == "entra" and (not isinstance(claims.get("tid"), str) or claims["tid"].casefold() != settings.entra_tenant_id.casefold() or not isinstance(claims.get("oid"), str) or not claims["oid"]):
|
||||
raise HTTPException(401, "Microsoft Entra tenant or account mismatch")
|
||||
return claims
|
||||
|
||||
|
||||
def finish_login(db: Session, provider: str, state: str, browser: str, code: str) -> tuple[str, dict]:
|
||||
flow = consume_flow(db, provider, state, browser)
|
||||
config = provider_config(provider)
|
||||
metadata = discovery(config)
|
||||
with httpx.Client(timeout=10, follow_redirects=False) as client:
|
||||
response = client.post(metadata["token_endpoint"], data={
|
||||
"grant_type": "authorization_code", "code": code, "redirect_uri": config["redirect_uri"],
|
||||
"client_id": config["client_id"], "client_secret": config["secret"], "code_verifier": flow.verifier,
|
||||
})
|
||||
response.raise_for_status()
|
||||
tokens = response.json()
|
||||
keys_response = client.get(metadata["jwks_uri"])
|
||||
keys_response.raise_for_status()
|
||||
claims = verify_id_token(tokens.get("id_token", ""), keys_response.json(), config, flow.nonce, provider)
|
||||
# Access/refresh/ID tokens are deliberately discarded after validation.
|
||||
return config["issuer"], claims
|
||||
Reference in new issue
Block a user