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,74 @@
"""
RSA signing key management for local-identity OIDC serve.
The signing key is generated on first invocation and stored at:
~/.local-identity/keys/signing_private.pem (mode 600)
The corresponding public key is never stored separately — it is always
derived from the private key on load.
"""
import base64
import hashlib
import os
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from .store import _store_dir
def _keys_dir() -> Path:
return _store_dir() / "keys"
def ensure_signing_key() -> RSAPrivateKey:
"""Load the signing key from disk, or generate a new RSA-2048 key if absent."""
keys_dir = _keys_dir()
keys_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
priv_path = keys_dir / "signing_private.pem"
if priv_path.exists():
priv_pem = priv_path.read_bytes()
return serialization.load_pem_private_key(priv_pem, password=None)
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
priv_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
priv_path.write_bytes(priv_pem)
os.chmod(priv_path, 0o600)
return private_key
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def key_id(private_key: RSAPrivateKey) -> str:
"""Return a stable 16-hex-char key ID derived from the public key modulus."""
n = private_key.public_key().public_numbers().n
n_bytes = n.to_bytes((n.bit_length() + 7) // 8, byteorder="big")
return hashlib.sha256(n_bytes).hexdigest()[:16]
def jwk_public(private_key: RSAPrivateKey) -> dict:
"""Return the RSA public key as a JWK dict (RS256, sig use)."""
pub = private_key.public_key().public_numbers()
n_bytes = pub.n.to_bytes((pub.n.bit_length() + 7) // 8, byteorder="big")
e_bytes = pub.e.to_bytes((pub.e.bit_length() + 7) // 8, byteorder="big")
return {
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": key_id(private_key),
"n": _b64url(n_bytes),
"e": _b64url(e_bytes),
}