Files
email-unsubscribe-links-scr…/oauth.py
T
Rsclub2_2 68940e831a feat: Sammelpostfach per SSO (OAuth/XOAUTH2) statt Passwort
Einmal "Connect mailbox" (OIDC mit PKCE, prompt=login, Anmeldung als technisches Konto des
Postfachs), Prüfung der Mail-Adresse im Token, Refresh-Token verschlüsselt in DATA_DIR,
Access-Token im Speicher; ein gunicorn-Worker wegen der Token-Rotation. Ohne OIDC_* weiter mit
EMAIL_PASSWORD.

Claude-Session: https://claude.ai/code/session_01SqzF6qT3TQ8ej3wigEuMSu
2026-10-29 14:29:22 +01:00

185 lines
7.0 KiB
Python

"""
OAuth access to the collection mailbox (XOAUTH2) instead of a stored password.
An admin connects the mailbox once: OIDC code flow with PKCE, signing in at the SSO as the technical
account of the mailbox. The refresh token is stored encrypted in DATA_DIR; access tokens stay in memory.
"""
import base64
import hashlib
import json
import os
import secrets
import threading
import time
from pathlib import Path
from urllib.parse import urlencode
import httpx
from cryptography.fernet import Fernet, InvalidToken
SCOPES = "openid profile email offline_access"
REFRESH_MARGIN_SEC = 60
PENDING_TTL_SEC = 600
class OAuthError(Exception):
pass
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def new_pkce() -> tuple[str, str]:
verifier = _b64url(secrets.token_bytes(48))
return verifier, _b64url(hashlib.sha256(verifier.encode()).digest())
def jwt_claims(token: str) -> dict:
try:
payload = token.split(".")[1]
return json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
except (IndexError, ValueError) as e:
raise OAuthError("Access token is not a JWT") from e
def xoauth2_string(user: str, token: str) -> str:
return f"user={user}\x01auth=Bearer {token}\x01\x01"
class MailboxOAuth:
"""Token handling for exactly one mailbox."""
def __init__(self, issuer, client_id, client_secret, redirect_uri, mailbox, data_dir, key,
transport=None, clock=time.time):
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.mailbox = mailbox
self.authorization_endpoint = issuer.rstrip("/") + "/api/oidc/authorization"
self.token_endpoint = issuer.rstrip("/") + "/api/oidc/token"
self.token_file = Path(data_dir) / "oauth-token"
self.cipher = Fernet(key.encode() if isinstance(key, str) else key)
self.transport = transport
self.clock = clock
self._lock = threading.Lock()
self._access: tuple[str, float] | None = None
self._pending: dict[str, tuple[str, float]] = {}
@classmethod
def from_env(cls):
names = ["OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI", "TOKEN_KEY"]
if not all(os.environ.get(n) for n in names):
return None
return cls(
issuer=os.environ["OIDC_ISSUER"],
client_id=os.environ["OIDC_CLIENT_ID"],
client_secret=os.environ["OIDC_CLIENT_SECRET"],
redirect_uri=os.environ["OIDC_REDIRECT_URI"],
mailbox=os.environ.get("EMAIL_USERNAME", ""),
data_dir=os.environ.get("DATA_DIR", "/data"),
key=os.environ["TOKEN_KEY"],
)
# --- stored refresh token ---------------------------------------------------------------
def connected(self) -> bool:
return self._read_refresh() is not None
def _read_refresh(self) -> str | None:
try:
return self.cipher.decrypt(self.token_file.read_bytes()).decode()
except (FileNotFoundError, InvalidToken):
return None
def _write_refresh(self, token: str | None) -> None:
if token is None:
self.token_file.unlink(missing_ok=True)
return
tmp = self.token_file.with_suffix(".tmp")
tmp.write_bytes(self.cipher.encrypt(token.encode()))
tmp.chmod(0o600)
tmp.replace(self.token_file)
# --- connect flow -------------------------------------------------------------------------
def start(self) -> str:
verifier, challenge = new_pkce()
state = secrets.token_urlsafe(32)
now = self.clock()
with self._lock:
self._pending = {k: v for k, v in self._pending.items() if now - v[1] < PENDING_TTL_SEC}
self._pending[state] = (verifier, now)
query = {
"response_type": "code",
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"scope": SCOPES,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
"prompt": "login", # sign in as the mailbox account, not with the admin's session
"login_hint": self.mailbox,
}
return self.authorization_endpoint + "?" + urlencode(query)
def finish(self, state: str, code: str) -> None:
with self._lock:
entry = self._pending.pop(state, None)
if not entry or self.clock() - entry[1] > PENDING_TTL_SEC or not code:
raise OAuthError("Invalid or expired request")
tokens = self._token_request({
"grant_type": "authorization_code",
"code": code,
"redirect_uri": self.redirect_uri,
"code_verifier": entry[0],
})
email = jwt_claims(tokens["access_token"]).get("email", "")
if email.lower() != self.mailbox.lower():
raise OAuthError(f"Signed in as {email or 'unknown'}, expected {self.mailbox}")
if not tokens.get("refresh_token"):
raise OAuthError("SSO returned no refresh token")
with self._lock:
self._store(tokens)
def disconnect(self) -> None:
with self._lock:
self._write_refresh(None)
self._access = None
# --- tokens -------------------------------------------------------------------------------
def _token_request(self, data: dict) -> dict:
try:
with httpx.Client(timeout=20.0, transport=self.transport) as client:
r = client.post(self.token_endpoint, data=data, auth=(self.client_id, self.client_secret))
except httpx.HTTPError as e:
raise OAuthError(f"Token endpoint unreachable: {e}") from e
try:
body = r.json()
except ValueError:
body = {}
if r.status_code != 200 or not body.get("access_token"):
raise OAuthError(f"Token request failed ({r.status_code}): {body.get('error', 'unknown')}")
return body
def _store(self, tokens: dict) -> None:
if tokens.get("refresh_token"):
self._write_refresh(tokens["refresh_token"])
self._access = (tokens["access_token"], self.clock() + float(tokens.get("expires_in") or 300))
def access_token(self) -> str:
with self._lock:
if self._access and self._access[1] - REFRESH_MARGIN_SEC > self.clock():
return self._access[0]
refresh = self._read_refresh()
if not refresh:
raise OAuthError("Mailbox not connected")
try:
tokens = self._token_request({"grant_type": "refresh_token", "refresh_token": refresh})
except OAuthError as e:
if "invalid_grant" in str(e):
self._write_refresh(None) # revoked/expired: connect again
self._access = None
raise
self._store(tokens)
return tokens["access_token"]