feat: add OIDC sign-in, roles, sessions and client address rule
Authorization-code flow with PKCE through an idp adapter (with a fake), ID token validation, identity and roles resolved from the directory with a short cache, refusal of unknown, disabled and locked accounts with one generic page, sessions that end when the account becomes unavailable, sign-out that ends the server-side session, role decorators and the trusted-proxy client address rule. Adds the OIDC_* and PUBLIC_BASE_URL configuration. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
"""Identity-provider adapter: the only code that talks OIDC (AD-1)."""
|
||||
|
||||
from .base import Flow, Identity, Idp, IdpError, IdpUnavailable, LoginRejected
|
||||
from .fake import FakeIdp
|
||||
from .oidc import OidcIdp
|
||||
|
||||
__all__ = [
|
||||
"FakeIdp",
|
||||
"Flow",
|
||||
"Identity",
|
||||
"Idp",
|
||||
"IdpError",
|
||||
"IdpUnavailable",
|
||||
"LoginRejected",
|
||||
"OidcIdp",
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
"""The identity-provider interface, its data types and typed errors (AD-1, AD-17)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class IdpError(Exception):
|
||||
"""Base class of every error the identity-provider adapter raises."""
|
||||
|
||||
|
||||
class IdpUnavailable(IdpError):
|
||||
"""The provider could not be reached or answered with something unusable."""
|
||||
|
||||
|
||||
class LoginRejected(IdpError):
|
||||
"""The sign-in response is not valid: wrong state, bad token, provider error."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Flow:
|
||||
"""What must survive between the redirect and the callback. Kept in the session."""
|
||||
|
||||
state: str
|
||||
nonce: str
|
||||
code_verifier: str
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return {"state": self.state, "nonce": self.nonce, "code_verifier": self.code_verifier}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, str]) -> Flow:
|
||||
return cls(data["state"], data["nonce"], data["code_verifier"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Identity:
|
||||
username: str # the validated preferred_username claim
|
||||
subject: str
|
||||
|
||||
|
||||
class Idp(Protocol):
|
||||
def begin(self) -> tuple[str, Flow]:
|
||||
"""The address to send the browser to, and the flow to remember."""
|
||||
|
||||
def complete(self, params: Mapping[str, str], flow: Flow) -> Identity:
|
||||
"""Validate the callback's query parameters against the flow; return who signed in."""
|
||||
@@ -0,0 +1,41 @@
|
||||
"""In-memory identity provider for tests (AD-1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from collections.abc import Mapping
|
||||
|
||||
from .base import Flow, Identity, IdpUnavailable, LoginRejected
|
||||
|
||||
|
||||
class FakeIdp:
|
||||
"""`begin` hands out a flow; `complete` accepts the one-time code from `callback_params`."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.unavailable = False
|
||||
self.identity: Identity | None = Identity("alice", "sub-alice")
|
||||
self._codes: dict[str, Identity | None] = {}
|
||||
|
||||
def sign_in_as(self, username: str | None) -> None:
|
||||
self.identity = Identity(username, f"sub-{username}") if username else None
|
||||
|
||||
def begin(self) -> tuple[str, Flow]:
|
||||
if self.unavailable:
|
||||
raise IdpUnavailable("The identity provider is not available")
|
||||
flow = Flow(secrets.token_urlsafe(8), secrets.token_urlsafe(8), secrets.token_urlsafe(8))
|
||||
return f"https://idp.example.test/authorize?state={flow.state}", flow
|
||||
|
||||
def callback_params(self, flow: Flow) -> dict[str, str]:
|
||||
code = secrets.token_urlsafe(8)
|
||||
self._codes[code] = self.identity
|
||||
return {"state": flow.state, "code": code}
|
||||
|
||||
def complete(self, params: Mapping[str, str], flow: Flow) -> Identity:
|
||||
if self.unavailable:
|
||||
raise IdpUnavailable("The identity provider is not available")
|
||||
if "error" in params or params.get("state") != flow.state:
|
||||
raise LoginRejected("The sign-in response does not match the request")
|
||||
identity = self._codes.pop(params.get("code", ""), None) # a code works once
|
||||
if identity is None:
|
||||
raise LoginRejected("The identity provider refused the code")
|
||||
return identity
|
||||
@@ -0,0 +1,176 @@
|
||||
"""OpenID Connect authorization-code flow with PKCE. The only module that talks OIDC (AD-1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from authlib.common.errors import AuthlibBaseError
|
||||
from authlib.integrations.requests_client import OAuth2Session
|
||||
from joserfc import jwt
|
||||
from joserfc.errors import InvalidKeyIdError, JoseError
|
||||
from joserfc.jwk import KeySet
|
||||
|
||||
from portal.config.env import OidcConfig
|
||||
|
||||
from .base import Flow, Identity, IdpUnavailable, LoginRejected
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TIMEOUT_SECONDS = 10
|
||||
CACHE_SECONDS = 60 * 60
|
||||
CLOCK_LEEWAY_SECONDS = 30
|
||||
SCOPE = "openid profile email"
|
||||
# Asymmetric algorithms only: a token signed with a shared secret is never accepted.
|
||||
ALGORITHMS = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512"]
|
||||
|
||||
SessionFactory = Callable[[], OAuth2Session]
|
||||
|
||||
|
||||
class OidcIdp:
|
||||
def __init__(
|
||||
self,
|
||||
config: OidcConfig,
|
||||
session_factory: SessionFactory | None = None,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._clock = clock
|
||||
self._factory = session_factory or self._default_session
|
||||
self._lock = threading.Lock()
|
||||
self._metadata: dict[str, Any] | None = None
|
||||
self._metadata_at = 0.0
|
||||
self._keys: KeySet | None = None
|
||||
self._keys_at = 0.0
|
||||
|
||||
def _default_session(self) -> OAuth2Session:
|
||||
return OAuth2Session(
|
||||
client_id=self._config.client_id,
|
||||
client_secret=self._config.client_secret,
|
||||
scope=SCOPE,
|
||||
redirect_uri=self._config.redirect_uri,
|
||||
code_challenge_method="S256",
|
||||
)
|
||||
|
||||
# -- provider documents ----------------------------------------------------------
|
||||
|
||||
def _get_json(self, session: OAuth2Session, url: str) -> dict[str, Any]:
|
||||
try:
|
||||
response = session.get(url, timeout=TIMEOUT_SECONDS, withhold_token=True)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
logger.warning("Identity provider request failed: %s", type(exc).__name__)
|
||||
raise IdpUnavailable("The identity provider is not available") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise IdpUnavailable("The identity provider sent an unusable document")
|
||||
return data
|
||||
|
||||
def _discovery(self, session: OAuth2Session) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if self._metadata is not None and self._clock() - self._metadata_at < CACHE_SECONDS:
|
||||
return self._metadata
|
||||
url = self._config.issuer.rstrip("/") + "/.well-known/openid-configuration"
|
||||
data = self._get_json(session, url)
|
||||
if data.get("issuer") != self._config.issuer:
|
||||
raise IdpUnavailable("The identity provider reports a different issuer")
|
||||
for name in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
|
||||
if not isinstance(data.get(name), str) or not data[name].startswith("https://"):
|
||||
raise IdpUnavailable(f"The identity provider document lacks a usable {name}")
|
||||
with self._lock:
|
||||
self._metadata, self._metadata_at = data, self._clock()
|
||||
return data
|
||||
|
||||
def _keyset(self, session: OAuth2Session, jwks_uri: str, *, refresh: bool) -> KeySet:
|
||||
with self._lock:
|
||||
if self._keys is not None and not refresh:
|
||||
if self._clock() - self._keys_at < CACHE_SECONDS:
|
||||
return self._keys
|
||||
try:
|
||||
keys = KeySet.import_key_set(self._get_json(session, jwks_uri))
|
||||
except (JoseError, ValueError, KeyError) as exc:
|
||||
raise IdpUnavailable("The identity provider sent unusable keys") from exc
|
||||
with self._lock:
|
||||
self._keys, self._keys_at = keys, self._clock()
|
||||
return keys
|
||||
|
||||
# -- interface ---------------------------------------------------------------------
|
||||
|
||||
def begin(self) -> tuple[str, Flow]:
|
||||
session = self._factory()
|
||||
metadata = self._discovery(session)
|
||||
flow = Flow(
|
||||
state=secrets.token_urlsafe(32),
|
||||
nonce=secrets.token_urlsafe(32),
|
||||
code_verifier=secrets.token_urlsafe(64),
|
||||
)
|
||||
url, _ = session.create_authorization_url(
|
||||
metadata["authorization_endpoint"],
|
||||
state=flow.state,
|
||||
nonce=flow.nonce,
|
||||
code_verifier=flow.code_verifier,
|
||||
)
|
||||
return url, flow
|
||||
|
||||
def complete(self, params: Mapping[str, str], flow: Flow) -> Identity:
|
||||
if "error" in params:
|
||||
raise LoginRejected("The identity provider refused the sign-in")
|
||||
state, code = params.get("state", ""), params.get("code", "")
|
||||
if not code or not hmac.compare_digest(state.encode(), flow.state.encode()):
|
||||
raise LoginRejected("The sign-in response does not match the request")
|
||||
|
||||
session = self._factory()
|
||||
metadata = self._discovery(session)
|
||||
try:
|
||||
token = session.fetch_token(
|
||||
metadata["token_endpoint"],
|
||||
grant_type="authorization_code",
|
||||
code=code,
|
||||
code_verifier=flow.code_verifier,
|
||||
timeout=TIMEOUT_SECONDS,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
logger.warning("Identity provider request failed: %s", type(exc).__name__)
|
||||
raise IdpUnavailable("The identity provider is not available") from exc
|
||||
except AuthlibBaseError as exc:
|
||||
raise LoginRejected("The identity provider refused the code") from exc
|
||||
id_token = token.get("id_token") if isinstance(token, dict) else None
|
||||
if not isinstance(id_token, str):
|
||||
raise LoginRejected("The identity provider sent no ID token")
|
||||
return self._identity(session, metadata["jwks_uri"], id_token, flow)
|
||||
|
||||
def _identity(self, session: OAuth2Session, jwks_uri: str, id_token: str, flow: Flow):
|
||||
decoded = None
|
||||
for refresh in (False, True): # the second try covers a key rotation
|
||||
keys = self._keyset(session, jwks_uri, refresh=refresh)
|
||||
try:
|
||||
decoded = jwt.decode(id_token, keys, algorithms=ALGORITHMS)
|
||||
break
|
||||
except InvalidKeyIdError as exc:
|
||||
if refresh:
|
||||
raise LoginRejected("The ID token is not valid") from exc
|
||||
except JoseError as exc:
|
||||
raise LoginRejected("The ID token is not valid") from exc
|
||||
registry = jwt.JWTClaimsRegistry(
|
||||
leeway=CLOCK_LEEWAY_SECONDS,
|
||||
now=int(self._clock()),
|
||||
iss={"essential": True, "value": self._config.issuer},
|
||||
aud={"essential": True, "value": self._config.client_id},
|
||||
exp={"essential": True},
|
||||
sub={"essential": True},
|
||||
nonce={"essential": True, "value": flow.nonce},
|
||||
)
|
||||
try:
|
||||
registry.validate(decoded.claims)
|
||||
except JoseError as exc:
|
||||
raise LoginRejected("The ID token claims are not valid") from exc
|
||||
username = decoded.claims.get("preferred_username")
|
||||
if not isinstance(username, str) or not username.strip():
|
||||
raise LoginRejected("The ID token has no usable user name")
|
||||
return Identity(username=username.strip(), subject=str(decoded.claims["sub"]))
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Where the rest of the portal gets its adapters. Tests install fakes with `install`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .directory import Directory, LdapDirectory
|
||||
from .idp import Idp, OidcIdp
|
||||
|
||||
_directory: Directory | None = None
|
||||
_idp: Idp | None = None
|
||||
|
||||
|
||||
def get_directory() -> Directory:
|
||||
global _directory
|
||||
if _directory is None:
|
||||
_directory = LdapDirectory(settings.DIRECTORY)
|
||||
return _directory
|
||||
|
||||
|
||||
def get_idp() -> Idp:
|
||||
global _idp
|
||||
if _idp is None:
|
||||
_idp = OidcIdp(settings.OIDC)
|
||||
return _idp
|
||||
|
||||
|
||||
def install(directory: Directory | None = None, idp: Idp | None = None) -> None:
|
||||
"""Replace the adapters (tests). Passing None for one leaves it to be built on first use."""
|
||||
global _directory, _idp
|
||||
_directory, _idp = directory, idp
|
||||
Reference in New Issue
Block a user