Fix plus-address sign-in and finish NK-WP-0041
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e31f-2bcc-7051-a050-70d8cb2dfa49
This commit is contained in:
parent
6700d8f995
commit
2f1e6c3369
7 changed files with 306 additions and 17 deletions
|
|
@ -117,3 +117,32 @@ kubectl run -n sso --rm -it auth-test --image=busybox --restart=Never \
|
|||
# OIDC discovery (should return issuer + endpoints)
|
||||
curl -s https://auth.coulomb.social/.well-known/openid-configuration | jq .
|
||||
```
|
||||
|
||||
## 4.39 compatibility and regression check
|
||||
|
||||
KeyCape needs `preferred_username` in the signed upstream ID token for its
|
||||
directory and MFA lookup. Authelia 4.39 requires the explicit `keycape`
|
||||
claims policy in `configmap.yaml`; deploy it together with the pinned image.
|
||||
KeyCape's in-cluster token requests must also carry the public HTTPS forwarded
|
||||
scheme and host (KeyCape `3b0446e` or later). Neither a health check nor an
|
||||
authorization redirect exercises these requirements.
|
||||
|
||||
Run the isolated token regression with an Authelia 4.39.28 binary and Python
|
||||
`requests`, `PyYAML`, and `cryptography` installed:
|
||||
|
||||
```bash
|
||||
python3 sso-mfa/k8s/authelia/tests/probe_claims.py --authelia-bin /path/to/authelia
|
||||
```
|
||||
|
||||
It uses disposable local users, keys and SQLite databases, checks real signed
|
||||
authorization-code tokens with and without the policy, and terminates the
|
||||
scratch processes. It uses a file backend, so it does not prove LDAP email
|
||||
lookup. Live acceptance also requires a fresh plus-addressed email sign-in
|
||||
through KeyCape to both Vergabe and the account portal, with successful
|
||||
callback and token issuance evidence.
|
||||
|
||||
Before a 4.38 → 4.39 rollout, stop Authelia and preserve the SQLite database
|
||||
and any sidecar files. Keep the previous ConfigMap and image reference too.
|
||||
Rollback requires stopping 4.39 and restoring all three: database, ConfigMap,
|
||||
and image. The migrated database and 4.39 claims policy cannot be used by
|
||||
4.38. See `NK-WP-0041` for the exercised rollout and backup receipt.
|
||||
|
|
|
|||
|
|
@ -121,10 +121,17 @@ data:
|
|||
# KeyCape is the only registered client.
|
||||
identity_providers:
|
||||
oidc:
|
||||
# Authelia 4.39 no longer includes profile claims in ID tokens by
|
||||
# default. KeyCape uses this verified claim for directory/MFA lookup;
|
||||
# its opaque OIDC subject is not a directory username (NK-WP-0041).
|
||||
claims_policies:
|
||||
keycape:
|
||||
id_token: [preferred_username]
|
||||
# hmac_secret: injected via AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE
|
||||
# issuer_private_key: injected via AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE
|
||||
clients:
|
||||
- id: keycape
|
||||
claims_policy: keycape
|
||||
description: "KeyCape IAM Orchestration Layer"
|
||||
# OIDC clients are a list, so Authelia's *_FILE environment
|
||||
# mechanism cannot override this field. The template filter reads
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ spec:
|
|||
containers:
|
||||
- name: authelia
|
||||
# Pin to a specific 4.x release. Check https://hub.docker.com/r/authelia/authelia
|
||||
image: docker.io/authelia/authelia@sha256:46021dc20efdcc5cdc38a29e3050b8835429a155ae6215388ed3b793a02eb0ab # 4.38 (rolled back from 4.39.28, NK-WP-0041-T02)
|
||||
# Requires the KeyCape claims policy in configmap.yaml (NK-WP-0041).
|
||||
image: docker.io/authelia/authelia@sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54 # 4.39.28
|
||||
imagePullPolicy: IfNotPresent
|
||||
|
||||
ports:
|
||||
|
|
|
|||
214
sso-mfa/k8s/authelia/tests/probe_claims.py
Normal file
214
sso-mfa/k8s/authelia/tests/probe_claims.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue