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,78 @@
"""
Self-signed TLS certificate management for local-identity serve.
Certificate and key are generated on first run and stored at:
~/.local-identity/tls/server.crt (mode 644, 10-year validity)
~/.local-identity/tls/server.key (mode 600)
The certificate covers localhost and 127.0.0.1 via SubjectAltName.
"""
import datetime
import ipaddress
import os
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from .store import _store_dir
def _tls_dir() -> Path:
return _store_dir() / "tls"
def ensure_tls_cert() -> tuple[Path, Path]:
"""Ensure a self-signed TLS cert exists. Returns (cert_path, key_path)."""
tls_dir = _tls_dir()
tls_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
cert_path = tls_dir / "server.crt"
key_path = tls_dir / "server.key"
if not cert_path.exists() or not key_path.exists():
_generate_cert(cert_path, key_path)
return cert_path, key_path
def _generate_cert(cert_path: Path, key_path: Path) -> None:
tls_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
key_path.write_bytes(
tls_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
)
os.chmod(key_path, 0o600)
subject = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, "localhost"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "local-identity"),
])
now = datetime.datetime.now(datetime.timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(subject)
.public_key(tls_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=3650))
.add_extension(
x509.SubjectAlternativeName([
x509.DNSName("localhost"),
x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
]),
critical=False,
)
.sign(tls_key, hashes.SHA256())
)
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
os.chmod(cert_path, 0o644)