fix: restore native user portal login and track tenant integration
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
5293a9bea3
commit
6cd89f0a7a
7 changed files with 397 additions and 0 deletions
|
|
@ -113,6 +113,18 @@ clients:
|
|||
allowedScopes: ["openid", "profile", "email", "groups"]
|
||||
grantTypes: ["authorization_code"]
|
||||
clientType: "public"
|
||||
- clientId: user-engine-portal
|
||||
displayName: User Engine Portal
|
||||
redirectUris:
|
||||
- https://users.92-205-62-239.nip.io/oidc/callback
|
||||
allowedScopes:
|
||||
- openid
|
||||
- profile
|
||||
- email
|
||||
- groups
|
||||
grantTypes:
|
||||
- authorization_code
|
||||
clientType: public
|
||||
- clientId: "netkingdom-bootstrap-console"
|
||||
displayName: "NetKingdom Bootstrap Console"
|
||||
redirectUris:
|
||||
|
|
|
|||
136
sso-mfa/k8s/keycape/portal-client-rollout.py
Normal file
136
sso-mfa/k8s/keycape/portal-client-rollout.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Restore the approved public portal client without exporting Secret values."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path('/home/worsch/net-kingdom/sso-mfa/k8s/keycape')
|
||||
|
||||
|
||||
def module(name, filename):
|
||||
spec = importlib.util.spec_from_file_location(name, ROOT / filename)
|
||||
result = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(result)
|
||||
return result
|
||||
|
||||
|
||||
pin = module('issuer_pin', 'openbao-client-config.py')
|
||||
portal = module('portal_registration', 'register-user-engine-portal.py')
|
||||
CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786'
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
"""Only fixed reason codes may leave the operation."""
|
||||
|
||||
|
||||
def require(condition, reason):
|
||||
if not condition:
|
||||
raise Refused(reason)
|
||||
|
||||
|
||||
def replacement(secret):
|
||||
raw, config, _ = pin.issuer_document(secret)
|
||||
clients = config.get('clients')
|
||||
require(isinstance(clients, list) and clients, 'client_sequence_required')
|
||||
ids = [client['clientId'] for client in clients]
|
||||
require(len(ids) == len(set(ids)), 'duplicate_client_id')
|
||||
if portal.CLIENT_ID in ids:
|
||||
require(clients[ids.index(portal.CLIENT_ID)] == portal.CLIENT,
|
||||
'existing_registration_differs_requires_reconciliation')
|
||||
return secret['data']['config.yaml'], False
|
||||
root = yaml.compose(raw)
|
||||
node = next(value for key, value in root.value if key.value == 'clients')
|
||||
require(isinstance(node, yaml.nodes.SequenceNode) and not node.flow_style,
|
||||
'block_client_sequence_required')
|
||||
index = node.end_mark.index
|
||||
line_start = raw.rfind('\n', 0, index) + 1
|
||||
if not raw[line_start:index].strip():
|
||||
index = line_start
|
||||
indent = ' ' * node.start_mark.column
|
||||
addition = yaml.safe_dump([portal.CLIENT], sort_keys=False)
|
||||
addition = ''.join(indent + line if line.strip() else line
|
||||
for line in addition.splitlines(True))
|
||||
if index and raw[index - 1] != '\n':
|
||||
addition = '\n' + addition
|
||||
updated = raw[:index] + addition + raw[index:]
|
||||
expected = copy.deepcopy(config)
|
||||
expected['clients'].append(portal.CLIENT)
|
||||
require(yaml.load(updated, Loader=pin.UniqueLoader) == expected,
|
||||
'unrelated_configuration_changed')
|
||||
return base64.b64encode(updated.encode()).decode(), True
|
||||
|
||||
|
||||
def kubectl(arguments, payload=None):
|
||||
args = ['kubectl', '--kubeconfig', '/home/worsch/.kube/config-hosteurope',
|
||||
'--server', 'https://127.0.0.1:16444', '--request-timeout=20s', *arguments]
|
||||
result = subprocess.run(args, input=None if payload is None else json.dumps(payload),
|
||||
text=True, capture_output=True, timeout=30)
|
||||
require(result.returncode == 0, 'kubernetes_operation_failed')
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def run(args):
|
||||
cluster = kubectl(['get', 'namespace', 'kube-system', '-o', 'json'])
|
||||
require(cluster['metadata']['uid'] == CLUSTER_UID, 'cluster_identity_changed')
|
||||
before = kubectl(['-n', 'sso', 'get', 'secret', 'keycape-config', '-o', 'json'])
|
||||
require(not before['metadata'].get('ownerReferences'), 'controller_owned_secret')
|
||||
metadata = pin.safe_metadata(before)
|
||||
encoded, changed = replacement(before)
|
||||
receipt = {'target': 'sso/keycape-config', 'before': metadata,
|
||||
'client': portal.CLIENT, 'change_needed': changed,
|
||||
'mode': args.mode, 'values_emitted': False, 'changed': False}
|
||||
if args.mode == 'inspect':
|
||||
return receipt
|
||||
require(metadata == {'uid': args.expected_uid,
|
||||
'resource_version': args.expected_resource_version},
|
||||
'observed_revision_changed')
|
||||
if changed:
|
||||
patch = [
|
||||
{'op': 'test', 'path': '/metadata/uid', 'value': metadata['uid']},
|
||||
{'op': 'test', 'path': '/metadata/resourceVersion', 'value': metadata['resource_version']},
|
||||
{'op': 'replace', 'path': '/data/config.yaml', 'value': encoded},
|
||||
]
|
||||
command = ['-n', 'sso', 'patch', 'secret', 'keycape-config', '--type=json',
|
||||
'--patch-file=/dev/stdin', '-o', 'json']
|
||||
if args.mode == 'dry-run':
|
||||
command += ['--dry-run=server']
|
||||
result = kubectl(command, patch)
|
||||
require(result['data'] == dict(before['data'], **{'config.yaml': encoded}),
|
||||
'patch_response_mismatch')
|
||||
if args.mode == 'apply':
|
||||
after = kubectl(['-n', 'sso', 'get', 'secret', 'keycape-config', '-o', 'json'])
|
||||
require(after['metadata']['uid'] == metadata['uid'] and
|
||||
after['data'] == dict(before['data'], **{'config.yaml': encoded}),
|
||||
'readback_mismatch_stop_without_stale_replay')
|
||||
receipt.update(changed=changed, after=pin.safe_metadata(after))
|
||||
receipt.update(unrelated_config_bytes_preserved=True, other_secret_data_unchanged=True)
|
||||
return receipt
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('mode', choices=['inspect', 'dry-run', 'apply'])
|
||||
parser.add_argument('--expected-uid')
|
||||
parser.add_argument('--expected-resource-version')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
print(json.dumps(run(args), sort_keys=True))
|
||||
return 0
|
||||
except Refused as error:
|
||||
print(json.dumps({'status': 'refused', 'reason': str(error)}))
|
||||
except Exception:
|
||||
# Parser and Kubernetes errors can contain full Secret material.
|
||||
print(json.dumps({'status': 'failed', 'reason': 'contained_operation_failed'}))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
54
sso-mfa/k8s/keycape/test_portal_client_rollout.py
Normal file
54
sso-mfa/k8s/keycape/test_portal_client_rollout.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
import yaml
|
||||
|
||||
spec = importlib.util.spec_from_file_location('portal_rollout', Path(__file__).with_name('portal-client-rollout.py'))
|
||||
rollout = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(rollout)
|
||||
|
||||
|
||||
class PortalClientTests(unittest.TestCase):
|
||||
def fixture(self, clients=None):
|
||||
# Synthetic marker values only; no live Secret is loaded by this suite.
|
||||
raw = ('# preserve this comment\nauthelia:\n issuer: https://auth.example.invalid\n'
|
||||
' clientSecret: synthetic-marker\nclients:\n' +
|
||||
yaml.safe_dump(json.loads(json.dumps(clients or [{'clientId': 'existing', 'clientType': 'public'}])), sort_keys=False) +
|
||||
'tokenLifetime: 15m\n')
|
||||
return {'data': {'config.yaml': base64.b64encode(raw.encode()).decode(), 'key.pem': 'synthetic-key'}}, raw
|
||||
|
||||
def test_add_preserves_other_values_and_original_bytes(self):
|
||||
secret, raw = self.fixture()
|
||||
encoded, changed = rollout.replacement(secret)
|
||||
updated = base64.b64decode(encoded).decode()
|
||||
self.assertTrue(changed)
|
||||
self.assertTrue(updated.startswith(raw[:raw.index('tokenLifetime:')]))
|
||||
self.assertTrue(updated.endswith('tokenLifetime: 15m\n'))
|
||||
expected = yaml.safe_load(raw)
|
||||
expected['clients'].append(rollout.portal.CLIENT)
|
||||
self.assertEqual(yaml.safe_load(updated), expected)
|
||||
self.assertEqual(secret['data']['key.pem'], 'synthetic-key')
|
||||
|
||||
def test_exact_existing_registration_is_noop(self):
|
||||
secret, _ = self.fixture([rollout.portal.CLIENT])
|
||||
self.assertEqual(rollout.replacement(secret), (secret['data']['config.yaml'], False))
|
||||
|
||||
def test_differing_or_duplicate_registration_refused(self):
|
||||
for clients in [[dict(rollout.portal.CLIENT, redirectUris=['https://other.invalid/callback'])],
|
||||
[rollout.portal.CLIENT, rollout.portal.CLIENT]]:
|
||||
with self.subTest(clients=len(clients)), self.assertRaises(rollout.Refused):
|
||||
rollout.replacement(self.fixture(clients)[0])
|
||||
|
||||
def test_bootstrap_retains_same_public_registration(self):
|
||||
source = Path(__file__).with_name('create-secrets.sh').read_text()
|
||||
raw = source.split('CONFIG_YAML=$(cat <<EOF\n', 1)[1].split('\nEOF', 1)[0]
|
||||
clients = yaml.safe_load(raw)['clients']
|
||||
self.assertEqual([c for c in clients if c['clientId'] == rollout.portal.CLIENT_ID],
|
||||
[rollout.portal.CLIENT])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
56
sso-mfa/k8s/keycape/verify-user-engine-portal.py
Normal file
56
sso-mfa/k8s/keycape/verify-user-engine-portal.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read-only native authorize checks; print no state, cookies or auth tokens."""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, build_opener
|
||||
|
||||
|
||||
class NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
def verify():
|
||||
opener = build_opener(NoRedirect)
|
||||
base = {
|
||||
"client_id": "user-engine-portal", "response_type": "code",
|
||||
"redirect_uri": "https://users.92-205-62-239.nip.io/oidc/callback",
|
||||
"scope": "openid profile email groups", "state": secrets.token_urlsafe(24),
|
||||
"code_challenge": base64.urlsafe_b64encode(
|
||||
hashlib.sha256(secrets.token_bytes(32)).digest()
|
||||
).rstrip(b"=").decode(), "code_challenge_method": "S256",
|
||||
}
|
||||
results = []
|
||||
for name in ("valid", "wrong_callback", "missing_pkce"):
|
||||
params = dict(base)
|
||||
if name == "wrong_callback":
|
||||
params["redirect_uri"] = "https://invalid.example/oidc/callback"
|
||||
if name == "missing_pkce":
|
||||
params.pop("code_challenge")
|
||||
params.pop("code_challenge_method")
|
||||
try:
|
||||
response = opener.open("https://kc.coulomb.social/authorize?" + urlencode(params), timeout=20)
|
||||
except HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
row = {"check": name, "status": response.code}
|
||||
if name == "valid":
|
||||
location = urlsplit(response.headers.get("Location", ""))
|
||||
row["redirect_origin"] = location.scheme + "://" + location.netloc
|
||||
row["passed"] = response.code == 302 and row["redirect_origin"] == "https://auth.coulomb.social"
|
||||
else:
|
||||
body = json.loads(response.read(4096))
|
||||
row.update(error=body.get("error"), feature=body.get("feature"))
|
||||
expected = "redirect_uri" if name == "wrong_callback" else "missing_pkce"
|
||||
row["passed"] = response.code == 400 and row["error"] == "invalid_profile_usage" and row["feature"] == expected
|
||||
results.append(row)
|
||||
print(json.dumps(results, indent=2))
|
||||
return all(row["passed"] for row in results)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(0 if verify() else 1)
|
||||
Loading…
Add table
Add a link
Reference in a new issue