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

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