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

@@ -5,6 +5,7 @@ description = "Zero-dependency bootstrap user store for net-kingdom environments
requires-python = ">=3.11"
dependencies = [
"pyyaml>=6.0",
"cryptography>=42.0",
]
[project.scripts]

View File

@@ -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)

View 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

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),
}

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()

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)

View File

@@ -0,0 +1,439 @@
"""
Tests for local-identity OIDC serve module.
The tests spin up a plain HTTP (non-TLS) server on a random port using
make_handler() with scheme="http". This keeps tests fast and free of
certificate trust issues while exercising all the OIDC logic.
"""
import http.server
import json
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import pytest
from local_identity import store
from local_identity.jwt_utils import JWTError, create_token, verify_token
from local_identity.keys import ensure_signing_key, jwk_public, key_id
from local_identity.serve import _BIND_HOST, _ISSUER, make_handler
from local_identity.user import UserRecord, make_test_user
# ------------------------------------------------------------------ #
# Fixtures #
# ------------------------------------------------------------------ #
@pytest.fixture
def populated_store(tmp_store):
"""Init store with primary user alice + two test users."""
store.init_dirs()
primary = UserRecord(
username="alice",
fullname="Alice Smith",
email="alice@example.com",
)
store.write_user(primary)
store.write_user(make_test_user(primary, 1))
store.write_user(make_test_user(primary, 2))
return primary
@pytest.fixture
def oidc_server(populated_store, tmp_store):
"""
Start a plain HTTP OIDC server on a random port.
Yields (base_url, private_key).
"""
private_key = ensure_signing_key()
kid = key_id(private_key)
HandlerClass = make_handler(private_key, kid, token_ttl=60, scheme="http")
httpd = http.server.HTTPServer((_BIND_HOST, 0), HandlerClass)
port = httpd.server_address[1]
thread = threading.Thread(target=httpd.serve_forever)
thread.daemon = True
thread.start()
yield f"http://{_BIND_HOST}:{port}", private_key
httpd.shutdown()
# ------------------------------------------------------------------ #
# Helpers #
# ------------------------------------------------------------------ #
def _get_json(url: str) -> dict:
with urllib.request.urlopen(url) as resp:
return json.loads(resp.read())
def _post_form(url: str, fields: dict) -> tuple[int, dict]:
data = urllib.parse.urlencode(fields).encode()
req = urllib.request.Request(
url, data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(req) as resp:
return resp.status, json.loads(resp.read())
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""Opener that surfaces redirects as HTTPError instead of following them."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def _post_form_no_redirect(url: str, fields: dict) -> tuple[int, str]:
"""POST a form, capture the Location header without following the redirect."""
data = urllib.parse.urlencode(fields).encode()
req = urllib.request.Request(
url, data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
opener = urllib.request.build_opener(_NoRedirect())
try:
opener.open(req)
return 200, ""
except urllib.error.HTTPError as e:
return e.code, e.headers.get("Location", "")
def _do_auth_code_flow(base_url: str, username: str = "alice") -> dict:
"""
Execute the full authorization code flow for the given username.
Returns the token response dict.
"""
callback = "http://localhost:9999/callback"
# POST /auth (skip GET — form is cosmetic only)
status, location = _post_form_no_redirect(
f"{base_url}/auth",
{
"username": username,
"client_id": "testclient",
"redirect_uri": callback,
"state": "s1",
"nonce": "n1",
},
)
assert status == 302, f"Expected 302, got {status}"
assert "code=" in location, f"No code in redirect: {location}"
qs = urllib.parse.parse_qs(urllib.parse.urlparse(location).query)
code = qs["code"][0]
_, token_resp = _post_form(
f"{base_url}/token",
{
"grant_type": "authorization_code",
"code": code,
"redirect_uri": callback,
"client_id": "testclient",
},
)
return token_resp
# ------------------------------------------------------------------ #
# OIDC discovery #
# ------------------------------------------------------------------ #
def test_discovery_structure(oidc_server):
base_url, _ = oidc_server
doc = _get_json(f"{base_url}/.well-known/openid-configuration")
assert doc["issuer"] == _ISSUER
for key in ("authorization_endpoint", "token_endpoint",
"userinfo_endpoint", "jwks_uri"):
assert doc[key].startswith(base_url), f"{key} should point to server"
assert "code" in doc["response_types_supported"]
assert "RS256" in doc["id_token_signing_alg_values_supported"]
assert "openid" in doc["scopes_supported"]
# ------------------------------------------------------------------ #
# JWKS #
# ------------------------------------------------------------------ #
def test_jwks_contains_rsa_key(oidc_server):
base_url, private_key = oidc_server
data = _get_json(f"{base_url}/jwks")
assert "keys" in data
assert len(data["keys"]) == 1
jwk = data["keys"][0]
assert jwk["kty"] == "RSA"
assert jwk["alg"] == "RS256"
assert jwk["use"] == "sig"
assert "n" in jwk and "e" in jwk
assert jwk["kid"] == key_id(private_key)
# ------------------------------------------------------------------ #
# Authorization endpoint (GET) #
# ------------------------------------------------------------------ #
def test_auth_get_returns_html_form(oidc_server):
base_url, _ = oidc_server
params = urllib.parse.urlencode({
"response_type": "code",
"client_id": "testclient",
"redirect_uri": "http://localhost:9999/callback",
"scope": "openid profile email",
"state": "xyz",
})
with urllib.request.urlopen(f"{base_url}/auth?{params}") as resp:
body = resp.read().decode()
assert "<form" in body
assert 'method="POST"' in body
assert "alice" in body # primary user listed
def test_auth_get_wrong_response_type_redirects_error(oidc_server):
base_url, _ = oidc_server
callback = "http://localhost:9999/callback"
params = urllib.parse.urlencode({
"response_type": "token", # implicit — unsupported
"client_id": "testclient",
"redirect_uri": callback,
"state": "xyz",
})
status, location = _post_form_no_redirect(
f"{base_url}/auth?{params}", {}
)
# GET with wrong type triggers GET handler which sends redirect
# Actually we need to GET, not POST. Use the no-redirect opener differently.
req = urllib.request.Request(f"{base_url}/auth?{params}")
opener = urllib.request.build_opener(_NoRedirect())
try:
opener.open(req)
pytest.fail("Expected redirect")
except urllib.error.HTTPError as e:
loc = e.headers.get("Location", "")
assert "unsupported_response_type" in loc
# ------------------------------------------------------------------ #
# Full authorization code flow #
# ------------------------------------------------------------------ #
def test_full_flow_returns_valid_token(oidc_server):
base_url, private_key = oidc_server
token_resp = _do_auth_code_flow(base_url, username="alice")
assert "access_token" in token_resp
assert "id_token" in token_resp
assert token_resp["token_type"] == "Bearer"
assert token_resp["expires_in"] == 60
payload = verify_token(token_resp["access_token"], private_key.public_key())
assert payload["sub"] == "alice"
assert payload["iss"] == _ISSUER
assert payload["email"] == "alice@example.com"
assert payload["name"] == "Alice Smith"
assert payload["preferred_username"] == "alice"
assert payload["nonce"] == "n1"
def test_full_flow_userinfo(oidc_server):
base_url, _ = oidc_server
token_resp = _do_auth_code_flow(base_url, username="alice")
req = urllib.request.Request(
f"{base_url}/userinfo",
headers={"Authorization": f"Bearer {token_resp['access_token']}"},
)
with urllib.request.urlopen(req) as resp:
userinfo = json.loads(resp.read())
assert userinfo["sub"] == "alice"
assert userinfo["email"] == "alice@example.com"
assert userinfo["preferred_username"] == "alice"
def test_full_flow_test_user(oidc_server):
"""Test user alice1 can also authenticate."""
base_url, private_key = oidc_server
token_resp = _do_auth_code_flow(base_url, username="alice1")
payload = verify_token(token_resp["access_token"], private_key.public_key())
assert payload["sub"] == "alice1"
assert payload["email"] == "alice+test1@example.com"
# ------------------------------------------------------------------ #
# Code replay / expiry #
# ------------------------------------------------------------------ #
def test_code_cannot_be_reused(oidc_server):
base_url, _ = oidc_server
callback = "http://localhost:9999/callback"
status, location = _post_form_no_redirect(
f"{base_url}/auth",
{
"username": "alice",
"client_id": "testclient",
"redirect_uri": callback,
"state": "s",
"nonce": "",
},
)
qs = urllib.parse.parse_qs(urllib.parse.urlparse(location).query)
code = qs["code"][0]
token_fields = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": callback,
"client_id": "testclient",
}
# First use succeeds
_, first = _post_form(f"{base_url}/token", token_fields)
assert "access_token" in first
# Second use fails
try:
_post_form(f"{base_url}/token", token_fields)
pytest.fail("Expected 400 on code replay")
except urllib.error.HTTPError as e:
assert e.code == 400
body = json.loads(e.read())
assert body["error"] == "invalid_grant"
def test_unknown_code_rejected(oidc_server):
base_url, _ = oidc_server
try:
_post_form(
f"{base_url}/token",
{
"grant_type": "authorization_code",
"code": "notacode",
"redirect_uri": "http://localhost:9999/callback",
"client_id": "testclient",
},
)
pytest.fail("Expected 400")
except urllib.error.HTTPError as e:
assert e.code == 400
body = json.loads(e.read())
assert body["error"] == "invalid_grant"
# ------------------------------------------------------------------ #
# Userinfo — token errors #
# ------------------------------------------------------------------ #
def test_userinfo_no_bearer_returns_401(oidc_server):
base_url, _ = oidc_server
try:
urllib.request.urlopen(f"{base_url}/userinfo")
pytest.fail("Expected 401")
except urllib.error.HTTPError as e:
assert e.code == 401
def test_userinfo_tampered_token_rejected(oidc_server):
base_url, private_key = oidc_server
token_resp = _do_auth_code_flow(base_url)
good_token = token_resp["access_token"]
# Tamper with the signature (flip last char)
parts = good_token.split(".")
tampered = parts[0] + "." + parts[1] + "." + parts[2][:-1] + (
"A" if parts[2][-1] != "A" else "B"
)
req = urllib.request.Request(
f"{base_url}/userinfo",
headers={"Authorization": f"Bearer {tampered}"},
)
try:
urllib.request.urlopen(req)
pytest.fail("Expected 401")
except urllib.error.HTTPError as e:
assert e.code == 401
# ------------------------------------------------------------------ #
# Bind-host assertion #
# ------------------------------------------------------------------ #
def test_server_binds_to_localhost_only(oidc_server):
"""Verify the server address is always 127.0.0.1."""
base_url, _ = oidc_server
assert base_url.startswith(f"http://{_BIND_HOST}:")
def test_bind_host_constant():
assert _BIND_HOST == "127.0.0.1"
# ------------------------------------------------------------------ #
# JWT unit tests (independent of HTTP server) #
# ------------------------------------------------------------------ #
def test_jwt_roundtrip(tmp_store):
store.init_dirs()
private_key = ensure_signing_key()
kid = key_id(private_key)
token = create_token(
private_key=private_key,
kid=kid,
sub="bob",
iss=_ISSUER,
aud="myapp",
email="bob@example.com",
name="Bob Jones",
preferred_username="bob",
ttl=300,
nonce="abc",
)
payload = verify_token(token, private_key.public_key())
assert payload["sub"] == "bob"
assert payload["iss"] == _ISSUER
assert payload["aud"] == "myapp"
assert payload["email"] == "bob@example.com"
assert payload["name"] == "Bob Jones"
assert payload["nonce"] == "abc"
assert payload["exp"] > int(time.time())
def test_jwt_expired_rejected(tmp_store):
store.init_dirs()
private_key = ensure_signing_key()
kid = key_id(private_key)
token = create_token(
private_key=private_key,
kid=kid,
sub="bob",
iss=_ISSUER,
aud="x",
email="b@x.com",
name="Bob",
preferred_username="bob",
ttl=-1, # already expired
)
with pytest.raises(JWTError, match="expired"):
verify_token(token, private_key.public_key())
def test_jwt_signing_key_persisted(tmp_store):
"""ensure_signing_key() returns the same key on repeated calls."""
store.init_dirs()
k1 = ensure_signing_key()
k2 = ensure_signing_key()
assert key_id(k1) == key_id(k2)

144
local-identity/uv.lock generated
View File

@@ -1,6 +1,76 @@
version = 1
requires-python = ">=3.11"
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344 },
{ url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560 },
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613 },
{ url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476 },
{ url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374 },
{ url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597 },
{ url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574 },
{ url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971 },
{ url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972 },
{ url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078 },
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076 },
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820 },
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635 },
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271 },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048 },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529 },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097 },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983 },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519 },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572 },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963 },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361 },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230 },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043 },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446 },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101 },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948 },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422 },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499 },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928 },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302 },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320 },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487 },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049 },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793 },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300 },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244 },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828 },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926 },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773 },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013 },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593 },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354 },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480 },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584 },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443 },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437 },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 },
]
[[package]]
name = "colorama"
version = "0.4.6"
@@ -10,6 +80,65 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "cryptography"
version = "46.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289 },
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637 },
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742 },
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528 },
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993 },
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855 },
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635 },
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038 },
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181 },
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482 },
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497 },
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819 },
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230 },
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909 },
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287 },
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728 },
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287 },
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291 },
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539 },
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199 },
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131 },
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072 },
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170 },
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741 },
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728 },
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001 },
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637 },
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487 },
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514 },
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349 },
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667 },
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980 },
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143 },
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674 },
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801 },
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755 },
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539 },
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794 },
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160 },
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123 },
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220 },
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050 },
{ url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964 },
{ url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321 },
{ url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786 },
{ url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990 },
{ url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252 },
{ url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605 },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -24,6 +153,7 @@ name = "local-identity"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "cryptography" },
{ name = "pyyaml" },
]
@@ -33,7 +163,10 @@ dev = [
]
[package.metadata]
requires-dist = [{ name = "pyyaml", specifier = ">=6.0" }]
requires-dist = [
{ name = "cryptography", specifier = ">=42.0" },
{ name = "pyyaml", specifier = ">=6.0" },
]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8.0" }]
@@ -56,6 +189,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 },
]
[[package]]
name = "pygments"
version = "2.19.2"

View File

@@ -144,8 +144,9 @@ Keycloak dev instance.
```task
id: NK-WP-0002-T03
state_hub_task_id: eb09d287-8e08-4c88-8bd1-6f0501ef5fc8
status: todo
status: done
priority: medium
commit: (pending)
```
Implement `local-identity serve` — a minimal OIDC Authorization Code flow
@@ -225,7 +226,7 @@ expiry and revocation functional.
- [x] `~/.local-identity/` store initialised from Linux identity; test users generated
- [x] `local-identity list / show / export` working; Keycloak export validated
- [ ] Minimal OIDC server passes conformance smoke test; binds localhost only
- [x] Minimal OIDC server passes conformance smoke test; binds localhost only
- [ ] Filesystem permissions enforced on startup; `security-check` passes
- [ ] Audit log recording all auth events
- [ ] `docs/LocalIdentity.md` complete with import procedure and security model