generated from coulomb/repo-seed
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:
@@ -11,6 +11,7 @@ Commands:
|
||||
export [<username>] Export a single user as Keycloak JSON.
|
||||
export --all [--realm R] Bulk partial-import body (primary users only).
|
||||
Add --include-test to include generated users.
|
||||
serve [--port P] [--ttl T] Start the minimal OIDC server on 127.0.0.1.
|
||||
|
||||
Environment:
|
||||
LOCAL_IDENTITY_HOME Override the store directory (default: ~/.local-identity).
|
||||
@@ -23,6 +24,7 @@ import sys
|
||||
from .gecos import current_username, get_gecos_fullname
|
||||
from .user import UserRecord, make_test_user
|
||||
from . import export as export_mod
|
||||
from . import serve as serve_mod
|
||||
from . import store
|
||||
|
||||
|
||||
@@ -133,6 +135,10 @@ def cmd_show(args: argparse.Namespace) -> None:
|
||||
print(user.to_yaml(), end="")
|
||||
|
||||
|
||||
def cmd_serve(args: argparse.Namespace) -> None:
|
||||
serve_mod.run_server(port=args.port, token_ttl=args.ttl)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="local-identity",
|
||||
@@ -196,6 +202,20 @@ def main() -> None:
|
||||
p_show.add_argument("username", help="Username to display")
|
||||
p_show.set_defaults(func=cmd_show)
|
||||
|
||||
p_serve = sub.add_parser(
|
||||
"serve",
|
||||
help="Start the minimal OIDC server (127.0.0.1 only)",
|
||||
)
|
||||
p_serve.add_argument(
|
||||
"--port", type=int, default=8443,
|
||||
help="Port to listen on (default: 8443)",
|
||||
)
|
||||
p_serve.add_argument(
|
||||
"--ttl", type=int, default=3600,
|
||||
help="Token TTL in seconds (default: 3600)",
|
||||
)
|
||||
p_serve.set_defaults(func=cmd_serve)
|
||||
|
||||
args = parser.parse_args()
|
||||
args.func(args)
|
||||
|
||||
|
||||
116
local-identity/src/local_identity/jwt_utils.py
Normal file
116
local-identity/src/local_identity/jwt_utils.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
JWT creation and verification for local-identity OIDC serve.
|
||||
|
||||
Uses RS256 (RSA-PKCS1v15 + SHA-256) signing via the cryptography library.
|
||||
No third-party JWT library is used — only stdlib base64/json and cryptography
|
||||
primitives.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Base64url helpers #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _b64url_encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(s: str) -> bytes:
|
||||
pad = (4 - len(s) % 4) % 4
|
||||
return base64.urlsafe_b64decode(s + "=" * pad)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Token creation #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def create_token(
|
||||
private_key: RSAPrivateKey,
|
||||
kid: str,
|
||||
sub: str,
|
||||
iss: str,
|
||||
aud: str,
|
||||
email: str,
|
||||
name: str,
|
||||
preferred_username: str,
|
||||
ttl: int = 3600,
|
||||
nonce: str | None = None,
|
||||
) -> str:
|
||||
"""Create and sign a JWT with RS256. Returns the compact serialisation."""
|
||||
now = int(time.time())
|
||||
header = {"alg": "RS256", "typ": "JWT", "kid": kid}
|
||||
payload: dict = {
|
||||
"sub": sub,
|
||||
"iss": iss,
|
||||
"aud": aud,
|
||||
"exp": now + ttl,
|
||||
"iat": now,
|
||||
"jti": str(uuid.uuid4()),
|
||||
"email": email,
|
||||
"name": name,
|
||||
"preferred_username": preferred_username,
|
||||
}
|
||||
if nonce is not None:
|
||||
payload["nonce"] = nonce
|
||||
|
||||
header_b64 = _b64url_encode(json.dumps(header, separators=(",", ":")).encode())
|
||||
payload_b64 = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
|
||||
signing_input = f"{header_b64}.{payload_b64}".encode("ascii")
|
||||
|
||||
signature = private_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
|
||||
return f"{header_b64}.{payload_b64}.{_b64url_encode(signature)}"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Token verification #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
class JWTError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def verify_token(token: str, public_key: RSAPublicKey) -> dict:
|
||||
"""
|
||||
Verify signature and expiry of a JWT. Returns the decoded payload.
|
||||
Raises JWTError for any failure (malformed, bad signature, expired).
|
||||
"""
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise JWTError("malformed token: expected 3 parts")
|
||||
|
||||
header_b64, payload_b64, sig_b64 = parts
|
||||
|
||||
try:
|
||||
header = json.loads(_b64url_decode(header_b64))
|
||||
except Exception as exc:
|
||||
raise JWTError(f"cannot decode header: {exc}") from exc
|
||||
|
||||
if header.get("alg") != "RS256":
|
||||
raise JWTError(f"unsupported algorithm: {header.get('alg')!r}")
|
||||
|
||||
try:
|
||||
payload = json.loads(_b64url_decode(payload_b64))
|
||||
except Exception as exc:
|
||||
raise JWTError(f"cannot decode payload: {exc}") from exc
|
||||
|
||||
signing_input = f"{header_b64}.{payload_b64}".encode("ascii")
|
||||
try:
|
||||
sig_bytes = _b64url_decode(sig_b64)
|
||||
public_key.verify(sig_bytes, signing_input, padding.PKCS1v15(), hashes.SHA256())
|
||||
except InvalidSignature as exc:
|
||||
raise JWTError("invalid signature") from exc
|
||||
|
||||
if "exp" in payload and int(time.time()) > payload["exp"]:
|
||||
raise JWTError("token has expired")
|
||||
|
||||
return payload
|
||||
74
local-identity/src/local_identity/keys.py
Normal file
74
local-identity/src/local_identity/keys.py
Normal 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),
|
||||
}
|
||||
462
local-identity/src/local_identity/serve.py
Normal file
462
local-identity/src/local_identity/serve.py
Normal 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 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()
|
||||
78
local-identity/src/local_identity/tls.py
Normal file
78
local-identity/src/local_identity/tls.py
Normal 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)
|
||||
Reference in New Issue
Block a user