Bind password setup grants to approved company welcome pages
Some checks are pending
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / container-smoke (push) Waiting to run

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-12 02:43:31 +02:00
parent c8ad7a85ea
commit 48a75b1a54
7 changed files with 267 additions and 8 deletions

View file

@ -12,13 +12,14 @@ import threading
import time
from typing import Callable
from urllib.request import Request, urlopen
from urllib.parse import urlencode
from urllib.parse import urlencode, urlsplit
@dataclass(frozen=True)
class SetupGrant:
subject: str
expires_at: float
return_to: str = ""
class PasswordSetupGrants:
@ -34,9 +35,18 @@ class PasswordSetupGrants:
*,
public_url: str,
setter: Callable[[str, str], None],
tenant_returns: dict[str, str] | None = None,
ttl_seconds: int = 900,
clock: Callable[[], float] = time.monotonic,
) -> None:
self.tenant_returns = dict(tenant_returns or {})
for tenant, target in self.tenant_returns.items():
parts = urlsplit(target)
if (not tenant.startswith("tenant:") or parts.scheme != "https"
or not parts.hostname or parts.username or parts.password
or parts.query or parts.fragment or not parts.path.startswith("/")
or parts.hostname in {"localhost", "127.0.0.1"}):
raise ValueError("tenant welcome targets require exact HTTPS URLs")
self.public_url = public_url.rstrip("/")
self.setter = setter
self.ttl_seconds = ttl_seconds
@ -45,7 +55,7 @@ class PasswordSetupGrants:
self._subjects: dict[str, str] = {}
self._lock = threading.Lock()
def issue(self, subject: str) -> str:
def issue(self, subject: str, tenant: str = "") -> str:
if not subject or len(subject) > 255:
raise ValueError("valid external subject is required")
token = secrets.token_urlsafe(32)
@ -56,6 +66,7 @@ class PasswordSetupGrants:
self._grants.pop(previous, None)
self._grants[digest] = SetupGrant(
subject=subject,
return_to=self.tenant_returns.get(tenant, ""),
expires_at=self.clock() + self.ttl_seconds,
)
self._subjects[subject] = digest
@ -67,7 +78,7 @@ class PasswordSetupGrants:
grant = self._grants.get(digest)
return grant is not None and grant.expires_at > self.clock()
def consume(self, token: str, password: str) -> None:
def consume(self, token: str, password: str) -> str:
if len(password) < 12:
raise ValueError("password must contain at least 12 characters")
digest = _digest(token)
@ -77,6 +88,7 @@ class PasswordSetupGrants:
raise ValueError("password setup link is invalid or expired")
self._subjects.pop(grant.subject, None)
self.setter(grant.subject, password)
return grant.return_to
class LLDAPPasswordSetter: