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