#!/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( '' '{}{}' '{}{}'.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 = """ Sign in to CASAN
CASAN LOCAL OIDC

Choose an identity

Use separate operator and reviewer identities to preserve separation of duties.

__IDENTITY_CARDS__
""" 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()