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:
2026-10-17 19:19:51 +02:00
co-authored by Claude Sonnet 5.5
parent 7458a2a815
commit 3c69c2a156
32 changed files with 1692 additions and 14 deletions
+11 -1
View File
@@ -30,6 +30,16 @@ CA_FILE_HOST=
LDAP_PUBLISHER_GROUP_DN= LDAP_PUBLISHER_GROUP_DN=
LDAP_ADMIN_GROUP_DN= LDAP_ADMIN_GROUP_DN=
# Required. The OpenID provider's application: the issuer address from its OpenID
# configuration page (keep the trailing slash), client ID and client secret.
OIDC_ISSUER=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
# Required. The address users reach the portal at (scheme and host only). The sign-in
# redirect address registered at the provider is this plus /auth/callback.
PUBLIC_BASE_URL=https://portal.example.test
# Optional. Directory for the database. Default /data (the container volume). # Optional. Directory for the database. Default /data (the container volume).
# DATA_DIR=/data # DATA_DIR=/data
@@ -37,7 +47,7 @@ LDAP_ADMIN_GROUP_DN=
# DEBUG=false # DEBUG=false
# Optional. Comma-separated IP addresses or networks of reverse proxies. Default empty. # Optional. Comma-separated IP addresses or networks of reverse proxies. Default empty.
# Reserved for the client-IP rule in a later entry; currently has no effect. # The portal takes the client address from X-Forwarded-For only when the request comes from one of these.
# TRUSTED_PROXIES= # TRUSTED_PROXIES=
# Optional. DEBUG, INFO, WARNING, ERROR or CRITICAL. Default INFO. # Optional. DEBUG, INFO, WARNING, ERROR or CRITICAL. Default INFO.
+2
View File
@@ -16,6 +16,8 @@ RUN SECRET_KEY=build-time-only-value-not-used-at-runtime-000000 \
ALLOWED_HOSTS=localhost DATA_DIR=/tmp \ ALLOWED_HOSTS=localhost DATA_DIR=/tmp \
LDAP_SERVER=build.invalid LDAP_USER_OU_DN=build LDAP_BIND_DN=build LDAP_BIND_PASSWORD=build \ LDAP_SERVER=build.invalid LDAP_USER_OU_DN=build LDAP_BIND_DN=build LDAP_BIND_PASSWORD=build \
LDAP_CA_FILE=/app/README.md LDAP_PUBLISHER_GROUP_DN=build LDAP_ADMIN_GROUP_DN=build \ LDAP_CA_FILE=/app/README.md LDAP_PUBLISHER_GROUP_DN=build LDAP_ADMIN_GROUP_DN=build \
OIDC_ISSUER=https://build.invalid/ OIDC_CLIENT_ID=build OIDC_CLIENT_SECRET=build \
PUBLIC_BASE_URL=https://build.invalid \
/app/.venv/bin/python manage.py collectstatic --noinput /app/.venv/bin/python manage.py collectstatic --noinput
# Not part of the runtime image: `docker compose --profile test run --rm live-test` # Not part of the runtime image: `docker compose --profile test run --rm live-test`
+5 -1
View File
@@ -12,7 +12,11 @@ All configuration comes from environment variables, validated at startup. If any
| `ALLOWED_HOSTS` | yes | | Comma-separated host names | | `ALLOWED_HOSTS` | yes | | Comma-separated host names |
| `DATA_DIR` | no | `/data` | Existing directory for the SQLite database | | `DATA_DIR` | no | `/data` | Existing directory for the SQLite database |
| `DEBUG` | no | `false` | `true` or `false` | | `DEBUG` | no | `false` | `true` or `false` |
| `TRUSTED_PROXIES` | no | empty | Comma-separated IP addresses or networks. Reserved for the client-IP rule in a later entry; currently has no effect | | `TRUSTED_PROXIES` | no | empty | Comma-separated IP addresses or networks of the reverse proxies. The client address is taken from the last `X-Forwarded-For` entry only when the request comes from one of them |
| `OIDC_ISSUER` | yes | | Issuer address from the provider's OpenID configuration page, exactly as shown (https, keep the trailing slash) |
| `OIDC_CLIENT_ID` | yes | | Client ID of the provider's application |
| `OIDC_CLIENT_SECRET` | yes | | Client secret of the provider's application |
| `PUBLIC_BASE_URL` | yes | | Address users reach the portal at, scheme and host only. The provider's redirect address must be this plus `/auth/callback` |
| `LOG_LEVEL` | no | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` | | `LOG_LEVEL` | no | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
| `LDAP_SERVER` | yes | | Directory server host name (optionally `:port`); LDAPS on 636 is always used and the name must be in the server's certificate | | `LDAP_SERVER` | yes | | Directory server host name (optionally `:port`); LDAPS on 636 is always used and the name must be in the server's certificate |
| `LDAP_USER_OU_DN` | yes | | Distinguished name of the OU whose users the portal may read and change | | `LDAP_USER_OU_DN` | yes | | Distinguished name of the OU whose users the portal may read and change |
+35
View File
@@ -0,0 +1,35 @@
"""The client address, trusting X-Forwarded-For only from configured proxies (AD-7)."""
from __future__ import annotations
import ipaddress
from collections.abc import Iterable
Network = ipaddress.IPv4Network | ipaddress.IPv6Network
def parse_networks(items: Iterable[str]) -> list[Network]:
return [ipaddress.ip_network(item, strict=False) for item in items]
def _address(value: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
try:
return ipaddress.ip_address(value.strip())
except ValueError:
return None
def client_ip(peer: str, forwarded_for: str, trusted: list[Network]) -> str:
"""The TCP peer, or the last X-Forwarded-For entry when the peer is a trusted proxy.
The last entry is the one the proxy appended itself; earlier ones come from the client and
can be forged. X-Real-IP is never used.
"""
peer_address = _address(peer)
if peer_address is None:
return ""
if not any(peer_address in network for network in trusted):
return str(peer_address)
last = forwarded_for.rsplit(",", 1)[-1] if forwarded_for else ""
forwarded = _address(last)
return str(forwarded) if forwarded is not None else str(peer_address)
+39
View File
@@ -0,0 +1,39 @@
"""Access control for views: signed in, Publisher, Admin (FR-1, FR-2, FR-25)."""
from __future__ import annotations
from functools import wraps
from urllib.parse import urlencode
from django.shortcuts import redirect, render
from django.urls import reverse
def login_required(view):
@wraps(view)
def wrapper(request, *args, **kwargs):
if getattr(request, "portal_user", None) is None:
target = reverse("login")
if request.method in ("GET", "HEAD"):
target += "?" + urlencode({"next": request.get_full_path()})
return redirect(target)
return view(request, *args, **kwargs)
return wrapper
def _role_required(attribute: str):
def decorator(view):
@wraps(view)
def wrapper(request, *args, **kwargs):
if not getattr(request.portal_user, attribute):
return render(request, "accounts/forbidden.html", status=403)
return view(request, *args, **kwargs)
return login_required(wrapper)
return decorator
publisher_required = _role_required("is_publisher")
admin_required = _role_required("is_admin")
+101
View File
@@ -0,0 +1,101 @@
"""Who the signed-in user is and what they may do: identity, roles and account state (AD-5).
The directory is the source of truth. Roles and account state are cached in the session for a
short time; a directory outage denies elevated roles, and a disabled or locked account ends all
of its sessions at its next request.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from django.conf import settings
from django.db import transaction
from portal.adapters.directory import DirectoryUnavailable, DirectoryUser
from portal.adapters.registry import get_directory
from portal.audit import services as audit
from .session_store import DISPLAY_NAME, ROLES
from .sessions import SessionUser, current_user, end_sessions
logger = logging.getLogger(__name__)
class SignInRefused(Exception):
"""The directory does not allow this account to start a session; `reason` is for the audit."""
def __init__(self, reason: str) -> None:
self.reason = reason
super().__init__(f"Sign-in refused: {reason}")
@dataclass(frozen=True)
class PortalUser:
user: SessionUser
display_name: str
is_publisher: bool
is_admin: bool
def _display_name(user: DirectoryUser) -> str:
full_name = " ".join(p for p in (user.given_name, user.surname) if p)
return user.display_name or full_name or user.sam
def admit(username: str) -> DirectoryUser:
"""The directory user for a verified sign-in name, or SignInRefused.
Raises DirectoryUnavailable when the directory cannot answer; the caller denies the sign-in.
"""
user = get_directory().find_user(username)
if user is None:
raise SignInRefused("unknown")
if not user.enabled:
raise SignInRefused("disabled")
if user.locked:
raise SignInRefused("locked")
return user
def remember(request, user: DirectoryUser) -> None:
"""Store what the portal shows and decides with, with the time it was read."""
request.session[DISPLAY_NAME] = _display_name(user)
request.session[ROLES] = {
"publisher": user.is_publisher,
"admin": user.is_admin,
"checked": time.time(),
}
def current(request) -> PortalUser | None:
"""The signed-in user for this request, refreshing roles and state when the cache is old."""
user = current_user(request)
if user is None:
return None
session = request.session
cached = session.get(ROLES)
name = session.get(DISPLAY_NAME) or user.sam
age = time.time() - cached.get("checked", 0) if isinstance(cached, dict) else None
if age is not None and age < settings.ROLE_CACHE_SECONDS:
return PortalUser(user, name, bool(cached.get("publisher")), bool(cached.get("admin")))
try:
fresh = get_directory().find_user(user.sam)
except DirectoryUnavailable:
logger.warning("Roles could not be refreshed; elevated roles are denied")
return PortalUser(user, name, False, False) # the old cache stays expired
if fresh is None or not fresh.enabled or fresh.locked:
with transaction.atomic():
end_sessions(user)
audit.record(
user.key,
"session.ended",
ip=getattr(request, "client_ip", ""),
detail={"reason": "account_unavailable"},
)
session.flush()
return None
remember(request, fresh)
return PortalUser(user, _display_name(fresh), fresh.is_publisher, fresh.is_admin)
+33 -1
View File
@@ -1,9 +1,14 @@
"""Absolute session limit; the idle limit is SESSION_COOKIE_AGE with save-every-request.""" """Session limits, client address and the signed-in user.
The idle limit is SESSION_COOKIE_AGE with save-every-request; the absolute limit is below.
"""
import time import time
from django.conf import settings from django.conf import settings
from . import identity
from .clientip import client_ip, parse_networks
from .session_store import CREATED_AT from .session_store import CREATED_AT
@@ -18,3 +23,30 @@ class AbsoluteSessionLimitMiddleware:
# flush() deletes the row; the empty session makes SessionMiddleware clear the cookie. # flush() deletes the row; the empty session makes SessionMiddleware clear the cookie.
session.flush() session.flush()
return self.get_response(request) return self.get_response(request)
class ClientIpMiddleware:
"""Set request.client_ip from the peer and, behind a trusted proxy, X-Forwarded-For (AD-7)."""
def __init__(self, get_response):
self.get_response = get_response
self.trusted = parse_networks(settings.TRUSTED_PROXIES)
def __call__(self, request):
request.client_ip = client_ip(
request.META.get("REMOTE_ADDR", ""),
request.META.get("HTTP_X_FORWARDED_FOR", ""),
self.trusted,
)
return self.get_response(request)
class CurrentUserMiddleware:
"""Set request.portal_user: the signed-in user with roles, or None (AD-5)."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
request.portal_user = identity.current(request)
return self.get_response(request)
+3
View File
@@ -12,6 +12,9 @@ from .models import UserSession
USER_KEY = "session_user_key" USER_KEY = "session_user_key"
USER_SAM = "session_user_sam" USER_SAM = "session_user_sam"
CREATED_AT = "session_created_at" # epoch seconds, UTC CREATED_AT = "session_created_at" # epoch seconds, UTC
DISPLAY_NAME = "session_display_name"
ROLES = "session_roles" # {"publisher": bool, "admin": bool, "checked": epoch seconds}
LOGIN_FLOW = "session_login_flow" # state, nonce and PKCE verifier between redirect and callback
class SessionStore(DBSessionStore): class SessionStore(DBSessionStore):
+11
View File
@@ -0,0 +1,11 @@
from django.urls import path
from . import views
urlpatterns = [
path("", views.home, name="home"),
path("auth/login", views.login, name="login"),
path("auth/callback", views.callback, name="callback"),
path("auth/logout", views.logout, name="logout"),
path("auth/signed-out", views.signed_out, name="signed-out"),
]
+105
View File
@@ -0,0 +1,105 @@
"""Sign-in with OIDC, sign-out and the signed-in landing page (FR-1, FR-3)."""
from __future__ import annotations
import logging
from django.db import transaction
from django.shortcuts import redirect, render
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_GET, require_POST
from portal.adapters.directory import DirectoryUnavailable
from portal.adapters.idp import Flow, IdpUnavailable, LoginRejected
from portal.adapters.registry import get_idp
from portal.audit import services as audit
from . import identity
from .decorators import login_required
from .session_store import LOGIN_FLOW
from .sessions import start_session
logger = logging.getLogger(__name__)
def _unavailable(request):
return render(request, "accounts/unavailable.html", status=503)
def _refused(request, reason: str, username: str = ""):
"""One generic page for every refusal, so it never says whether the name exists."""
audit.record_failure(
username,
"signin.refused",
ip=request.client_ip,
detail={"reason": reason},
)
return render(request, "accounts/refused.html", status=403)
def _safe_next(value: str) -> str:
if value.startswith("/") and url_has_allowed_host_and_scheme(value, allowed_hosts=None):
return value
return "/"
@require_GET
def login(request):
if request.portal_user is not None:
return redirect("home")
try:
url, flow = get_idp().begin()
except IdpUnavailable:
return _unavailable(request)
target = _safe_next(request.GET.get("next", ""))
request.session[LOGIN_FLOW] = {**flow.as_dict(), "next": target}
return redirect(url)
@require_GET
def callback(request):
data = request.session.pop(LOGIN_FLOW, None) # a flow works once
try:
flow = Flow.from_dict(data) if data else None
except KeyError:
flow = None
if flow is None:
return _refused(request, "no_flow")
try:
found = get_idp().complete(request.GET, flow)
except IdpUnavailable:
return _unavailable(request)
except LoginRejected:
return _refused(request, "provider_response")
try:
user = identity.admit(found.username)
except identity.SignInRefused as refusal:
return _refused(request, refusal.reason, found.username)
except DirectoryUnavailable:
return _unavailable(request)
start_session(request, user.sam)
identity.remember(request, user)
with transaction.atomic():
audit.record(user.sam, "signin.succeeded", ip=request.client_ip)
return redirect(_safe_next(data.get("next", "")))
@login_required
def home(request):
return render(request, "accounts/home.html", {"portal_user": request.portal_user})
@require_POST
def logout(request):
portal_user = request.portal_user
if portal_user is not None:
with transaction.atomic():
audit.record(portal_user.user.key, "signout", ip=request.client_ip)
request.session.flush() # deletes the session row on the server
return redirect("signed-out")
@require_GET
def signed_out(request):
return render(request, "accounts/signed_out.html")
+16
View File
@@ -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",
]
+49
View File
@@ -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."""
+41
View File
@@ -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
+176
View File
@@ -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"]))
+31
View File
@@ -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
+33
View File
@@ -6,6 +6,7 @@ import ipaddress
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
@@ -42,6 +43,18 @@ class DirectoryConfig:
admin_group_dn: str admin_group_dn: str
@dataclass(frozen=True)
class OidcConfig:
issuer: str # exactly as the provider reports it, including any trailing slash
client_id: str
client_secret: str = field(repr=False)
public_base_url: str # origin the users reach the portal at; no trailing slash
@property
def redirect_uri(self) -> str:
return f"{self.public_base_url}/auth/callback"
@dataclass(frozen=True) @dataclass(frozen=True)
class Config: class Config:
secret_key: str secret_key: str
@@ -52,6 +65,7 @@ class Config:
log_level: str log_level: str
default_timezone: str default_timezone: str
directory: DirectoryConfig directory: DirectoryConfig
oidc: OidcConfig
def load_config(environ: Mapping[str, str]) -> Config: def load_config(environ: Mapping[str, str]) -> Config:
@@ -135,6 +149,19 @@ def load_config(environ: Mapping[str, str]) -> Config:
if raw("LDAP_CA_FILE") is not None and not ca_file.is_file(): if raw("LDAP_CA_FILE") is not None and not ca_file.is_file():
problems.append("LDAP_CA_FILE: must be an existing file") problems.append("LDAP_CA_FILE: must be an existing file")
issuer = required("OIDC_ISSUER", "issuer address from the provider's OpenID configuration")
client_id = required("OIDC_CLIENT_ID", "client ID of the provider's application")
client_secret = required("OIDC_CLIENT_SECRET", "client secret of the provider's application")
base_url = required("PUBLIC_BASE_URL", "address users reach the portal at, e.g. https://host")
base_url = base_url.rstrip("/")
if issuer and urlsplit(issuer).scheme != "https":
problems.append("OIDC_ISSUER: must be an https address")
if base_url:
parts = urlsplit(base_url)
allowed = ("https", "http") if debug else ("https",)
if parts.scheme not in allowed or not parts.netloc or parts.path or parts.query:
problems.append("PUBLIC_BASE_URL: must be an https origin such as https://host")
if problems: if problems:
raise ConfigError(problems) raise ConfigError(problems)
@@ -155,4 +182,10 @@ def load_config(environ: Mapping[str, str]) -> Config:
publisher_group_dn=publisher_group_dn, publisher_group_dn=publisher_group_dn,
admin_group_dn=admin_group_dn, admin_group_dn=admin_group_dn,
), ),
oidc=OidcConfig(
issuer=issuer,
client_id=client_id,
client_secret=client_secret,
public_base_url=base_url,
),
) )
+7 -1
View File
@@ -19,11 +19,12 @@ BASE_DIR = Path(__file__).resolve().parent.parent.parent
SECRET_KEY = _config.secret_key SECRET_KEY = _config.secret_key
DEBUG = _config.debug DEBUG = _config.debug
ALLOWED_HOSTS = _config.allowed_hosts ALLOWED_HOSTS = _config.allowed_hosts
# Reserved for the client-IP rule in a later entry; currently has no effect. # The client-IP rule (AD-7) trusts X-Forwarded-For only from these peers.
TRUSTED_PROXIES = _config.trusted_proxies TRUSTED_PROXIES = _config.trusted_proxies
DATA_DIR = _config.data_dir DATA_DIR = _config.data_dir
DISPLAY_TIME_ZONE = _config.default_timezone DISPLAY_TIME_ZONE = _config.default_timezone
DIRECTORY = _config.directory DIRECTORY = _config.directory
OIDC = _config.oidc
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.contenttypes", "django.contrib.contenttypes",
@@ -36,11 +37,13 @@ INSTALLED_APPS = [
MIDDLEWARE = [ MIDDLEWARE = [
"portal.config.middleware.RequestIdMiddleware", "portal.config.middleware.RequestIdMiddleware",
"portal.accounts.middleware.ClientIpMiddleware",
"portal.config.middleware.SecurityHeadersMiddleware", "portal.config.middleware.SecurityHeadersMiddleware",
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"portal.accounts.middleware.AbsoluteSessionLimitMiddleware", "portal.accounts.middleware.AbsoluteSessionLimitMiddleware",
"portal.accounts.middleware.CurrentUserMiddleware",
"django.middleware.locale.LocaleMiddleware", "django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
@@ -99,10 +102,13 @@ SESSION_ENGINE = "portal.accounts.session_store"
SESSION_COOKIE_AGE = 60 * 60 # idle limit: 1 hour, renewed on every request SESSION_COOKIE_AGE = 60 * 60 # idle limit: 1 hour, renewed on every request
SESSION_SAVE_EVERY_REQUEST = True SESSION_SAVE_EVERY_REQUEST = True
SESSION_ABSOLUTE_SECONDS = 4 * 60 * 60 # absolute limit: 4 hours SESSION_ABSOLUTE_SECONDS = 4 * 60 * 60 # absolute limit: 4 hours
# How long roles and account state are trusted before the directory is asked again (AD-5).
ROLE_CACHE_SECONDS = 60
SESSION_COOKIE_SECURE = not DEBUG SESSION_COOKIE_SECURE = not DEBUG
SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax" SESSION_COOKIE_SAMESITE = "Lax"
CSRF_COOKIE_SECURE = not DEBUG CSRF_COOKIE_SECURE = not DEBUG
CSRF_TRUSTED_ORIGINS = [OIDC.public_base_url]
CSRF_COOKIE_HTTPONLY = True CSRF_COOKIE_HTTPONLY = True
CSRF_COOKIE_SAMESITE = "Lax" CSRF_COOKIE_SAMESITE = "Lax"
+2 -1
View File
@@ -1,7 +1,8 @@
from django.urls import path from django.urls import include, path
from .health import healthz from .health import healthz
urlpatterns = [ urlpatterns = [
path("healthz", healthz, name="healthz"), path("healthz", healthz, name="healthz"),
path("", include("portal.accounts.urls")),
] ]
+13
View File
@@ -0,0 +1,13 @@
{% load i18n %}<!doctype html>
<html lang="{{ LANGUAGE_CODE }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{% trans "Self-service portal" %}{% endblock %}</title>
</head>
<body>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{% extends "accounts/base.html" %}
{% load i18n %}
{% block title %}{% trans "No permission" %}{% endblock %}
{% block content %}
<h1>{% trans "No permission" %}</h1>
<p>{% trans "You do not have permission to open this page." %}</p>
<p><a href="{% url 'home' %}">{% trans "Back to the portal" %}</a></p>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "accounts/base.html" %}
{% load i18n %}
{% block title %}{% trans "Welcome" %}{% endblock %}
{% block content %}
<h1>{% blocktrans with name=portal_user.display_name %}Signed in as {{ name }}{% endblocktrans %}</h1>
<p>{{ portal_user.user.sam }}</p>
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button type="submit">{% trans "Sign out" %}</button>
</form>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
{% extends "accounts/base.html" %}
{% load i18n %}
{% block title %}{% trans "Sign-in not possible" %}{% endblock %}
{% block content %}
<h1>{% trans "Sign-in not possible" %}</h1>
<p>{% trans "You cannot sign in to the portal with this account. If you think this is a mistake, contact your administrator." %}</p>
<p><a href="{% url 'login' %}">{% trans "Try again" %}</a></p>
{% endblock %}
@@ -0,0 +1,7 @@
{% extends "accounts/base.html" %}
{% load i18n %}
{% block title %}{% trans "Signed out" %}{% endblock %}
{% block content %}
<h1>{% trans "You have been signed out" %}</h1>
<p><a href="{% url 'login' %}">{% trans "Sign in again" %}</a></p>
{% endblock %}
@@ -0,0 +1,7 @@
{% extends "accounts/base.html" %}
{% load i18n %}
{% block title %}{% trans "Not available" %}{% endblock %}
{% block content %}
<h1>{% trans "Not available" %}</h1>
<p>{% trans "This is not possible right now. Please try again later." %}</p>
{% endblock %}
+19
View File
@@ -1,5 +1,11 @@
from types import SimpleNamespace
import pytest import pytest
from portal.adapters import registry
from portal.adapters.directory import FakeDirectory
from portal.adapters.idp import FakeIdp
@pytest.fixture @pytest.fixture
def valid_env(tmp_path): def valid_env(tmp_path):
@@ -16,4 +22,17 @@ def valid_env(tmp_path):
"LDAP_CA_FILE": str(ca_file), "LDAP_CA_FILE": str(ca_file),
"LDAP_PUBLISHER_GROUP_DN": "CN=Publishers,OU=Users,DC=example,DC=test", "LDAP_PUBLISHER_GROUP_DN": "CN=Publishers,OU=Users,DC=example,DC=test",
"LDAP_ADMIN_GROUP_DN": "CN=Admins,OU=Users,DC=example,DC=test", "LDAP_ADMIN_GROUP_DN": "CN=Admins,OU=Users,DC=example,DC=test",
"OIDC_ISSUER": "https://idp.example.test/application/o/portal/",
"OIDC_CLIENT_ID": "portal-client",
"OIDC_CLIENT_SECRET": "client-secret",
"PUBLIC_BASE_URL": "https://portal.example.test",
} }
@pytest.fixture(autouse=True)
def adapters():
"""Fakes for every adapter, so no test can reach a real directory or provider."""
fakes = SimpleNamespace(directory=FakeDirectory(), idp=FakeIdp())
registry.install(fakes.directory, fakes.idp)
yield fakes
registry.install()
+56
View File
@@ -0,0 +1,56 @@
import pytest
from django.test import RequestFactory, override_settings
from portal.accounts.clientip import client_ip, parse_networks
from portal.accounts.middleware import ClientIpMiddleware
PROXY = parse_networks(["192.168.180.150"])
def test_without_a_trusted_proxy_the_peer_is_the_client_and_the_header_is_ignored():
assert client_ip("203.0.113.9", "1.2.3.4", []) == "203.0.113.9"
def test_a_peer_that_is_not_a_trusted_proxy_cannot_set_the_address():
assert client_ip("203.0.113.9", "1.2.3.4", PROXY) == "203.0.113.9"
def test_behind_a_trusted_proxy_the_last_entry_is_used():
assert client_ip("192.168.180.150", "9.9.9.9, 198.51.100.7", PROXY) == "198.51.100.7"
def test_entries_before_the_last_cannot_be_forged_by_the_client():
assert client_ip("192.168.180.150", "6.6.6.6, 7.7.7.7, 198.51.100.7", PROXY) == "198.51.100.7"
@pytest.mark.parametrize("header", ["", "not-an-ip", "198.51.100.7, garbage"])
def test_a_missing_or_malformed_header_falls_back_to_the_proxy_address(header):
assert client_ip("192.168.180.150", header, PROXY) == "192.168.180.150"
def test_networks_work_and_ipv6_is_handled():
nets = parse_networks(["10.0.0.0/8", "fd00::/8"])
assert client_ip("10.1.2.3", "198.51.100.7", nets) == "198.51.100.7"
assert client_ip("fd00::1", "2001:db8::5", nets) == "2001:db8::5"
def test_a_missing_peer_gives_no_address():
assert client_ip("", "1.2.3.4", PROXY) == ""
def test_x_real_ip_is_never_used():
request = RequestFactory().get(
"/", REMOTE_ADDR="192.168.180.150", HTTP_X_REAL_IP="6.6.6.6"
)
with override_settings(TRUSTED_PROXIES=["192.168.180.150"]):
middleware = ClientIpMiddleware(lambda r: r)
assert middleware(request).client_ip == "192.168.180.150"
def test_the_middleware_applies_the_rule():
request = RequestFactory().get(
"/", REMOTE_ADDR="192.168.180.150", HTTP_X_FORWARDED_FOR="6.6.6.6, 198.51.100.7"
)
with override_settings(TRUSTED_PROXIES=["192.168.180.150"]):
middleware = ClientIpMiddleware(lambda r: r)
assert middleware(request).client_ip == "198.51.100.7"
+35
View File
@@ -105,3 +105,38 @@ def test_ldap_server_must_not_be_a_url(valid_env):
def test_a_missing_ca_file_is_reported(valid_env, tmp_path): def test_a_missing_ca_file_is_reported(valid_env, tmp_path):
with pytest.raises(ConfigError, match="LDAP_CA_FILE: must be an existing file"): with pytest.raises(ConfigError, match="LDAP_CA_FILE: must be an existing file"):
load_config({**valid_env, "LDAP_CA_FILE": str(tmp_path / "missing.pem")}) load_config({**valid_env, "LDAP_CA_FILE": str(tmp_path / "missing.pem")})
@pytest.mark.parametrize(
"name", ["OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "PUBLIC_BASE_URL"]
)
def test_each_oidc_variable_is_required(valid_env, name):
del valid_env[name]
with pytest.raises(ConfigError, match=name):
load_config(valid_env)
def test_oidc_settings_are_loaded_and_the_secret_is_not_printed(valid_env):
oidc = load_config({**valid_env, "PUBLIC_BASE_URL": "https://portal.example.test/"}).oidc
assert oidc.issuer == "https://idp.example.test/application/o/portal/"
assert oidc.redirect_uri == "https://portal.example.test/auth/callback"
assert "client-secret" not in repr(oidc)
@pytest.mark.parametrize(
("name", "value"),
[
("OIDC_ISSUER", "http://idp.example.test/"),
("PUBLIC_BASE_URL", "http://portal.example.test"),
("PUBLIC_BASE_URL", "https://portal.example.test/app"),
("PUBLIC_BASE_URL", "portal.example.test"),
],
)
def test_oidc_addresses_must_be_https_origins(valid_env, name, value):
with pytest.raises(ConfigError, match=name):
load_config({**valid_env, name: value})
def test_a_plain_http_origin_is_allowed_only_in_debug(valid_env):
env = {**valid_env, "PUBLIC_BASE_URL": "http://localhost:8000"}
assert load_config({**env, "DEBUG": "true"}).oidc.public_base_url == "http://localhost:8000"
+271
View File
@@ -0,0 +1,271 @@
"""The OIDC adapter against a stand-in provider: real signed tokens, canned HTTP answers."""
import json
import time
from urllib.parse import parse_qs, urlsplit
import pytest
import requests
from joserfc import jwt
from joserfc.jwk import KeySet, OctKey, RSAKey
from portal.adapters.idp import IdpUnavailable, LoginRejected, OidcIdp
from portal.config.env import OidcConfig
ISSUER = "https://idp.example.test/application/o/portal/"
CLIENT_ID = "portal-client"
SECRET = "client-secret-value-0123456789abcdef"
BASE = "https://idp.example.test/application/o"
DISCOVERY_URL = ISSUER + ".well-known/openid-configuration"
JWKS_URL = f"{BASE}/jwks/"
TOKEN_URL = f"{BASE}/token/"
CONFIG = OidcConfig(ISSUER, CLIENT_ID, SECRET, "https://portal.example.test")
KEY = RSAKey.generate_key(2048, parameters={"kid": "k1", "use": "sig"})
OTHER_KEY = RSAKey.generate_key(2048, parameters={"kid": "k1", "use": "sig"})
ROTATED_KEY = RSAKey.generate_key(2048, parameters={"kid": "k2", "use": "sig"})
class Provider(requests.adapters.BaseAdapter):
"""Answers requests from a table of address -> (status, json) and records them."""
def __init__(self):
super().__init__()
self.calls = []
self.error = None
self.routes = {
DISCOVERY_URL: (
200,
{
"issuer": ISSUER,
"authorization_endpoint": f"{BASE}/authorize/",
"token_endpoint": TOKEN_URL,
"jwks_uri": JWKS_URL,
},
),
JWKS_URL: (200, KeySet([KEY]).as_dict(private=False)),
TOKEN_URL: (200, {"access_token": "at", "token_type": "Bearer", "id_token": ""}),
}
def send(self, request, **kwargs):
self.calls.append(request)
if self.error:
raise self.error
status, body = self.routes[request.url.split("?")[0]]
response = requests.Response()
response.status_code = status
response._content = json.dumps(body).encode()
response.headers["Content-Type"] = "application/json"
response.url = request.url
response.request = request
response.encoding = "utf-8"
return response
def close(self):
pass
def count(self, url):
return sum(1 for r in self.calls if r.url.split("?")[0] == url)
@pytest.fixture
def provider():
return Provider()
@pytest.fixture
def idp(provider):
instance = OidcIdp(CONFIG)
def factory():
session = instance._default_session()
session.mount("https://", provider)
return session
instance._factory = factory
return instance
def mint(flow, *, key=KEY, alg="RS256", header=None, **overrides):
claims = {
"iss": ISSUER,
"aud": CLIENT_ID,
"sub": "user-1",
"exp": int(time.time()) + 300,
"iat": int(time.time()),
"nonce": flow.nonce,
"preferred_username": "alice",
}
claims.update(overrides)
claims = {k: v for k, v in claims.items() if v is not None}
return jwt.encode({"alg": alg, **(header or {"kid": "k1"})}, claims, key)
def sign_in(idp, provider, flow, token, **params):
provider.routes[TOKEN_URL] = (
200,
{"access_token": "at", "token_type": "Bearer", "id_token": token},
)
return idp.complete({"state": flow.state, "code": "the-code", **params}, flow)
def test_begin_builds_a_pkce_authorization_request(idp):
url, flow = idp.begin()
parts = urlsplit(url)
query = {k: v[0] for k, v in parse_qs(parts.query).items()}
assert f"{parts.scheme}://{parts.netloc}{parts.path}" == f"{BASE}/authorize/"
assert query["response_type"] == "code"
assert query["client_id"] == CLIENT_ID
assert query["redirect_uri"] == "https://portal.example.test/auth/callback"
assert query["scope"] == "openid profile email"
assert query["code_challenge_method"] == "S256"
assert query["state"] == flow.state and query["nonce"] == flow.nonce
assert "code_challenge" in query
assert flow.code_verifier not in url # only its hash is sent
def test_each_flow_has_fresh_random_values(idp):
_, first = idp.begin()
_, second = idp.begin()
assert len({first.state, second.state, first.nonce, second.nonce}) == 4
def test_discovery_is_fetched_once(idp, provider):
idp.begin()
idp.begin()
assert provider.count(DISCOVERY_URL) == 1
def test_a_valid_sign_in_returns_the_identity(idp, provider):
_, flow = idp.begin()
identity = sign_in(idp, provider, flow, mint(flow))
assert (identity.username, identity.subject) == ("alice", "user-1")
def test_the_code_is_exchanged_with_the_verifier_and_client_authentication(idp, provider):
_, flow = idp.begin()
sign_in(idp, provider, flow, mint(flow))
request = [r for r in provider.calls if r.url == TOKEN_URL][0]
form = parse_qs(request.body)
assert form["grant_type"] == ["authorization_code"]
assert form["code"] == ["the-code"]
assert form["code_verifier"] == [flow.code_verifier]
assert form["redirect_uri"] == ["https://portal.example.test/auth/callback"]
assert request.headers["Authorization"].startswith("Basic ")
def test_an_audience_list_that_contains_the_client_is_accepted(idp, provider):
_, flow = idp.begin()
assert sign_in(idp, provider, flow, mint(flow, aud=[CLIENT_ID, "other"])).username == "alice"
@pytest.mark.parametrize(
"params", [{"state": "wrong"}, {"state": ""}, {"code": ""}, {"error": "access_denied"}]
)
def test_a_bad_callback_is_rejected_before_any_token_request(idp, provider, params):
_, flow = idp.begin()
with pytest.raises(LoginRejected):
idp.complete({"state": flow.state, "code": "c", **params}, flow)
assert provider.count(TOKEN_URL) == 0
def test_a_refused_code_is_rejected(idp, provider):
_, flow = idp.begin()
provider.routes[TOKEN_URL] = (400, {"error": "invalid_grant"})
with pytest.raises(LoginRejected):
idp.complete({"state": flow.state, "code": "c"}, flow)
def test_an_unreachable_token_endpoint_is_an_outage(idp, provider):
_, flow = idp.begin()
provider.error = requests.ConnectionError("down")
with pytest.raises(IdpUnavailable):
idp.complete({"state": flow.state, "code": "c"}, flow)
def test_a_missing_id_token_is_rejected(idp, provider):
_, flow = idp.begin()
provider.routes[TOKEN_URL] = (200, {"access_token": "at", "token_type": "Bearer"})
with pytest.raises(LoginRejected):
idp.complete({"state": flow.state, "code": "c"}, flow)
@pytest.mark.parametrize(
"overrides",
[
{"nonce": "somebody-elses"},
{"nonce": None},
{"iss": "https://evil.example.test/"},
{"aud": "another-client"},
{"exp": int(time.time()) - 3600},
{"exp": None},
{"sub": None},
{"preferred_username": None},
{"preferred_username": " "},
{"preferred_username": 42},
],
)
def test_invalid_claims_are_rejected(idp, provider, overrides):
_, flow = idp.begin()
with pytest.raises(LoginRejected):
sign_in(idp, provider, flow, mint(flow, **overrides))
def test_a_token_signed_by_another_key_is_rejected(idp, provider):
_, flow = idp.begin()
with pytest.raises(LoginRejected):
sign_in(idp, provider, flow, mint(flow, key=OTHER_KEY))
def test_a_token_signed_with_the_client_secret_is_rejected(idp, provider):
_, flow = idp.begin()
forged = mint(flow, key=OctKey.import_key(SECRET), alg="HS256", header={})
with pytest.raises(LoginRejected):
sign_in(idp, provider, flow, forged)
def test_an_unsigned_token_is_rejected(idp, provider):
_, flow = idp.begin()
unsigned = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ4In0."
with pytest.raises(LoginRejected):
sign_in(idp, provider, flow, unsigned)
def test_a_rotated_key_is_picked_up_with_one_refetch(idp, provider):
_, flow = idp.begin()
sign_in(idp, provider, flow, mint(flow)) # caches the key set with k1 only
provider.routes[JWKS_URL] = (200, KeySet([KEY, ROTATED_KEY]).as_dict(private=False))
_, flow = idp.begin()
token = mint(flow, key=ROTATED_KEY, header={"kid": "k2"})
assert sign_in(idp, provider, flow, token).username == "alice"
assert provider.count(JWKS_URL) == 2
def test_an_unknown_key_is_rejected_after_one_refetch(idp, provider):
_, flow = idp.begin()
token = mint(flow, key=ROTATED_KEY, header={"kid": "k2"})
with pytest.raises(LoginRejected):
sign_in(idp, provider, flow, token)
assert provider.count(JWKS_URL) == 2
def test_a_different_issuer_in_discovery_is_an_outage(idp, provider):
provider.routes[DISCOVERY_URL] = (
200,
{**provider.routes[DISCOVERY_URL][1], "issuer": "https://evil.example.test/"},
)
with pytest.raises(IdpUnavailable):
idp.begin()
def test_a_discovery_document_with_a_plain_http_endpoint_is_an_outage(idp, provider):
document = {**provider.routes[DISCOVERY_URL][1], "token_endpoint": "http://x.test/token"}
provider.routes[DISCOVERY_URL] = (200, document)
with pytest.raises(IdpUnavailable):
idp.begin()
def test_an_unreachable_provider_is_an_outage(idp, provider):
provider.error = requests.ConnectionError("down")
with pytest.raises(IdpUnavailable):
idp.begin()
+11 -9
View File
@@ -72,7 +72,8 @@ def test_settings_defaults():
assert settings.SESSION_SAVE_EVERY_REQUEST is True assert settings.SESSION_SAVE_EVERY_REQUEST is True
def _client_with_session(created_at): def _client_with_session(created_at, adapters):
adapters.directory.add_user("alice")
store = SessionStore() store = SessionStore()
request = RequestFactory().get("/") request = RequestFactory().get("/")
request.session = store request.session = store
@@ -84,16 +85,17 @@ def _client_with_session(created_at):
return client, store.session_key return client, store.session_key
def test_absolute_limit_removes_session_and_clears_cookie(): def test_absolute_limit_removes_session_and_clears_cookie(adapters):
assert any(m.endswith("AbsoluteSessionLimitMiddleware") for m in settings.MIDDLEWARE) assert any(m.endswith("AbsoluteSessionLimitMiddleware") for m in settings.MIDDLEWARE)
client, key = _client_with_session(time.time() - settings.SESSION_ABSOLUTE_SECONDS - 5) too_old = time.time() - settings.SESSION_ABSOLUTE_SECONDS - 5
client, key = _client_with_session(too_old, adapters)
response = client.get("/healthz") response = client.get("/healthz")
assert not UserSession.objects.filter(session_key=key).exists() assert not UserSession.objects.filter(session_key=key).exists()
assert response.cookies[settings.SESSION_COOKIE_NAME].value == "" assert response.cookies[settings.SESSION_COOKIE_NAME].value == ""
def test_session_within_limits_survives_a_request(): def test_session_within_limits_survives_a_request(adapters):
client, key = _client_with_session(time.time() - 60) client, key = _client_with_session(time.time() - 60, adapters)
client.get("/healthz") client.get("/healthz")
assert UserSession.objects.filter(session_key=key).exists() assert UserSession.objects.filter(session_key=key).exists()
@@ -104,8 +106,8 @@ def test_middleware_callable_directly():
assert current_user(request) is not None assert current_user(request) is not None
def test_request_renews_expire_date_to_one_hour_ahead(): def test_request_renews_expire_date_to_one_hour_ahead(adapters):
client, key = _client_with_session(time.time() - 60) client, key = _client_with_session(time.time() - 60, adapters)
UserSession.objects.filter(session_key=key).update( UserSession.objects.filter(session_key=key).update(
expire_date=timezone.now() + timedelta(minutes=5) expire_date=timezone.now() + timedelta(minutes=5)
) )
@@ -114,8 +116,8 @@ def test_request_renews_expire_date_to_one_hour_ahead():
assert timedelta(minutes=59) < remaining <= timedelta(hours=1) assert timedelta(minutes=59) < remaining <= timedelta(hours=1)
def test_request_with_idle_expired_session_has_no_session_and_clears_cookie(): def test_request_with_idle_expired_session_has_no_session_and_clears_cookie(adapters):
client, key = _client_with_session(time.time() - 60) client, key = _client_with_session(time.time() - 60, adapters)
UserSession.objects.filter(session_key=key).update( UserSession.objects.filter(session_key=key).update(
expire_date=timezone.now() - timedelta(seconds=1) expire_date=timezone.now() - timedelta(seconds=1)
) )
+339
View File
@@ -0,0 +1,339 @@
"""Sign-in, roles, sign-out and account-state handling through the real views (FR-1..FR-3)."""
import time
from urllib.parse import parse_qs, urlsplit
import pytest
from django.http import HttpResponse
from django.test import Client, RequestFactory
from portal.accounts import identity
from portal.accounts.decorators import admin_required, login_required, publisher_required
from portal.accounts.models import UserSession
from portal.accounts.session_store import LOGIN_FLOW, ROLES
from portal.accounts.sessions import SessionUser
from portal.adapters.idp import Flow
from portal.audit.models import AuditEntry
pytestmark = pytest.mark.django_db(transaction=True)
def sign_in(adapters, username="alice", next_path=None, client=None):
"""Run the whole flow with the fake provider; returns (client, callback response)."""
client = client or Client()
adapters.idp.sign_in_as(username)
query = f"?next={next_path}" if next_path else ""
client.get("/auth/login" + query)
flow = Flow.from_dict(client.session[LOGIN_FLOW])
return client, client.get("/auth/callback", adapters.idp.callback_params(flow))
def age_the_cache(client):
session = client.session
session[ROLES] = {**session[ROLES], "checked": time.time() - 3600}
session.save()
@pytest.fixture
def people(adapters):
d = adapters.directory
d.add_user("Alice", given_name="Alice", surname="Anders", display_name="Alice Anders")
d.add_user("pub", is_publisher=True)
d.add_user("boss", is_admin=True, is_publisher=True)
d.add_user("gone", enabled=False)
d.add_user("locked", locked=True)
d.add_user("expired", password_last_set=None)
return d
# -- reaching the sign-in -----------------------------------------------------------------
def test_an_unauthenticated_request_redirects_to_sign_in(people):
response = Client().get("/")
assert response.status_code == 302
assert response["Location"] == "/auth/login?next=%2F"
def test_sign_in_redirects_to_the_provider_and_keeps_the_flow_in_the_session(people, adapters):
client = Client()
response = client.get("/auth/login")
assert response.status_code == 302
state = parse_qs(urlsplit(response["Location"]).query)["state"][0]
assert client.session[LOGIN_FLOW]["state"] == state
def test_the_provider_being_down_shows_an_unavailable_page(people, adapters):
adapters.idp.unavailable = True
response = Client().get("/auth/login")
assert response.status_code == 503
# -- a successful sign-in -------------------------------------------------------------------
def test_a_user_signs_in_and_sees_their_own_name(people, adapters):
client, response = sign_in(adapters, "alice")
assert response.status_code == 302 and response["Location"] == "/"
page = client.get("/")
assert page.status_code == 200
assert "Alice Anders" in page.content.decode()
def test_the_session_holds_the_exact_directory_name_and_a_lowercase_key(people, adapters):
client, _ = sign_in(adapters, "ALICE")
row = UserSession.objects.get(session_key=client.session.session_key)
assert row.username == "alice"
assert client.session["session_user_sam"] == "Alice"
def test_the_requested_page_is_remembered(people, adapters):
_, response = sign_in(adapters, "alice", next_path="/auth/signed-out")
assert response["Location"] == "/auth/signed-out"
@pytest.mark.parametrize("target", ["https://evil.example.test/", "//evil.example.test", "x"])
def test_an_outside_address_cannot_be_used_as_the_destination(people, adapters, target):
_, response = sign_in(adapters, "alice", next_path=target)
assert response["Location"] == "/"
def test_sign_in_is_audited_with_the_client_address(people, adapters):
sign_in(adapters, "alice")
entry = AuditEntry.objects.get(action="signin.succeeded")
assert entry.actor == "alice" and entry.ip == "127.0.0.1"
assert "token" not in str(entry.detail).lower()
def test_signing_in_replaces_a_previous_session_key(people, adapters):
client = Client()
client.get("/auth/login")
before = client.session.session_key
sign_in(adapters, "alice", client=client)
assert client.session.session_key != before
assert not UserSession.objects.filter(session_key=before).exists()
def test_an_already_signed_in_user_is_sent_home(people, adapters):
client, _ = sign_in(adapters, "alice")
assert client.get("/auth/login")["Location"] == "/"
# -- refusals -----------------------------------------------------------------------------------
def test_unknown_disabled_and_locked_accounts_are_refused_with_the_same_page(people, adapters):
bodies = set()
for name in ("nobody", "gone", "locked"):
client, response = sign_in(adapters, name)
assert response.status_code == 403
assert UserSession.objects.exclude(username="").count() == 0
assert client.get("/").status_code == 302
bodies.add(response.content)
assert len(bodies) == 1
def test_refusals_are_audited_with_the_reason(people, adapters):
for name in ("nobody", "gone", "locked"):
sign_in(adapters, name)
refusals = AuditEntry.objects.filter(action="signin.refused")
reasons = {e.actor: e.detail["reason"] for e in refusals}
assert reasons == {"nobody": "unknown", "gone": "disabled", "locked": "locked"}
def test_the_refusal_page_does_not_name_the_account(people, adapters):
_, response = sign_in(adapters, "gone")
assert "gone" not in response.content.decode().lower()
def test_a_user_with_an_expired_password_can_sign_in(people, adapters):
client, response = sign_in(adapters, "expired")
assert response.status_code == 302
assert client.get("/").status_code == 200
def test_a_callback_without_a_started_flow_is_refused(people, adapters):
response = Client().get("/auth/callback", {"state": "x", "code": "y"})
assert response.status_code == 403
def test_a_callback_works_once(people, adapters):
adapters.idp.sign_in_as("alice")
client = Client()
client.get("/auth/login")
params = adapters.idp.callback_params(Flow.from_dict(client.session[LOGIN_FLOW]))
assert client.get("/auth/callback", params).status_code == 302
assert client.get("/auth/callback", params).status_code == 403
def test_a_wrong_state_is_refused(people, adapters):
adapters.idp.sign_in_as("alice")
client = Client()
client.get("/auth/login")
params = adapters.idp.callback_params(Flow.from_dict(client.session[LOGIN_FLOW]))
response = client.get("/auth/callback", {**params, "state": "forged"})
assert response.status_code == 403
assert client.get("/").status_code == 302
def test_a_directory_outage_blocks_new_sessions(people, adapters):
adapters.idp.sign_in_as("alice")
client = Client()
client.get("/auth/login")
params = adapters.idp.callback_params(Flow.from_dict(client.session[LOGIN_FLOW]))
adapters.directory.unavailable = True
response = client.get("/auth/callback", params)
assert response.status_code == 503
assert UserSession.objects.exclude(username="").count() == 0
# -- roles ----------------------------------------------------------------------------------------
def make_request(portal_user):
request = RequestFactory().get("/")
request.portal_user = portal_user
return request
def portal_user(publisher=False, admin=False):
return identity.PortalUser(SessionUser("u", "U"), "U", publisher, admin)
@publisher_required
def publisher_page(request):
return HttpResponse("publisher")
@admin_required
def admin_page(request):
return HttpResponse("admin")
@login_required
def any_page(request):
return HttpResponse("page")
def test_pages_need_a_signed_in_user():
for view in (any_page, publisher_page, admin_page):
response = view(make_request(None))
assert response.status_code == 302 and response["Location"].startswith("/auth/login")
def test_a_user_in_neither_group_is_refused_on_publisher_and_admin_pages():
for view in (publisher_page, admin_page):
assert view(make_request(portal_user())).status_code == 403
def test_publisher_and_admin_are_separate_roles():
assert publisher_page(make_request(portal_user(publisher=True))).status_code == 200
assert admin_page(make_request(portal_user(publisher=True))).status_code == 403
assert admin_page(make_request(portal_user(admin=True))).status_code == 200
assert publisher_page(make_request(portal_user(admin=True))).status_code == 403
def test_roles_come_from_the_directory_at_sign_in(people, adapters):
client, _ = sign_in(adapters, "boss")
assert client.session[ROLES]["publisher"] is True
assert client.session[ROLES]["admin"] is True
client, _ = sign_in(adapters, "alice")
assert client.session[ROLES]["publisher"] is False
def session_request(client):
request = RequestFactory().get("/")
request.session = client.session
request.client_ip = "127.0.0.1"
return request
def test_roles_are_not_rechecked_within_the_cache_time(people, adapters):
client, _ = sign_in(adapters, "pub")
adapters.directory.unavailable = True # would fail if the directory were asked
assert identity.current(session_request(client)).is_publisher is True
def test_a_role_change_is_seen_after_the_cache_expires(people, adapters):
client, _ = sign_in(adapters, "pub")
adapters.directory.add_user("pub", is_publisher=False)
assert identity.current(session_request(client)).is_publisher is True
age_the_cache(client)
assert identity.current(session_request(client)).is_publisher is False
def test_a_directory_outage_denies_elevated_roles_but_keeps_the_session(people, adapters):
client, _ = sign_in(adapters, "boss")
age_the_cache(client)
adapters.directory.unavailable = True
user = identity.current(session_request(client))
assert user is not None and not user.is_admin and not user.is_publisher
assert client.get("/").status_code == 200
# -- account state after sign-in -----------------------------------------------------------
@pytest.mark.parametrize("change", ["disabled", "locked", "deleted"])
def test_a_session_ends_at_the_next_request_when_the_account_becomes_unavailable(
people, adapters, change
):
first, _ = sign_in(adapters, "alice")
second, _ = sign_in(adapters, "alice")
other, _ = sign_in(adapters, "pub")
if change == "deleted":
del adapters.directory._users["alice"]
else:
flag = {"disabled": {"enabled": False}, "locked": {"locked": True}}[change]
adapters.directory.add_user("Alice", **flag)
age_the_cache(first)
response = first.get("/")
assert response.status_code == 302 and response["Location"].startswith("/auth/login")
assert UserSession.objects.filter(username="alice").count() == 0 # every session of the user
assert second.get("/").status_code == 302
assert other.get("/").status_code == 200 # other users are untouched
entry = AuditEntry.objects.get(action="session.ended")
assert entry.actor == "alice" and entry.detail == {"reason": "account_unavailable"}
# -- sign-out ---------------------------------------------------------------------------------
def test_sign_out_ends_the_session_on_the_server(people, adapters):
client, _ = sign_in(adapters, "alice")
key = client.session.session_key
response = client.post("/auth/logout")
assert response["Location"] == "/auth/signed-out"
assert not UserSession.objects.filter(session_key=key).exists()
assert client.get("/").status_code == 302
assert AuditEntry.objects.filter(action="signout", actor="alice").count() == 1
def test_a_replayed_session_cookie_does_not_work_after_sign_out(people, adapters):
client, _ = sign_in(adapters, "alice")
stolen = client.cookies["sessionid"].value
client.post("/auth/logout")
replay = Client()
replay.cookies["sessionid"] = stolen
assert replay.get("/").status_code == 302
def test_sign_out_only_ends_this_session(people, adapters):
first, _ = sign_in(adapters, "alice")
second, _ = sign_in(adapters, "alice")
first.post("/auth/logout")
assert second.get("/").status_code == 200
def test_sign_out_needs_post_and_a_csrf_token(people, adapters):
client, _ = sign_in(adapters, "alice")
assert client.get("/auth/logout").status_code == 405
strict = Client(enforce_csrf_checks=True)
strict.cookies = client.cookies
assert strict.post("/auth/logout").status_code == 403
assert client.get("/").status_code == 200
def test_the_signed_out_page_is_public(people):
assert Client().get("/auth/signed-out").status_code == 200
+6
View File
@@ -10,6 +10,8 @@ dependencies = [
"whitenoise>=6.9", "whitenoise>=6.9",
"tzdata", "tzdata",
"python-ldap>=3.4.4", "python-ldap>=3.4.4",
"authlib>=1.7,<1.8",
"requests>=2.32",
] ]
[dependency-groups] [dependency-groups]
@@ -37,6 +39,10 @@ env = [
"LDAP_CA_FILE=pyproject.toml", "LDAP_CA_FILE=pyproject.toml",
"LDAP_PUBLISHER_GROUP_DN=CN=Publishers,OU=Users,DC=example,DC=test", "LDAP_PUBLISHER_GROUP_DN=CN=Publishers,OU=Users,DC=example,DC=test",
"LDAP_ADMIN_GROUP_DN=CN=Admins,OU=Users,DC=example,DC=test", "LDAP_ADMIN_GROUP_DN=CN=Admins,OU=Users,DC=example,DC=test",
"OIDC_ISSUER=https://idp.example.test/application/o/portal/",
"OIDC_CLIENT_ID=portal-client",
"OIDC_CLIENT_SECRET=test-only-client-secret",
"PUBLIC_BASE_URL=https://portal.example.test",
] ]
[tool.ruff] [tool.ruff]
Generated
+201
View File
@@ -11,6 +11,99 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" },
] ]
[[package]]
name = "authlib"
version = "1.7.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "joserfc" },
]
sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.5.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" },
{ url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" },
{ url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" },
{ url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" },
{ url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" },
{ url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" },
{ url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" },
{ url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" },
{ url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" },
{ url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" },
{ url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" },
{ url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" },
{ url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" },
{ url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" },
{ url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" },
{ url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" },
{ url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" },
{ url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" },
{ url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" },
{ url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" },
{ url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" },
{ url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" },
{ url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" },
{ url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" },
{ url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" },
{ url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" },
{ url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" },
{ url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" },
{ url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" },
{ url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" },
{ url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" },
{ url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" },
{ url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" },
{ url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" },
]
[[package]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.6" version = "0.4.6"
@@ -20,6 +113,56 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
] ]
[[package]]
name = "cryptography"
version = "50.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" },
{ url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" },
{ url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" },
{ url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" },
{ url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" },
{ url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" },
{ url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" },
{ url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" },
{ url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" },
{ url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" },
{ url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" },
{ url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" },
{ url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" },
{ url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" },
{ url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" },
{ url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" },
{ url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" },
{ url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" },
{ url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" },
{ url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" },
{ url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" },
{ url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" },
{ url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" },
{ url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" },
{ url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" },
{ url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" },
{ url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" },
{ url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" },
{ url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" },
{ url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" },
{ url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" },
{ url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" },
]
[[package]] [[package]]
name = "django" name = "django"
version = "5.2.18" version = "5.2.18"
@@ -43,6 +186,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389, upload-time = "2026-08-24T15:05:57.67Z" }, { url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389, upload-time = "2026-08-24T15:05:57.67Z" },
] ]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.3.1" version = "2.3.1"
@@ -52,6 +204,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
] ]
[[package]]
name = "joserfc"
version = "1.7.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/94/80fea1514b7c6d7d37804d3fe9ca81455f633347fc98731bd71ffe1faa17/joserfc-1.7.5.tar.gz", hash = "sha256:d5ff536e658e17664f8c1b1ab60dc4aa62aa973fcef1edd33cc44bda45d6f5ea", size = 234990, upload-time = "2026-08-29T13:05:42.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/c5/82addfd375e5ee6520644e0553e4aadde92d668c4fc99cc716d337fe7bb3/joserfc-1.7.5-py3-none-any.whl", hash = "sha256:add2c2c84e8373b084d526a8b53daba5d7a513a118cd2dcd9fc9f979d0922159", size = 71269, upload-time = "2026-08-29T13:05:40.718Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.3" version = "26.3"
@@ -91,6 +255,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
] ]
[[package]]
name = "pycparser"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" },
]
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.21.0" version = "2.21.0"
@@ -160,6 +333,21 @@ dependencies = [
] ]
sdist = { url = "https://files.pythonhosted.org/packages/1c/45/489fcf46984f916ef165cdc6a2a1f1ca01ab6f486d47169256ebe44bdd62/python_ldap-3.4.8.tar.gz", hash = "sha256:18dc7460470c6ff64ed5c04ee21c56dbfee7ab433a53213ba91e407eea44c34c", size = 387710, upload-time = "2026-09-16T12:41:20.15Z" } sdist = { url = "https://files.pythonhosted.org/packages/1c/45/489fcf46984f916ef165cdc6a2a1f1ca01ab6f486d47169256ebe44bdd62/python_ldap-3.4.8.tar.gz", hash = "sha256:18dc7460470c6ff64ed5c04ee21c56dbfee7ab433a53213ba91e407eea44c34c", size = 387710, upload-time = "2026-09-16T12:41:20.15Z" }
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.17.0" version = "0.17.0"
@@ -190,9 +378,11 @@ name = "self-service-portal"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "authlib" },
{ name = "django" }, { name = "django" },
{ name = "gunicorn" }, { name = "gunicorn" },
{ name = "python-ldap" }, { name = "python-ldap" },
{ name = "requests" },
{ name = "tzdata" }, { name = "tzdata" },
{ name = "whitenoise" }, { name = "whitenoise" },
] ]
@@ -207,9 +397,11 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "authlib", specifier = ">=1.7,<1.8" },
{ name = "django", specifier = ">=5.2,<5.3" }, { name = "django", specifier = ">=5.2,<5.3" },
{ name = "gunicorn", specifier = ">=23.0" }, { name = "gunicorn", specifier = ">=23.0" },
{ name = "python-ldap", specifier = ">=3.4.4" }, { name = "python-ldap", specifier = ">=3.4.4" },
{ name = "requests", specifier = ">=2.32" },
{ name = "tzdata" }, { name = "tzdata" },
{ name = "whitenoise", specifier = ">=6.9" }, { name = "whitenoise", specifier = ">=6.9" },
] ]
@@ -240,6 +432,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" },
] ]
[[package]]
name = "urllib3"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
]
[[package]] [[package]]
name = "whitenoise" name = "whitenoise"
version = "6.12.0" version = "6.12.0"