generated from coulomb/repo-seed
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>
79 lines
2.4 KiB
Python
79 lines
2.4 KiB
Python
"""
|
|
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)
|