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