153 lines
7.2 KiB
Python
153 lines
7.2 KiB
Python
"""Exercise the built server in the runner's private network with synthetic input."""
|
|
import base64
|
|
import hashlib
|
|
import http.client
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import secrets
|
|
import socket
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import urllib.parse
|
|
|
|
workspace = Path('/work')
|
|
binary = workspace / 'keycape-candidate'
|
|
assert binary.is_file()
|
|
private = Path(tempfile.mkdtemp(prefix='token-status-private-'))
|
|
key = private / 'issuer.pem'
|
|
with key.open('xb') as output:
|
|
os.chmod(key, 0o600)
|
|
subprocess.run(['openssl', 'genpkey', '-algorithm', 'RSA', '-pkeyopt', 'rsa_keygen_bits:2048'],
|
|
stdout=output, stderr=subprocess.DEVNULL, check=True)
|
|
secret = secrets.token_urlsafe(24)
|
|
with socket.socket() as listener:
|
|
listener.bind(('127.0.0.1', 0))
|
|
port = listener.getsockname()[1]
|
|
cfg = {'issuer': 'https://issuer.fixture.invalid', 'port': port, 'tokenLifetime': '15m',
|
|
'privateKeyPem': str(key), 'environment': 'dev', 'tokenStatus': {'enabled': True, 'maxEntries': 2},
|
|
'clients': [{'clientId': 'fixture-client', 'clientType': 'confidential',
|
|
'grantTypes': ['client_credentials'], 'allowedScopes': ['fixture.read'],
|
|
'secretRef': 'env:FIXTURE_ONLY_CLIENT_SECRET', 'serviceSubject': 'service:fixture',
|
|
'tenant': 'tenant:fixture', 'audience': 'resource:fixture', 'tokenLifetime': '1m'}]}
|
|
config = private / 'config.json'
|
|
config.write_text(json.dumps(cfg))
|
|
os.chmod(config, 0o600)
|
|
auth = 'Basic ' + base64.b64encode(('fixture-client:' + urllib.parse.quote_plus(secret)).encode()).decode()
|
|
tokens, checks, logs, processes = [], [], [], []
|
|
|
|
def request(path, form=None, authenticated=True):
|
|
conn = http.client.HTTPConnection('127.0.0.1', port, timeout=3)
|
|
headers = {}
|
|
if authenticated:
|
|
headers['Authorization'] = auth
|
|
body = None if form is None else urllib.parse.urlencode(form)
|
|
if body is not None:
|
|
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
try:
|
|
conn.request('GET' if form is None else 'POST', path, body, headers)
|
|
response = conn.getresponse()
|
|
return response.status, dict(response.getheaders()), response.read(65537)
|
|
finally:
|
|
conn.close()
|
|
|
|
def check(label, condition):
|
|
checks.append({'check': label, 'passed': bool(condition)})
|
|
if not condition:
|
|
raise AssertionError(label)
|
|
|
|
def start(label):
|
|
log = private / (label + '.log')
|
|
output = log.open('xb')
|
|
process = subprocess.Popen([str(binary), '--config', str(config)], stdout=output, stderr=subprocess.STDOUT,
|
|
env={**os.environ, 'FIXTURE_ONLY_CLIENT_SECRET': secret})
|
|
output.close()
|
|
logs.append(log)
|
|
processes.append(process)
|
|
for _ in range(100):
|
|
if process.poll() is not None:
|
|
raise AssertionError('fixture process exited before readiness')
|
|
try:
|
|
if request('/healthz', authenticated=False)[0] == 200:
|
|
return process
|
|
except OSError:
|
|
pass
|
|
time.sleep(0.02)
|
|
raise AssertionError('fixture startup timed out')
|
|
|
|
def stop(process):
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait(timeout=3)
|
|
raise AssertionError('fixture required forced termination')
|
|
check('graceful_process_exit', process.returncode == 0)
|
|
|
|
def issue():
|
|
status, _, body = request('/token', {'grant_type': 'client_credentials'})
|
|
if status != 200:
|
|
return status, None
|
|
token = json.loads(body)['access_token']
|
|
tokens.append(token)
|
|
return status, token
|
|
|
|
def introspect(raw):
|
|
status, headers, body = request('/introspect', {'token': raw})
|
|
check('introspection_private_cache_control', headers.get('Cache-Control') == 'no-store')
|
|
return status, json.loads(body)
|
|
|
|
report = {'binary_sha256': 'sha256:' + hashlib.sha256(binary.read_bytes()).hexdigest(),
|
|
'checks': checks, 'real_credentials': False, 'native_acceptance': False,
|
|
'transport': 'isolated fixture HTTP backend; separate handler test proves fixture HTTPS',
|
|
'source': 'standalone built KeyCape executable with actual main router/config/grants/store'}
|
|
try:
|
|
process = start('enabled')
|
|
status, _, body = request('/.well-known/openid-configuration', authenticated=False)
|
|
discovery = json.loads(body)
|
|
check('enabled_discovery', status == 200 and discovery['introspection_endpoint'].endswith('/introspect') and discovery['revocation_endpoint'].endswith('/revoke'))
|
|
status, first = issue()
|
|
check('actual_service_grant', status == 200 and bool(first))
|
|
status, second = issue()
|
|
check('distinct_service_grants', status == 200 and first != second)
|
|
status, metadata = introspect(first)
|
|
check('current_service_metadata', status == 200 and metadata.get('active') is True and metadata.get('aud') == 'resource:fixture' and metadata.get('client_id') == 'fixture-client')
|
|
check('capacity_refuses_without_token', issue() == (503, None))
|
|
status, _, body = request('/revoke', {'token': first})
|
|
check('actual_revocation', status == 200 and body == b'')
|
|
check('revoked_token_inactive', introspect(first)[1] == {'active': False})
|
|
check('other_grant_still_active', introspect(second)[1].get('active') is True)
|
|
check('tombstone_not_evicted', issue() == (503, None))
|
|
check('unauthenticated_introspection_refused', request('/introspect', {'token': second}, authenticated=False)[0] == 401)
|
|
stop(process)
|
|
process = start('restarted')
|
|
check('actual_process_restart_refuses_predecessor', introspect(second)[1] == {'active': False})
|
|
status, third = issue()
|
|
check('fresh_post_restart_grant', status == 200 and third not in (first, second) and introspect(third)[1].get('active') is True)
|
|
check('new_grant_does_not_resurrect_predecessor', introspect(first)[1] == {'active': False})
|
|
stop(process)
|
|
cfg['tokenStatus'] = {'enabled': False}
|
|
config.write_text(json.dumps(cfg))
|
|
process = start('legacy')
|
|
_, _, body = request('/.well-known/openid-configuration', authenticated=False)
|
|
discovery = json.loads(body)
|
|
check('disabled_discovery_omits_endpoints', 'introspection_endpoint' not in discovery and 'revocation_endpoint' not in discovery)
|
|
check('disabled_router_has_no_endpoint', request('/introspect', {'token': second})[0] == 404)
|
|
status, fourth = issue()
|
|
payload = fourth.split('.')[1]
|
|
claims = json.loads(base64.urlsafe_b64decode(payload + '=' * (-len(payload) % 4)))
|
|
check('legacy_service_grant_has_no_new_claim', status == 200 and 'jti' not in claims)
|
|
stop(process)
|
|
combined = b''.join(path.read_bytes() for path in logs)
|
|
needles = [secret.encode(), key.read_bytes(), *(token.encode() for token in tokens)]
|
|
check('actual_process_diagnostics_omit_private_material', all(value not in combined for value in needles))
|
|
report['status'] = 'passed'
|
|
finally:
|
|
for process in processes:
|
|
if process.poll() is None:
|
|
stop(process)
|
|
report['all_owned_processes_terminal'] = all(p.poll() is not None for p in processes)
|
|
(workspace / 'binary-fixture-receipt.json').write_text(json.dumps(report, indent=2) + '\n')
|
|
print(json.dumps({'status': report['status'], 'checks': len(checks), 'all_owned_processes_terminal': report['all_owned_processes_terminal']}))
|