diff --git a/doc/authentication.md b/doc/authentication.md index a10d1b77..970d1b73 100644 --- a/doc/authentication.md +++ b/doc/authentication.md @@ -94,6 +94,16 @@ export LC_UID=your-user-id export LC_CURRENT_ENV=staging ``` +To target a LimaCharlie deployment other than the public one, such as a local +development stack, point the API and token-exchange roots elsewhere. Both are +unset by default, and a value that is not an absolute `http://` or `https://` +URL is refused: + +```bash +export LC_API_URL=http://127.0.0.1:9090 +export LC_JWT_URL=http://127.0.0.1:8135 +``` + ## Credentials File Credentials are stored in `~/.limacharlie.d/config.yaml` (YAML, mode 0600): diff --git a/limacharlie/client.py b/limacharlie/client.py index 863da52b..17437f02 100644 --- a/limacharlie/client.py +++ b/limacharlie/client.py @@ -14,6 +14,7 @@ import base64 import json +import os import ssl import sys import time @@ -41,9 +42,30 @@ except ImportError: __version__ = "0.0.0.dev0" -ROOT_URL = "https://api.limacharlie.io" + + +def _root_from_env(name: str, default: str) -> str: + """Return the service root named by environment variable *name*, or *default*. + + ``LC_API_URL`` and ``LC_JWT_URL`` point the SDK and CLI at a LimaCharlie + deployment other than the public one, such as a local development stack + (``LC_API_URL=http://127.0.0.1:9090 LC_JWT_URL=http://127.0.0.1:8135``). + Unset or empty keeps the public roots. A value that is not an absolute + http(s) URL is refused rather than ignored: silently falling back to the + public API would send the caller's credentials somewhere they did not ask + for. + """ + value = os.environ.get(name, "").strip() + if not value: + return default + if not (value.startswith("https://") or value.startswith("http://")): + raise ValueError(f"{name}={value!r}: want an absolute http:// or https:// URL") + return value.rstrip("/") + + +ROOT_URL = _root_from_env("LC_API_URL", "https://api.limacharlie.io") API_VERSION = "v1" -JWT_URL = "https://jwt.limacharlie.io" +JWT_URL = _root_from_env("LC_JWT_URL", "https://jwt.limacharlie.io") HTTP_OK = 200 HTTP_UNAUTHORIZED = 401 diff --git a/limacharlie/commands/api_cmd.py b/limacharlie/commands/api_cmd.py index 77f9b101..599cd611 100644 --- a/limacharlie/commands/api_cmd.py +++ b/limacharlie/commands/api_cmd.py @@ -232,6 +232,10 @@ def cmd(ctx: click.Context, endpoint: str, method: str | None, raw_field: tuple[ alt_root = None elif target == "api": alt_root = None + elif target == "jwt": + # Follows LC_JWT_URL like the client's own token exchange does. + from .. import client as _client + alt_root = _client.JWT_URL elif target in _TARGETS: alt_root = _TARGETS[target] elif target.startswith("https://") or target.startswith("http://"): diff --git a/tests/unit/test_client_root_override.py b/tests/unit/test_client_root_override.py new file mode 100644 index 00000000..82c5b02d --- /dev/null +++ b/tests/unit/test_client_root_override.py @@ -0,0 +1,100 @@ +"""LC_API_URL / LC_JWT_URL point the SDK and CLI at another deployment.""" + +import importlib + +import pytest + +import limacharlie.client as client_mod + + +@pytest.fixture +def reload_client(monkeypatch): + """Reload limacharlie.client under a patched environment, then restore it.""" + + def _reload(**env): + for name in ("LC_API_URL", "LC_JWT_URL"): + monkeypatch.delenv(name, raising=False) + for name, value in env.items(): + monkeypatch.setenv(name, value) + return importlib.reload(client_mod) + + yield _reload + monkeypatch.delenv("LC_API_URL", raising=False) + monkeypatch.delenv("LC_JWT_URL", raising=False) + importlib.reload(client_mod) + + +def test_unset_keeps_the_public_roots(reload_client): + mod = reload_client() + assert mod.ROOT_URL == "https://api.limacharlie.io" + assert mod.JWT_URL == "https://jwt.limacharlie.io" + + +def test_empty_keeps_the_public_roots(reload_client): + mod = reload_client(LC_API_URL=" ", LC_JWT_URL="") + assert mod.ROOT_URL == "https://api.limacharlie.io" + assert mod.JWT_URL == "https://jwt.limacharlie.io" + + +def test_override_is_used_and_normalized(reload_client): + mod = reload_client(LC_API_URL="http://127.0.0.1:9090/", LC_JWT_URL="http://127.0.0.1:8135") + assert mod.ROOT_URL == "http://127.0.0.1:9090" + assert mod.JWT_URL == "http://127.0.0.1:8135" + + +@pytest.mark.parametrize("value", ["127.0.0.1:9090", "ftp://example.com", "api.example.com"]) +def test_a_malformed_override_is_refused(reload_client, value): + with pytest.raises(ValueError, match="LC_API_URL"): + reload_client(LC_API_URL=value) + + +def test_the_token_exchange_and_api_calls_go_to_the_override(reload_client, monkeypatch): + """A client built under the overrides exchanges its key at LC_JWT_URL and + calls the API under LC_API_URL/v1, against a real local HTTP server.""" + import json + import threading + from http.server import BaseHTTPRequestHandler, HTTPServer + + seen = [] + + class Handler(BaseHTTPRequestHandler): + def _answer(self): + n = int(self.headers.get("Content-Length") or 0) + if n: + self.rfile.read(n) + seen.append((self.server.name, self.command, self.path)) + body = {"jwt": "h.p.s"} if self.server.name == "jwt" else {"ok": True} + raw = json.dumps(body).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + do_GET = do_POST = _answer + + def log_message(self, *args): + pass + + servers = {} + for name in ("api", "jwt"): + srv = HTTPServer(("127.0.0.1", 0), Handler) + srv.name = name + threading.Thread(target=srv.serve_forever, daemon=True).start() + servers[name] = srv + try: + mod = reload_client( + LC_API_URL=f"http://127.0.0.1:{servers['api'].server_port}", + LC_JWT_URL=f"http://127.0.0.1:{servers['jwt'].server_port}/", + ) + monkeypatch.setattr(mod, "resolve_credentials", lambda *a, **k: { + "oid": "11111111-1111-1111-1111-111111111111", "uid": None, + "api_key": "test-api-key-12345678", "oauth": None, + }) + c = mod.Client(oid="11111111-1111-1111-1111-111111111111") + c.request("GET", "orgs/11111111-1111-1111-1111-111111111111/url") + finally: + for srv in servers.values(): + srv.shutdown() + assert ("jwt", "POST", "/") in seen, seen + assert ("api", "GET", "/v1/orgs/11111111-1111-1111-1111-111111111111/url") in seen, seen