Fix plus-address sign-in and finish NK-WP-0041
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 11s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e31f-2bcc-7051-a050-70d8cb2dfa49
This commit is contained in:
tegwick 2026-09-27 16:05:03 +02:00
parent 6700d8f995
commit 2f1e6c3369
7 changed files with 306 additions and 17 deletions

View file

@ -0,0 +1,214 @@
"""Integration regression for NK-WP-0041; needs requests, PyYAML, cryptography."""
import argparse
import base64
import json
import pathlib
import socket
import subprocess
import tempfile
import time
import urllib.parse
import requests
import yaml
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
def decode(value):
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
parser = argparse.ArgumentParser(
description="Exercise real Authelia ID tokens with and without the KeyCape claims policy; scratch data only."
)
parser.add_argument("--authelia-bin", required=True)
binary = str(pathlib.Path(parser.parse_args().authelia_bin).resolve())
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pem = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
).decode()
source = yaml.safe_load(
yaml.safe_load(
(pathlib.Path(__file__).resolve().parents[1] / "configmap.yaml").read_text()
)["data"]["configuration.yml"]
)["identity_providers"]["oidc"]
for enabled in (False, True):
with tempfile.TemporaryDirectory(prefix="nk-claims-") as tmp:
p = pathlib.Path(tmp)
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
password = "scratch-password-only"
digest = (
subprocess.check_output(
[
binary,
"crypto",
"hash",
"generate",
"argon2",
"--password",
password,
],
text=True,
)
.strip()
.split("Digest: ")[1]
)
(p / "users.yml").write_text(
yaml.safe_dump(
{
"users": {
"nk-probe": {
"displayname": "Scratch User",
"password": digest,
"email": "nk-probe+x@example.com",
"groups": [],
}
}
}
)
)
client = dict(source["clients"][0])
client.update(
secret="scratch-client-secret-only",
redirect_uris=["https://client.example.com/callback"],
)
if not enabled:
client.pop("claims_policy")
oidc = {
"hmac_secret": "h" * 64,
"jwks": [{"key": pem, "algorithm": "RS256", "use": "sig"}],
"clients": [client],
}
if enabled:
oidc["claims_policies"] = source["claims_policies"]
cfg = {
"ntp": {"disable_startup_check": True},
"server": {"address": f"tcp://127.0.0.1:{port}/"},
"log": {"level": "info"},
"authentication_backend": {"file": {"path": str(p / "users.yml")}},
"session": {
"secret": "s" * 64,
"cookies": [
{
"domain": "example.com",
"authelia_url": "https://auth.example.com",
}
],
},
"storage": {
"encryption_key": "e" * 64,
"local": {"path": str(p / "db.sqlite3")},
},
"notifier": {"filesystem": {"filename": str(p / "notifications")}},
"access_control": {"default_policy": "one_factor"},
"identity_validation": {"reset_password": {"jwt_secret": "j" * 64}},
"identity_providers": {"oidc": oidc},
}
(p / "config.yml").write_text(yaml.safe_dump(cfg))
with (p / "log").open("w") as log:
proc = subprocess.Popen(
[binary, "--config", str(p / "config.yml")], stdout=log, stderr=log
)
try:
session = requests.Session()
session.trust_env = False
base = f"http://127.0.0.1:{port}"
session.headers.update(
{
"Host": "auth.example.com",
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "auth.example.com",
}
)
for _ in range(100):
if proc.poll() is not None:
raise RuntimeError((p / "log").read_text())
try:
if (
session.get(base + "/api/health", timeout=1).status_code
== 200
):
break
except requests.ConnectionError:
pass
time.sleep(0.1)
else:
raise RuntimeError("Scratch Authelia did not become healthy")
r = session.post(
base + "/api/firstfactor",
json={
"username": "nk-probe",
"password": password,
"keepMeLoggedIn": False,
},
timeout=5,
)
assert r.status_code == 200, (r.status_code, r.text)
session.headers["Cookie"] = "; ".join(
c.name + "=" + c.value for c in session.cookies
)
r = session.get(
base + "/api/oidc/authorization",
params={
"client_id": "keycape",
"redirect_uri": "https://client.example.com/callback",
"response_type": "code",
"scope": "openid profile email groups",
"state": "scratch-state-long-enough",
"nonce": "scratch-nonce-long-enough",
},
allow_redirects=False,
timeout=5,
)
loc = r.headers.get("Location", "")
query = urllib.parse.parse_qs(urllib.parse.urlparse(loc).query)
assert "code" in query, (
r.status_code,
loc,
r.text,
(p / "log").read_text(),
)
r = session.post(
base + "/api/oidc/token",
auth=("keycape", "scratch-client-secret-only"),
data={
"grant_type": "authorization_code",
"code": query["code"][0],
"redirect_uri": "https://client.example.com/callback",
},
timeout=5,
)
assert r.status_code == 200, (r.status_code, r.text)
parts = r.json()["id_token"].split(".")
key.public_key().verify(
decode(parts[2]),
(".".join(parts[:2])).encode(),
padding.PKCS1v15(),
hashes.SHA256(),
)
claims = json.loads(decode(parts[1]))
assert claims["iss"] == "https://auth.example.com"
assert "keycape" in claims["aud"]
assert claims["nonce"] == "scratch-nonce-long-enough"
assert claims["exp"] > time.time()
assert (claims.get("preferred_username") == "nk-probe") == enabled
assert claims["sub"] != "nk-probe"
print(
json.dumps(
{
"claims_policy": enabled,
"signed_id_token_verified": True,
"preferred_username_present": "preferred_username"
in claims,
"subject_is_directory_username": False,
}
)
)
finally:
proc.terminate()
proc.wait(timeout=10)