Files
Unraid-AppOps-Gateway-Public/backend/app/services/oidc_login.py
T

164 lines
7.6 KiB
Python

"""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