feat(local-identity): Stage 3 — minimal native OIDC provider (NK-WP-0002-T03)

Add local-identity serve command: a minimal Authorization Code flow OIDC
server backed by file-store users.  Implemented natively with no heavy
OIDC library — only stdlib http.server and the cryptography package.

New modules:
  keys.py      RSA-2048 signing key generation + JWKS helpers
  tls.py       Self-signed TLS certificate (localhost/127.0.0.1 SANs)
  jwt_utils.py RS256 JWT creation and verification
  serve.py     OIDCHandler + make_handler() factory + run_server()

Endpoints: /.well-known/openid-configuration, /jwks, /auth, /token,
/userinfo.  Server binds to 127.0.0.1 only; tokens carry iss: local-identity
which production Keycloak rejects by design.

104 tests passing (16 new for Stage 3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 01:05:50 +01:00
parent 25c92863cf
commit d35823df08
9 changed files with 1336 additions and 3 deletions

View File

@@ -0,0 +1,462 @@
"""
Minimal OIDC Authorization Code flow server for local-identity.
Binds to 127.0.0.1 only — external binding is explicitly unsupported.
In production mode (run_server) the socket is wrapped with TLS using an
auto-generated self-signed certificate. In test mode callers can use
make_handler() with scheme="http" and a plain HTTPServer.
Endpoints
---------
GET /.well-known/openid-configuration OIDC discovery document
GET /jwks JSON Web Key Set
GET /auth Authorization endpoint — login form
POST /auth Form submission — user selection
POST /token Token endpoint
GET /userinfo UserInfo endpoint (Bearer token)
Design constraints (from NK-WP-0002)
--------------------------------------
* No client secret validation (all registered clients trusted — dev only)
* No refresh tokens (stateless; re-auth required after expiry)
* Scopes supported: openid, profile, email
* iss: "local-identity" — intentionally non-routable; production Keycloak
rejects tokens with this issuer by design
"""
import http.server
import json
import secrets
import ssl
import sys
import time
import urllib.parse
from http import HTTPStatus
from pathlib import Path
from . import store
from .jwt_utils import JWTError, create_token, verify_token
from .keys import ensure_signing_key, jwk_public, key_id
from .tls import ensure_tls_cert
_BIND_HOST = "127.0.0.1"
_ISSUER = "local-identity"
_CODE_TTL = 60 # auth codes expire after 60 seconds
# ------------------------------------------------------------------ #
# HTML helpers #
# ------------------------------------------------------------------ #
def _he(s: str) -> str:
"""Minimal HTML attribute escaping."""
return (
s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
# ------------------------------------------------------------------ #
# Handler factory #
# ------------------------------------------------------------------ #
def make_handler(
private_key,
kid: str,
token_ttl: int = 3600,
scheme: str = "https",
):
"""
Return a configured OIDCHandler subclass.
Each call produces a fresh class with its own isolated auth-code store,
so multiple test servers can run concurrently without sharing state.
"""
codes: dict = {}
class _Handler(OIDCHandler):
_private_key = private_key
_kid = kid
_token_ttl = token_ttl
_codes = codes
_scheme = scheme
return _Handler
# ------------------------------------------------------------------ #
# HTTP handler #
# ------------------------------------------------------------------ #
class OIDCHandler(http.server.BaseHTTPRequestHandler):
# Configured by make_handler() / run_server() via subclass class vars
_private_key = None
_kid: str = ""
_token_ttl: int = 3600
_codes: dict = {}
_scheme: str = "https"
def log_message(self, fmt: str, *args) -> None:
pass # silence default Apache-style logging
# ---------------------------------------------------------------- #
# Routing #
# ---------------------------------------------------------------- #
def do_GET(self) -> None:
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
if path == "/.well-known/openid-configuration":
self._handle_discovery()
elif path == "/jwks":
self._handle_jwks()
elif path == "/auth":
self._handle_auth_get(parsed)
elif path == "/userinfo":
self._handle_userinfo()
else:
self._send_json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
def do_POST(self) -> None:
path = urllib.parse.urlparse(self.path).path
if path == "/auth":
self._handle_auth_post()
elif path == "/token":
self._handle_token()
else:
self._send_json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
# ---------------------------------------------------------------- #
# Endpoint: OIDC discovery #
# ---------------------------------------------------------------- #
def _handle_discovery(self) -> None:
base = self._base_url()
doc = {
"issuer": _ISSUER,
"authorization_endpoint": f"{base}/auth",
"token_endpoint": f"{base}/token",
"userinfo_endpoint": f"{base}/userinfo",
"jwks_uri": f"{base}/jwks",
"response_types_supported": ["code"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "profile", "email"],
"token_endpoint_auth_methods_supported": ["none"],
"grant_types_supported": ["authorization_code"],
"claims_supported": [
"sub", "iss", "aud", "exp", "iat",
"email", "name", "preferred_username",
],
}
self._send_json(doc)
# ---------------------------------------------------------------- #
# Endpoint: JWKS #
# ---------------------------------------------------------------- #
def _handle_jwks(self) -> None:
self._send_json({"keys": [jwk_public(self._private_key)]})
# ---------------------------------------------------------------- #
# Endpoint: GET /auth — display login form #
# ---------------------------------------------------------------- #
def _handle_auth_get(self, parsed: urllib.parse.ParseResult) -> None:
params = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
if params.get("response_type", [""])[0] != "code":
self._send_error_redirect(
params.get("redirect_uri", [""])[0],
"unsupported_response_type",
params.get("state", [""])[0],
)
return
users = store.list_users()
if not users:
self._send_html(
"<h1>Error</h1><p>No users in store. Run "
"<code>local-identity init</code> first.</p>",
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
return
client_id = params.get("client_id", [""])[0]
redirect_uri = params.get("redirect_uri", [""])[0]
state = params.get("state", [""])[0]
nonce = params.get("nonce", [""])[0]
options = "\n".join(
f'<option value="{_he(u.username)}">'
f'{_he(u.username)}{_he(u.fullname)}'
f'{" [test]" if u.generated else ""}'
"</option>"
for u in users
)
form = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>local-identity login</title>
<style>
body {{font-family: sans-serif; max-width: 480px; margin: 4em auto; color: #222;}}
h1 {{color: #333;}}
.warn {{background:#fff3cd;border:1px solid #ffc107;padding:.75em;border-radius:4px;}}
select, button {{display:block;margin:.5em 0;padding:.4em;font-size:1em;}}
button {{background:#0066cc;color:#fff;border:none;border-radius:4px;
padding:.5em 1.5em;cursor:pointer;}}
</style>
</head>
<body>
<h1>local-identity dev login</h1>
<p class="warn"><strong>Dev/test only.</strong> Tokens carry
<code>iss: local-identity</code> — production systems reject them.</p>
<p>Authenticating for <strong>{_he(client_id) or "(unknown client)"}</strong></p>
<form method="POST" action="/auth">
<input type="hidden" name="client_id" value="{_he(client_id)}">
<input type="hidden" name="redirect_uri" value="{_he(redirect_uri)}">
<input type="hidden" name="state" value="{_he(state)}">
<input type="hidden" name="nonce" value="{_he(nonce)}">
<label for="username">Login as:</label>
<select name="username" id="username">
{options}
</select>
<button type="submit">Login</button>
</form>
</body>
</html>"""
self._send_html(form)
# ---------------------------------------------------------------- #
# Endpoint: POST /auth — process login form #
# ---------------------------------------------------------------- #
def _handle_auth_post(self) -> None:
body = self._read_form_body()
username = body.get("username", [""])[0]
client_id = body.get("client_id", [""])[0]
redirect_uri = body.get("redirect_uri", [""])[0]
state = body.get("state", [""])[0]
nonce = body.get("nonce", [""])[0]
if not redirect_uri:
self._send_json(
{"error": "invalid_request", "error_description": "missing redirect_uri"},
HTTPStatus.BAD_REQUEST,
)
return
try:
store.read_user(username)
except FileNotFoundError:
self._send_error_redirect(redirect_uri, "access_denied", state)
return
# Expire stale codes on each write
now = time.time()
stale = [c for c, v in self._codes.items() if v["expires_at"] < now]
for c in stale:
del self._codes[c]
code = secrets.token_urlsafe(32)
self._codes[code] = {
"username": username,
"client_id": client_id,
"redirect_uri": redirect_uri,
"nonce": nonce or None,
"expires_at": now + _CODE_TTL,
}
sep = "&" if "?" in redirect_uri else "?"
location = (
f"{redirect_uri}{sep}code={urllib.parse.quote(code)}"
f"&state={urllib.parse.quote(state)}"
)
self.send_response(HTTPStatus.FOUND)
self.send_header("Location", location)
self.end_headers()
# ---------------------------------------------------------------- #
# Endpoint: POST /token #
# ---------------------------------------------------------------- #
def _handle_token(self) -> None:
body = self._read_form_body()
grant_type = body.get("grant_type", [""])[0]
if grant_type != "authorization_code":
self._send_json(
{"error": "unsupported_grant_type"},
HTTPStatus.BAD_REQUEST,
)
return
code = body.get("code", [""])[0]
client_id = body.get("client_id", [""])[0]
code_data = self._codes.pop(code, None)
if code_data is None:
self._send_json(
{"error": "invalid_grant", "error_description": "unknown or expired code"},
HTTPStatus.BAD_REQUEST,
)
return
if time.time() > code_data["expires_at"]:
self._send_json(
{"error": "invalid_grant", "error_description": "code expired"},
HTTPStatus.BAD_REQUEST,
)
return
try:
user = store.read_user(code_data["username"])
except FileNotFoundError:
self._send_json(
{"error": "invalid_grant", "error_description": "user not found"},
HTTPStatus.BAD_REQUEST,
)
return
token = create_token(
private_key=self._private_key,
kid=self._kid,
sub=user.username,
iss=_ISSUER,
aud=code_data.get("client_id") or client_id or "local-identity",
email=user.email,
name=user.fullname,
preferred_username=user.username,
ttl=self._token_ttl,
nonce=code_data.get("nonce"),
)
self._send_json({
"access_token": token,
"id_token": token,
"token_type": "Bearer",
"expires_in": self._token_ttl,
})
# ---------------------------------------------------------------- #
# Endpoint: GET /userinfo #
# ---------------------------------------------------------------- #
def _handle_userinfo(self) -> None:
auth_header = self.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
self.send_response(HTTPStatus.UNAUTHORIZED)
self.send_header("WWW-Authenticate", 'Bearer realm="local-identity"')
self.end_headers()
return
token = auth_header[len("Bearer "):]
try:
payload = verify_token(token, self._private_key.public_key())
except JWTError as exc:
self._send_json(
{"error": "invalid_token", "error_description": str(exc)},
HTTPStatus.UNAUTHORIZED,
)
return
self._send_json({
"sub": payload["sub"],
"email": payload.get("email"),
"name": payload.get("name"),
"preferred_username": payload.get("preferred_username"),
})
# ---------------------------------------------------------------- #
# Helpers #
# ---------------------------------------------------------------- #
def _base_url(self) -> str:
host, port = self.server.server_address
return f"{self._scheme}://{host}:{port}"
def _read_form_body(self) -> dict:
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length).decode("utf-8")
return urllib.parse.parse_qs(raw, keep_blank_values=True)
def _send_json(self, data: dict, status: HTTPStatus = HTTPStatus.OK) -> None:
body = json.dumps(data).encode("utf-8")
self.send_response(status)
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, html: str, status: HTTPStatus = HTTPStatus.OK) -> None:
body = html.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_error_redirect(
self, redirect_uri: str, error: str, state: str
) -> None:
if not redirect_uri:
self._send_json({"error": error}, HTTPStatus.BAD_REQUEST)
return
sep = "&" if "?" in redirect_uri else "?"
location = (
f"{redirect_uri}{sep}error={urllib.parse.quote(error)}"
f"&state={urllib.parse.quote(state)}"
)
self.send_response(HTTPStatus.FOUND)
self.send_header("Location", location)
self.end_headers()
# ------------------------------------------------------------------ #
# Public entry point #
# ------------------------------------------------------------------ #
def run_server(port: int = 8443, token_ttl: int = 3600) -> None:
"""
Start the local-identity OIDC server on 127.0.0.1:{port} with TLS.
Blocks until Ctrl+C.
The server always binds to 127.0.0.1. External binding (0.0.0.0) is
not supported and is not offered as an option.
"""
if not store.store_exists():
print(
"Error: store not initialised. Run 'local-identity init' first.",
file=sys.stderr,
)
sys.exit(1)
private_key = ensure_signing_key()
cert_path, key_path = ensure_tls_cert()
HandlerClass = make_handler(private_key, key_id(private_key), token_ttl, scheme="https")
httpd = http.server.HTTPServer((_BIND_HOST, port), HandlerClass)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(str(cert_path), str(key_path))
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
base_url = f"https://{_BIND_HOST}:{port}"
print(f"local-identity OIDC server")
print(f" URL: {base_url}")
print(f" Discovery: {base_url}/.well-known/openid-configuration")
print(f" Token TTL: {token_ttl}s")
print(f" Bound to: {_BIND_HOST} (localhost only)")
print("Press Ctrl+C to stop.")
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
finally:
httpd.server_close()