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:
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
439
local-identity/tests/test_serve.py
Normal file
439
local-identity/tests/test_serve.py
Normal 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
144
local-identity/uv.lock
generated
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user