248 lines
11 KiB
Python
248 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
import json
|
|
import hmac
|
|
import html
|
|
import os
|
|
import socket
|
|
import time
|
|
import uuid
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from urllib.parse import parse_qs, urlencode, urlparse
|
|
|
|
import jwt
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
|
|
|
|
PORT = int(os.environ.get("CASAN_IDP_PORT", "8080"))
|
|
ISSUER = os.environ.get("CASAN_IDP_ISSUER", f"http://127.0.0.1:{PORT}")
|
|
DEFAULT_SUB = os.environ.get("CASAN_IDP_SUB", "oidc-ops")
|
|
DEFAULT_EMAIL = os.environ.get("CASAN_IDP_EMAIL", "oidc-ops@example.com")
|
|
DEFAULT_GROUPS = [g for g in os.environ.get("CASAN_IDP_GROUPS", "casan-org-admin,casan-approver,casan-project:AINative_OKR_CASAN4").split(",") if g]
|
|
USERS = {
|
|
DEFAULT_SUB: {
|
|
"name": "Operations Owner",
|
|
"email": DEFAULT_EMAIL,
|
|
"groups": DEFAULT_GROUPS,
|
|
"description": "Creates and operates governed runs",
|
|
},
|
|
"oidc-reviewer": {
|
|
"name": "Independent Reviewer",
|
|
"email": "oidc-reviewer@example.com",
|
|
"groups": ["casan-approver", "casan-project:AINative_OKR_CASAN4"],
|
|
"description": "Reviews and approves another operator's proposal",
|
|
},
|
|
}
|
|
KID = "casan-local-prod-idp"
|
|
KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
CODES = {}
|
|
APPROVAL_SIGNER_TOKEN = os.environ.get("CASAN_APPROVAL_SIGNER_TOKEN", "")
|
|
|
|
|
|
def b64u_int(value: int) -> str:
|
|
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
|
|
import base64
|
|
|
|
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
|
|
|
|
def jwk():
|
|
numbers = KEY.public_key().public_numbers()
|
|
return {
|
|
"kty": "RSA",
|
|
"use": "sig",
|
|
"alg": "RS256",
|
|
"kid": KID,
|
|
"n": b64u_int(numbers.n),
|
|
"e": b64u_int(numbers.e),
|
|
}
|
|
|
|
|
|
def sign_claims(identity=None, client_id="casan-control-panel", nonce="", ttl_s=300):
|
|
identity = identity or USERS[DEFAULT_SUB]
|
|
now = int(time.time())
|
|
claims = {
|
|
"iss": ISSUER,
|
|
"sub": identity.get("sub", DEFAULT_SUB),
|
|
"aud": client_id,
|
|
"email": identity.get("email", DEFAULT_EMAIL),
|
|
"groups": identity.get("groups", DEFAULT_GROUPS),
|
|
"iat": now,
|
|
"exp": now + int(ttl_s),
|
|
}
|
|
if nonce:
|
|
claims["nonce"] = nonce
|
|
token = jwt.encode(claims, KEY, algorithm="RS256", headers={"kid": KID})
|
|
return token, claims
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def send_json(self, code, payload):
|
|
body = json.dumps(payload).encode()
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def send_html(self, code, body):
|
|
raw = body.encode()
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(raw)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
self.wfile.write(raw)
|
|
|
|
def login_page(self, query):
|
|
cards = []
|
|
base = {key: values[0] for key, values in query.items() if key != "user" and values}
|
|
for user_id, identity in USERS.items():
|
|
params = {**base, "user": user_id}
|
|
cards.append(
|
|
'<a class="identity" href="?{}">'
|
|
'<span class="avatar">{}</span><span><strong>{}</strong>'
|
|
'<small>{}</small><em>{}</em></span></a>'.format(
|
|
html.escape(urlencode(params), quote=True),
|
|
html.escape(identity["name"][0]),
|
|
html.escape(identity["name"]),
|
|
html.escape(user_id),
|
|
html.escape(identity["description"]),
|
|
)
|
|
)
|
|
template = """<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Sign in to CASAN</title><style>
|
|
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:radial-gradient(circle at top,#e0e7ff,#f8fafc 48%);font:15px system-ui;color:#0f172a}.panel{width:min(92vw,520px);background:#fff;border:1px solid #e2e8f0;border-radius:24px;padding:32px;box-shadow:0 24px 70px #47556922}.brand{color:#4f46e5;font-size:12px;font-weight:800;letter-spacing:.18em}.panel h1{margin:8px 0}.panel p{color:#64748b;margin:0 0 24px}.identity{display:flex;gap:14px;align-items:center;padding:16px;margin-top:12px;border:1px solid #e2e8f0;border-radius:16px;color:inherit;text-decoration:none;transition:.15s}.identity:hover{border-color:#818cf8;transform:translateY(-1px);box-shadow:0 10px 24px #6366f122}.avatar{display:grid;place-items:center;width:44px;height:44px;border-radius:14px;background:#eef2ff;color:#4f46e5;font-size:18px;font-weight:800}.identity span:last-child{display:grid;gap:2px}.identity small{color:#6366f1;font-weight:700}.identity em{color:#64748b;font-size:12px;font-style:normal}</style></head>
|
|
<body><main class="panel"><div class="brand">CASAN LOCAL OIDC</div><h1>Choose an identity</h1><p>Use separate operator and reviewer identities to preserve separation of duties.</p>__IDENTITY_CARDS__</main></body></html>"""
|
|
return template.replace("__IDENTITY_CARDS__", "".join(cards))
|
|
|
|
def do_GET(self): # noqa: N802
|
|
parsed = urlparse(self.path)
|
|
path = parsed.path
|
|
if path == "/healthz":
|
|
self.send_json(200, {"status": "ok", "issuer": ISSUER})
|
|
elif path == "/.well-known/openid-configuration":
|
|
self.send_json(
|
|
200,
|
|
{
|
|
"issuer": ISSUER,
|
|
"authorization_endpoint": f"{ISSUER}/authorize",
|
|
"jwks_uri": f"{ISSUER}/.well-known/jwks.json",
|
|
"token_endpoint": f"{ISSUER}/token",
|
|
"userinfo_endpoint": f"{ISSUER}/userinfo",
|
|
"id_token_signing_alg_values_supported": ["RS256"],
|
|
"response_types_supported": ["code"],
|
|
"subject_types_supported": ["public"],
|
|
"scopes_supported": ["openid", "email", "profile", "groups"],
|
|
"claims_supported": ["sub", "email", "groups"],
|
|
},
|
|
)
|
|
elif path == "/.well-known/jwks.json":
|
|
self.send_json(200, {"keys": [jwk()]})
|
|
elif path == "/authorize":
|
|
q = parse_qs(parsed.query)
|
|
redirect_uri = q.get("redirect_uri", [""])[0]
|
|
if not redirect_uri:
|
|
self.send_json(400, {"error": "missing_redirect_uri"})
|
|
return
|
|
user_id = q.get("user", [""])[0]
|
|
if user_id not in USERS:
|
|
self.send_html(200, self.login_page(q))
|
|
return
|
|
identity = USERS[user_id]
|
|
code = uuid.uuid4().hex
|
|
CODES[code] = {
|
|
"client_id": q.get("client_id", ["casan-control-panel"])[0],
|
|
"nonce": q.get("nonce", [""])[0],
|
|
"sub": user_id,
|
|
"email": identity["email"],
|
|
"groups": identity["groups"],
|
|
}
|
|
params = {"code": code}
|
|
if q.get("state"):
|
|
params["state"] = q["state"][0]
|
|
sep = "&" if "?" in redirect_uri else "?"
|
|
self.send_response(302)
|
|
self.send_header("Location", redirect_uri + sep + urlencode(params))
|
|
self.end_headers()
|
|
elif path == "/userinfo":
|
|
authorization = self.headers.get("Authorization", "")
|
|
token = authorization.removeprefix("Bearer ").strip()
|
|
try:
|
|
claims = jwt.decode(token, KEY.public_key(), algorithms=["RS256"], options={"verify_aud": False})
|
|
self.send_json(200, {"sub": claims["sub"], "email": claims["email"], "groups": claims["groups"]})
|
|
except Exception:
|
|
self.send_json(401, {"error": "invalid_token"})
|
|
else:
|
|
self.send_json(404, {"error": "not_found"})
|
|
|
|
def do_POST(self): # noqa: N802
|
|
if urlparse(self.path).path != "/token":
|
|
self.send_json(404, {"error": "not_found"})
|
|
return
|
|
try:
|
|
size = int(self.headers.get("Content-Length", "0"))
|
|
except ValueError:
|
|
self.send_json(400, {"error": "invalid_content_length"})
|
|
return
|
|
if size < 0 or size > 65536:
|
|
self.send_json(413, {"error": "request_too_large"})
|
|
return
|
|
self.connection.settimeout(5)
|
|
try:
|
|
raw = self.rfile.read(size) or b"{}"
|
|
except (TimeoutError, socket.timeout):
|
|
self.send_json(408, {"error": "request_timeout"})
|
|
return
|
|
ctype = self.headers.get("Content-Type", "")
|
|
if "application/json" in ctype:
|
|
supplied_token = self.headers.get("X-CASAN-Approval-Signer-Token", "")
|
|
if not APPROVAL_SIGNER_TOKEN or not hmac.compare_digest(supplied_token, APPROVAL_SIGNER_TOKEN):
|
|
self.send_json(401, {"error": "approval_signer_unauthorized"})
|
|
return
|
|
try:
|
|
payload = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
self.send_json(400, {"error": "invalid_json"})
|
|
return
|
|
# Backward-compatible approval-verifier token minting.
|
|
now = int(time.time())
|
|
claims = {
|
|
"iss": ISSUER,
|
|
"sub": payload.get("sub", DEFAULT_SUB),
|
|
"role": payload.get("role", "ops"),
|
|
"action": payload.get("action", "deploy"),
|
|
"actor": payload.get("actor", "alice"),
|
|
"input_sha256": payload.get("input_sha256", ""),
|
|
"jti": uuid.uuid4().hex,
|
|
"iat": now,
|
|
"exp": now + int(payload.get("ttl_s", 300)),
|
|
}
|
|
token = jwt.encode(claims, KEY, algorithm="RS256", headers={"kid": KID})
|
|
self.send_json(200, {"access_token": token, "token_type": "Bearer", "expires_in": claims["exp"] - now})
|
|
return
|
|
|
|
form = parse_qs(raw.decode())
|
|
code = form.get("code", [""])[0]
|
|
rec = CODES.pop(code, None)
|
|
if not rec:
|
|
self.send_json(400, {"error": "invalid_grant"})
|
|
return
|
|
token, claims = sign_claims(identity=rec,
|
|
client_id=rec.get("client_id") or form.get("client_id", ["casan-control-panel"])[0],
|
|
nonce=rec.get("nonce", ""))
|
|
self.send_json(200, {
|
|
"access_token": token,
|
|
"id_token": token,
|
|
"token_type": "Bearer",
|
|
"expires_in": claims["exp"] - int(time.time()),
|
|
})
|
|
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
|
|
server.daemon_threads = True
|
|
server.serve_forever()
|