Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions doc/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
26 changes: 24 additions & 2 deletions limacharlie/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import base64
import json
import os
import ssl
import sys
import time
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions limacharlie/commands/api_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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://"):
Expand Down
100 changes: 100 additions & 0 deletions tests/unit/test_client_root_override.py
Original file line number Diff line number Diff line change
@@ -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
Loading