164 lines
7.6 KiB
Python
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
|