paradigx-cli-core 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- paradigx_cli_core-0.1.0/.gitignore +8 -0
- paradigx_cli_core-0.1.0/PKG-INFO +66 -0
- paradigx_cli_core-0.1.0/README.md +40 -0
- paradigx_cli_core-0.1.0/pyproject.toml +45 -0
- paradigx_cli_core-0.1.0/src/paradigx_cli_core/__init__.py +63 -0
- paradigx_cli_core-0.1.0/src/paradigx_cli_core/auth.py +113 -0
- paradigx_cli_core-0.1.0/src/paradigx_cli_core/client.py +86 -0
- paradigx_cli_core-0.1.0/src/paradigx_cli_core/config.py +160 -0
- paradigx_cli_core-0.1.0/src/paradigx_cli_core/device_flow.py +129 -0
- paradigx_cli_core-0.1.0/src/paradigx_cli_core/output.py +76 -0
- paradigx_cli_core-0.1.0/tests/__init__.py +0 -0
- paradigx_cli_core-0.1.0/tests/test_core.py +265 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paradigx-cli-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared core for Paradigx product CLIs — OAuth device-flow, token cache, HTTP client
|
|
5
|
+
Project-URL: Homepage, https://github.com/jiangjin11/paradigx-workspace
|
|
6
|
+
Author: Paradigx Pte Ltd
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: agent,cli,device-flow,oauth,paradigx
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: httpx>=0.28
|
|
21
|
+
Requires-Dist: rich>=13.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest-mock>=3.14; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.3; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# paradigx-cli-core
|
|
28
|
+
|
|
29
|
+
Shared core for [Paradigx](https://github.com/jiangjin11/paradigx-workspace)
|
|
30
|
+
product CLIs (`botu`, `tokenroute`, ...).
|
|
31
|
+
|
|
32
|
+
This is a **library, not a CLI** — it has no command entry point. Product
|
|
33
|
+
CLIs depend on it and keep only their command layer.
|
|
34
|
+
|
|
35
|
+
## What it provides
|
|
36
|
+
|
|
37
|
+
- **OAuth device-flow** login against Logto (`auth`, `device_flow`)
|
|
38
|
+
- **Shared token cache** at `~/.paradigx/auth.json`, keyed by API URL, so
|
|
39
|
+
one login is reused across every Paradigx CLI (`config`)
|
|
40
|
+
- **Automatic JWT refresh** — cached tokens are transparently renewed via
|
|
41
|
+
the stored refresh token (`auth.valid_access_token`)
|
|
42
|
+
- **HTTP client** with per-product trailing-slash / redirect handling
|
|
43
|
+
(`client`)
|
|
44
|
+
- **`--json` output helpers** — rich tables for humans, JSON for agents
|
|
45
|
+
(`output`)
|
|
46
|
+
|
|
47
|
+
## Usage (by a product CLI)
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from paradigx_cli_core import do_login, valid_access_token, request
|
|
51
|
+
|
|
52
|
+
# login
|
|
53
|
+
do_login("https://botu.io/api/auth/discovery/", "https://botu.io",
|
|
54
|
+
on_code=lambda c: print("visit", c.verification_uri_complete))
|
|
55
|
+
|
|
56
|
+
# authenticated call (auto-refreshes the token)
|
|
57
|
+
token = valid_access_token("https://botu.io")
|
|
58
|
+
sites = request("GET", "https://botu.io", "/api/sites",
|
|
59
|
+
token=token, trailing_slash=True)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
See `docs/specs/cli-phase-b.md` in the workspace repo for the design.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
© 2026 Paradigx. All Rights Reserved.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# paradigx-cli-core
|
|
2
|
+
|
|
3
|
+
Shared core for [Paradigx](https://github.com/jiangjin11/paradigx-workspace)
|
|
4
|
+
product CLIs (`botu`, `tokenroute`, ...).
|
|
5
|
+
|
|
6
|
+
This is a **library, not a CLI** — it has no command entry point. Product
|
|
7
|
+
CLIs depend on it and keep only their command layer.
|
|
8
|
+
|
|
9
|
+
## What it provides
|
|
10
|
+
|
|
11
|
+
- **OAuth device-flow** login against Logto (`auth`, `device_flow`)
|
|
12
|
+
- **Shared token cache** at `~/.paradigx/auth.json`, keyed by API URL, so
|
|
13
|
+
one login is reused across every Paradigx CLI (`config`)
|
|
14
|
+
- **Automatic JWT refresh** — cached tokens are transparently renewed via
|
|
15
|
+
the stored refresh token (`auth.valid_access_token`)
|
|
16
|
+
- **HTTP client** with per-product trailing-slash / redirect handling
|
|
17
|
+
(`client`)
|
|
18
|
+
- **`--json` output helpers** — rich tables for humans, JSON for agents
|
|
19
|
+
(`output`)
|
|
20
|
+
|
|
21
|
+
## Usage (by a product CLI)
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from paradigx_cli_core import do_login, valid_access_token, request
|
|
25
|
+
|
|
26
|
+
# login
|
|
27
|
+
do_login("https://botu.io/api/auth/discovery/", "https://botu.io",
|
|
28
|
+
on_code=lambda c: print("visit", c.verification_uri_complete))
|
|
29
|
+
|
|
30
|
+
# authenticated call (auto-refreshes the token)
|
|
31
|
+
token = valid_access_token("https://botu.io")
|
|
32
|
+
sites = request("GET", "https://botu.io", "/api/sites",
|
|
33
|
+
token=token, trailing_slash=True)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
See `docs/specs/cli-phase-b.md` in the workspace repo for the design.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
© 2026 Paradigx. All Rights Reserved.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "paradigx-cli-core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Shared core for Paradigx product CLIs — OAuth device-flow, token cache, HTTP client"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Paradigx Pte Ltd" }]
|
|
9
|
+
keywords = ["cli", "oauth", "device-flow", "agent", "paradigx"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
dependencies = [
|
|
24
|
+
"httpx>=0.28",
|
|
25
|
+
"rich>=13.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=8.3",
|
|
31
|
+
"pytest-mock>=3.14",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/jiangjin11/paradigx-workspace"
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["hatchling>=1.25"]
|
|
39
|
+
build-backend = "hatchling.build"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/paradigx_cli_core"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""paradigx-cli-core — shared core for Paradigx product CLIs.
|
|
2
|
+
|
|
3
|
+
Provides OAuth device-flow login, a shared `~/.paradigx/auth.json` token
|
|
4
|
+
cache, automatic JWT refresh, an HTTP client, and `--json` output helpers.
|
|
5
|
+
Product CLIs (botu, tokenroute, ...) depend on this and keep only their
|
|
6
|
+
command layer. See docs/specs/cli-phase-b.md.
|
|
7
|
+
"""
|
|
8
|
+
from .client import ApiError, exit_code_for, request, with_trailing_slash
|
|
9
|
+
from .config import (
|
|
10
|
+
Credentials,
|
|
11
|
+
clear_credentials,
|
|
12
|
+
default_config_dir,
|
|
13
|
+
import_legacy,
|
|
14
|
+
load_credentials,
|
|
15
|
+
save_credentials,
|
|
16
|
+
)
|
|
17
|
+
from .auth import NeedLogin, do_login, refresh, valid_access_token
|
|
18
|
+
from .device_flow import (
|
|
19
|
+
DeviceCodeResponse,
|
|
20
|
+
DiscoveryInfo,
|
|
21
|
+
fetch_discovery,
|
|
22
|
+
open_browser,
|
|
23
|
+
poll_for_token,
|
|
24
|
+
request_device_code,
|
|
25
|
+
)
|
|
26
|
+
from .output import emit, error, info, is_json_mode, set_json_mode, success
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"__version__",
|
|
32
|
+
# client
|
|
33
|
+
"ApiError",
|
|
34
|
+
"exit_code_for",
|
|
35
|
+
"request",
|
|
36
|
+
"with_trailing_slash",
|
|
37
|
+
# config
|
|
38
|
+
"Credentials",
|
|
39
|
+
"clear_credentials",
|
|
40
|
+
"default_config_dir",
|
|
41
|
+
"import_legacy",
|
|
42
|
+
"load_credentials",
|
|
43
|
+
"save_credentials",
|
|
44
|
+
# auth
|
|
45
|
+
"NeedLogin",
|
|
46
|
+
"do_login",
|
|
47
|
+
"refresh",
|
|
48
|
+
"valid_access_token",
|
|
49
|
+
# device_flow
|
|
50
|
+
"DeviceCodeResponse",
|
|
51
|
+
"DiscoveryInfo",
|
|
52
|
+
"fetch_discovery",
|
|
53
|
+
"open_browser",
|
|
54
|
+
"poll_for_token",
|
|
55
|
+
"request_device_code",
|
|
56
|
+
# output
|
|
57
|
+
"emit",
|
|
58
|
+
"error",
|
|
59
|
+
"info",
|
|
60
|
+
"is_json_mode",
|
|
61
|
+
"set_json_mode",
|
|
62
|
+
"success",
|
|
63
|
+
]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Login orchestration + automatic token refresh.
|
|
2
|
+
|
|
3
|
+
`do_login` runs the full device-flow and persists credentials.
|
|
4
|
+
`valid_access_token` is what command code calls before every API request:
|
|
5
|
+
it returns a non-expired access token, transparently refreshing via the
|
|
6
|
+
stored refresh_token when needed.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from .config import Credentials, load_credentials, save_credentials
|
|
16
|
+
from .device_flow import fetch_discovery, poll_for_token, request_device_code
|
|
17
|
+
|
|
18
|
+
# Refresh when fewer than this many seconds of validity remain.
|
|
19
|
+
_REFRESH_SKEW_SECONDS = 60
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NeedLogin(RuntimeError):
|
|
23
|
+
"""Raised when there's no usable credential and the user must log in."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str = "not logged in — run `login` first"):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _token_endpoint(creds: Credentials) -> str | None:
|
|
30
|
+
"""Resolve the OIDC token endpoint for a credential.
|
|
31
|
+
|
|
32
|
+
New logins store `token_endpoint` directly. Pre-Phase-B / legacy-imported
|
|
33
|
+
entries only have `issuer`; derive from it (botu issuer ends with `/oidc`,
|
|
34
|
+
tokenroute issuer doesn't).
|
|
35
|
+
"""
|
|
36
|
+
if creds.token_endpoint:
|
|
37
|
+
return creds.token_endpoint
|
|
38
|
+
iss = (creds.issuer or "").rstrip("/")
|
|
39
|
+
if not iss:
|
|
40
|
+
return None
|
|
41
|
+
return f"{iss}/token" if iss.endswith("/oidc") else f"{iss}/oidc/token"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def refresh(creds: Credentials) -> Credentials:
|
|
45
|
+
"""Exchange the stored refresh_token for a fresh access token.
|
|
46
|
+
|
|
47
|
+
Raises NeedLogin if the credential can't be refreshed (no refresh_token,
|
|
48
|
+
endpoint unknown, or the IdP rejects it).
|
|
49
|
+
"""
|
|
50
|
+
endpoint = _token_endpoint(creds)
|
|
51
|
+
if not creds.refresh_token or not creds.client_id or not endpoint:
|
|
52
|
+
raise NeedLogin("session expired — run `login` again")
|
|
53
|
+
data = {
|
|
54
|
+
"grant_type": "refresh_token",
|
|
55
|
+
"refresh_token": creds.refresh_token,
|
|
56
|
+
"client_id": creds.client_id,
|
|
57
|
+
}
|
|
58
|
+
if creds.resource:
|
|
59
|
+
data["resource"] = creds.resource
|
|
60
|
+
try:
|
|
61
|
+
with httpx.Client(timeout=15.0) as c:
|
|
62
|
+
r = c.post(endpoint, data=data)
|
|
63
|
+
except httpx.RequestError as e:
|
|
64
|
+
raise NeedLogin(f"could not reach auth server to refresh: {e}") from e
|
|
65
|
+
if not r.is_success:
|
|
66
|
+
raise NeedLogin("session expired — run `login` again")
|
|
67
|
+
tok = r.json()
|
|
68
|
+
return replace(
|
|
69
|
+
creds,
|
|
70
|
+
access_token=tok["access_token"],
|
|
71
|
+
refresh_token=tok.get("refresh_token") or creds.refresh_token,
|
|
72
|
+
expires_at=int(time.time()) + int(tok.get("expires_in", 3600)),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def valid_access_token(api_url: str) -> str:
|
|
77
|
+
"""Return a non-expired access token for `api_url`.
|
|
78
|
+
|
|
79
|
+
Auto-refreshes (and persists) when the cached token is within the skew
|
|
80
|
+
window of expiry. Raises NeedLogin if there's nothing usable.
|
|
81
|
+
"""
|
|
82
|
+
creds = load_credentials(api_url)
|
|
83
|
+
if creds is None or not creds.access_token:
|
|
84
|
+
raise NeedLogin()
|
|
85
|
+
if creds.expires_at and creds.expires_at - time.time() < _REFRESH_SKEW_SECONDS:
|
|
86
|
+
creds = refresh(creds)
|
|
87
|
+
save_credentials(creds)
|
|
88
|
+
return creds.access_token
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def do_login(discovery_url: str, api_url: str, *, on_code=None) -> Credentials:
|
|
92
|
+
"""Run the full OAuth device-flow and persist the result.
|
|
93
|
+
|
|
94
|
+
`on_code(DeviceCodeResponse)` is invoked once the user code is known —
|
|
95
|
+
the CLI uses it to print the verification URL and open a browser.
|
|
96
|
+
"""
|
|
97
|
+
disc = fetch_discovery(discovery_url)
|
|
98
|
+
code = request_device_code(disc)
|
|
99
|
+
if on_code is not None:
|
|
100
|
+
on_code(code)
|
|
101
|
+
tok = poll_for_token(disc, code)
|
|
102
|
+
creds = Credentials(
|
|
103
|
+
access_token=tok["access_token"],
|
|
104
|
+
refresh_token=tok.get("refresh_token"),
|
|
105
|
+
expires_at=int(time.time()) + int(tok.get("expires_in", 3600)),
|
|
106
|
+
issuer=disc.issuer,
|
|
107
|
+
client_id=disc.client_id,
|
|
108
|
+
resource=disc.resource,
|
|
109
|
+
api_url=api_url,
|
|
110
|
+
token_endpoint=disc.token_endpoint,
|
|
111
|
+
)
|
|
112
|
+
save_credentials(creds)
|
|
113
|
+
return creds
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Thin HTTP client for Paradigx product APIs.
|
|
2
|
+
|
|
3
|
+
`request()` is product-agnostic: the caller passes the base URL, the bearer
|
|
4
|
+
token, and whether the API needs trailing slashes (botu-web is a Next.js
|
|
5
|
+
app with `trailingSlash: true`; the tokenroute FastAPI gateway is not).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApiError(RuntimeError):
|
|
15
|
+
def __init__(self, status: int, message: str, body: Any = None):
|
|
16
|
+
super().__init__(f"HTTP {status}: {message}")
|
|
17
|
+
self.status = status
|
|
18
|
+
self.message = message
|
|
19
|
+
self.body = body
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def exit_code_for(err: ApiError) -> int:
|
|
23
|
+
"""Map an ApiError onto a CLI exit code: 1 user / 2 network / 3 server."""
|
|
24
|
+
if err.status == 0:
|
|
25
|
+
return 2
|
|
26
|
+
return 1 if err.status < 500 else 3
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def with_trailing_slash(path: str) -> str:
|
|
30
|
+
"""Ensure a `/` before any query string — for `trailingSlash: true` APIs."""
|
|
31
|
+
base, sep, query = path.partition("?")
|
|
32
|
+
if not base.endswith("/"):
|
|
33
|
+
base += "/"
|
|
34
|
+
return f"{base}{sep}{query}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _raise(resp: httpx.Response) -> None:
|
|
38
|
+
if resp.is_success:
|
|
39
|
+
return
|
|
40
|
+
try:
|
|
41
|
+
body = resp.json()
|
|
42
|
+
except (ValueError, httpx.DecodingError):
|
|
43
|
+
body = resp.text
|
|
44
|
+
if isinstance(body, dict):
|
|
45
|
+
err = body.get("error")
|
|
46
|
+
if isinstance(err, dict): # {"error": {"message": ...}}
|
|
47
|
+
msg = err.get("message") or resp.reason_phrase
|
|
48
|
+
else: # {"error": "...", "detail"?: "..."}
|
|
49
|
+
msg = err or body.get("detail") or body.get("message") or resp.reason_phrase
|
|
50
|
+
if err and body.get("detail"):
|
|
51
|
+
msg = f"{err} ({body['detail']})"
|
|
52
|
+
else:
|
|
53
|
+
msg = resp.reason_phrase
|
|
54
|
+
raise ApiError(resp.status_code, str(msg), body)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def request(
|
|
58
|
+
method: str,
|
|
59
|
+
base_url: str,
|
|
60
|
+
path: str,
|
|
61
|
+
*,
|
|
62
|
+
token: str,
|
|
63
|
+
json_body: Any | None = None,
|
|
64
|
+
timeout: float = 30.0,
|
|
65
|
+
trailing_slash: bool = False,
|
|
66
|
+
follow_redirects: bool = True,
|
|
67
|
+
user_agent: str = "paradigx-cli-core",
|
|
68
|
+
) -> Any:
|
|
69
|
+
"""Call a product API endpoint with a bearer token. Returns parsed JSON."""
|
|
70
|
+
if trailing_slash:
|
|
71
|
+
path = with_trailing_slash(path)
|
|
72
|
+
headers = {
|
|
73
|
+
"User-Agent": user_agent,
|
|
74
|
+
"Accept": "application/json",
|
|
75
|
+
"Authorization": f"Bearer {token}",
|
|
76
|
+
}
|
|
77
|
+
url = f"{base_url.rstrip('/')}{path}"
|
|
78
|
+
try:
|
|
79
|
+
with httpx.Client(timeout=timeout, follow_redirects=follow_redirects) as client:
|
|
80
|
+
resp = client.request(method, url, json=json_body, headers=headers)
|
|
81
|
+
except httpx.RequestError as e:
|
|
82
|
+
raise ApiError(0, f"network error: {e}") from e
|
|
83
|
+
_raise(resp)
|
|
84
|
+
if resp.status_code == 204 or not resp.content:
|
|
85
|
+
return None
|
|
86
|
+
return resp.json()
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Shared credential storage for Paradigx product CLIs.
|
|
2
|
+
|
|
3
|
+
Tokens live in ``~/.paradigx/auth.json`` — shared across every Paradigx
|
|
4
|
+
CLI (botu, tokenroute, ...). They all authenticate against the same Logto,
|
|
5
|
+
so a single login is reused. Entries are keyed by Logto resource indicator
|
|
6
|
+
(so multiple products / envs coexist); retrieval picks the entry whose
|
|
7
|
+
``api_url`` matches the current target.
|
|
8
|
+
|
|
9
|
+
auth.json shape::
|
|
10
|
+
|
|
11
|
+
{ "version": 1,
|
|
12
|
+
"tokens": {
|
|
13
|
+
"<resource>": { access_token, refresh_token, expires_at, issuer,
|
|
14
|
+
client_id, resource, api_url, token_endpoint } } }
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import stat
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
_CRED_FIELDS = (
|
|
25
|
+
"access_token",
|
|
26
|
+
"refresh_token",
|
|
27
|
+
"expires_at",
|
|
28
|
+
"issuer",
|
|
29
|
+
"client_id",
|
|
30
|
+
"resource",
|
|
31
|
+
"api_url",
|
|
32
|
+
"token_endpoint",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def default_config_dir() -> Path:
|
|
37
|
+
return Path.home() / ".paradigx"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _auth_path() -> Path:
|
|
41
|
+
return default_config_dir() / "auth.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Credentials:
|
|
46
|
+
access_token: str
|
|
47
|
+
refresh_token: str | None = None
|
|
48
|
+
expires_at: int | None = None # unix seconds
|
|
49
|
+
issuer: str | None = None
|
|
50
|
+
client_id: str | None = None
|
|
51
|
+
resource: str | None = None
|
|
52
|
+
api_url: str | None = None
|
|
53
|
+
token_endpoint: str | None = None # OIDC token endpoint, for refresh
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_store() -> dict:
|
|
57
|
+
p = _auth_path()
|
|
58
|
+
if not p.exists():
|
|
59
|
+
return {"version": 1, "tokens": {}}
|
|
60
|
+
try:
|
|
61
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
62
|
+
except (json.JSONDecodeError, OSError):
|
|
63
|
+
return {"version": 1, "tokens": {}}
|
|
64
|
+
if not isinstance(data, dict):
|
|
65
|
+
return {"version": 1, "tokens": {}}
|
|
66
|
+
if not isinstance(data.get("tokens"), dict):
|
|
67
|
+
data["tokens"] = {}
|
|
68
|
+
data.setdefault("version", 1)
|
|
69
|
+
return data
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _write_store(store: dict) -> None:
|
|
73
|
+
d = default_config_dir()
|
|
74
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
p = _auth_path()
|
|
76
|
+
p.write_text(json.dumps(store, indent=2), encoding="utf-8")
|
|
77
|
+
# Owner-only on POSIX; near no-op on Windows but doesn't error.
|
|
78
|
+
try:
|
|
79
|
+
os.chmod(p, stat.S_IRUSR | stat.S_IWUSR)
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def save_credentials(creds: Credentials) -> Path:
|
|
85
|
+
store = _read_store()
|
|
86
|
+
key = creds.resource or creds.api_url or "default"
|
|
87
|
+
store["tokens"][key] = {f: getattr(creds, f) for f in _CRED_FIELDS}
|
|
88
|
+
_write_store(store)
|
|
89
|
+
return _auth_path()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_credentials(api_url: str) -> Credentials | None:
|
|
93
|
+
"""Return the stored credential whose ``api_url`` matches."""
|
|
94
|
+
store = _read_store()
|
|
95
|
+
matches = [
|
|
96
|
+
v for v in store["tokens"].values() if isinstance(v, dict) and v.get("api_url") == api_url
|
|
97
|
+
]
|
|
98
|
+
if not matches:
|
|
99
|
+
return None
|
|
100
|
+
best = max(matches, key=lambda v: v.get("expires_at") or 0)
|
|
101
|
+
return Credentials(**{f: best.get(f) for f in _CRED_FIELDS})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def clear_credentials(api_url: str) -> bool:
|
|
105
|
+
"""Drop credential(s) for ``api_url``. True if anything was removed."""
|
|
106
|
+
store = _read_store()
|
|
107
|
+
kept = {
|
|
108
|
+
k: v
|
|
109
|
+
for k, v in store["tokens"].items()
|
|
110
|
+
if not (isinstance(v, dict) and v.get("api_url") == api_url)
|
|
111
|
+
}
|
|
112
|
+
if len(kept) == len(store["tokens"]):
|
|
113
|
+
return False
|
|
114
|
+
store["tokens"] = kept
|
|
115
|
+
_write_store(store)
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def import_legacy(
|
|
120
|
+
legacy_path: Path,
|
|
121
|
+
api_url: str,
|
|
122
|
+
*,
|
|
123
|
+
token_endpoint: str | None = None,
|
|
124
|
+
resource: str | None = None,
|
|
125
|
+
) -> bool:
|
|
126
|
+
"""Migrate a pre-Phase-B single-credential file into the shared store.
|
|
127
|
+
|
|
128
|
+
Used by tokenroute 0.2.0 to pull ``~/.tokenroute/credentials.json`` into
|
|
129
|
+
``~/.paradigx/auth.json``. No-op if the legacy file is absent or this
|
|
130
|
+
api_url already has an entry. The legacy file is left in place (so a
|
|
131
|
+
downgrade to 0.1.0 still works).
|
|
132
|
+
|
|
133
|
+
`resource` is a fallback for legacy files that predate the field — it's
|
|
134
|
+
required for the OIDC token endpoint to mint JWT (not opaque) access
|
|
135
|
+
tokens on refresh, so the caller must supply its product's resource
|
|
136
|
+
indicator when the legacy file may lack one.
|
|
137
|
+
"""
|
|
138
|
+
if not legacy_path.exists():
|
|
139
|
+
return False
|
|
140
|
+
if load_credentials(api_url) is not None:
|
|
141
|
+
return False
|
|
142
|
+
try:
|
|
143
|
+
data = json.loads(legacy_path.read_text(encoding="utf-8"))
|
|
144
|
+
except (json.JSONDecodeError, OSError):
|
|
145
|
+
return False
|
|
146
|
+
if not isinstance(data, dict) or not data.get("access_token"):
|
|
147
|
+
return False
|
|
148
|
+
save_credentials(
|
|
149
|
+
Credentials(
|
|
150
|
+
access_token=data.get("access_token", ""),
|
|
151
|
+
refresh_token=data.get("refresh_token"),
|
|
152
|
+
expires_at=data.get("expires_at"),
|
|
153
|
+
issuer=data.get("issuer"),
|
|
154
|
+
client_id=data.get("client_id"),
|
|
155
|
+
resource=data.get("resource") or resource,
|
|
156
|
+
api_url=api_url,
|
|
157
|
+
token_endpoint=token_endpoint,
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
return True
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""OIDC device-flow client — talks directly to Logto.
|
|
2
|
+
|
|
3
|
+
The product CLI passes its discovery URL (a product endpoint that returns
|
|
4
|
+
``{issuer, client_id, resource, scopes, device_authorization_endpoint,
|
|
5
|
+
token_endpoint}``). The CLI never hardcodes a Logto URL. This is the
|
|
6
|
+
OIDC-standard pattern: the client connects to the IdP, the resource server
|
|
7
|
+
only validates tokens.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
import webbrowser
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
# Per RFC 8628 §3.5 we honour the server's `interval`; this is a sane floor.
|
|
18
|
+
_MIN_POLL_INTERVAL_SECONDS = 5
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class DiscoveryInfo:
|
|
23
|
+
issuer: str
|
|
24
|
+
client_id: str
|
|
25
|
+
resource: str
|
|
26
|
+
scopes: list[str]
|
|
27
|
+
device_authorization_endpoint: str
|
|
28
|
+
token_endpoint: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class DeviceCodeResponse:
|
|
33
|
+
device_code: str
|
|
34
|
+
user_code: str
|
|
35
|
+
verification_uri: str
|
|
36
|
+
verification_uri_complete: str
|
|
37
|
+
expires_in: int
|
|
38
|
+
interval: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fetch_discovery(discovery_url: str) -> DiscoveryInfo:
|
|
42
|
+
"""GET the product's auth-discovery endpoint."""
|
|
43
|
+
with httpx.Client(timeout=10.0, follow_redirects=True) as c:
|
|
44
|
+
r = c.get(discovery_url)
|
|
45
|
+
r.raise_for_status()
|
|
46
|
+
data = r.json()
|
|
47
|
+
return DiscoveryInfo(
|
|
48
|
+
issuer=data["issuer"],
|
|
49
|
+
client_id=data["client_id"],
|
|
50
|
+
resource=data["resource"],
|
|
51
|
+
scopes=data["scopes"],
|
|
52
|
+
device_authorization_endpoint=data["device_authorization_endpoint"],
|
|
53
|
+
token_endpoint=data["token_endpoint"],
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def request_device_code(disc: DiscoveryInfo) -> DeviceCodeResponse:
|
|
58
|
+
"""POST {device_authorization_endpoint} → device_code + user_code."""
|
|
59
|
+
with httpx.Client(timeout=10.0) as c:
|
|
60
|
+
r = c.post(
|
|
61
|
+
disc.device_authorization_endpoint,
|
|
62
|
+
data={
|
|
63
|
+
"client_id": disc.client_id,
|
|
64
|
+
"scope": " ".join(disc.scopes),
|
|
65
|
+
"resource": disc.resource,
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
r.raise_for_status()
|
|
69
|
+
body = r.json()
|
|
70
|
+
return DeviceCodeResponse(
|
|
71
|
+
device_code=body["device_code"],
|
|
72
|
+
user_code=body["user_code"],
|
|
73
|
+
verification_uri=body["verification_uri"],
|
|
74
|
+
verification_uri_complete=body.get(
|
|
75
|
+
"verification_uri_complete", body["verification_uri"]
|
|
76
|
+
),
|
|
77
|
+
expires_in=body["expires_in"],
|
|
78
|
+
interval=max(body.get("interval", 5), _MIN_POLL_INTERVAL_SECONDS),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def open_browser(url: str) -> bool:
|
|
83
|
+
"""Best-effort. False on headless environments with no display."""
|
|
84
|
+
try:
|
|
85
|
+
return webbrowser.open(url)
|
|
86
|
+
except webbrowser.Error:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def poll_for_token(disc: DiscoveryInfo, code: DeviceCodeResponse, *, on_pending=None) -> dict:
|
|
91
|
+
"""Poll {token_endpoint} until success / expired / denied.
|
|
92
|
+
|
|
93
|
+
Returns the raw token response dict. Raises RuntimeError with a
|
|
94
|
+
human-readable message on failure.
|
|
95
|
+
"""
|
|
96
|
+
deadline = time.time() + code.expires_in
|
|
97
|
+
interval = code.interval
|
|
98
|
+
with httpx.Client(timeout=10.0) as client:
|
|
99
|
+
while time.time() < deadline:
|
|
100
|
+
time.sleep(interval)
|
|
101
|
+
r = client.post(
|
|
102
|
+
disc.token_endpoint,
|
|
103
|
+
data={
|
|
104
|
+
"client_id": disc.client_id,
|
|
105
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
106
|
+
"device_code": code.device_code,
|
|
107
|
+
"resource": disc.resource,
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
if r.is_success:
|
|
111
|
+
return r.json()
|
|
112
|
+
err = (
|
|
113
|
+
r.json().get("error")
|
|
114
|
+
if r.headers.get("content-type", "").startswith("application/json")
|
|
115
|
+
else None
|
|
116
|
+
)
|
|
117
|
+
if err == "authorization_pending":
|
|
118
|
+
if on_pending is not None:
|
|
119
|
+
on_pending()
|
|
120
|
+
continue
|
|
121
|
+
if err == "slow_down":
|
|
122
|
+
interval += 5
|
|
123
|
+
continue
|
|
124
|
+
if err == "expired_token":
|
|
125
|
+
raise RuntimeError("device code expired — log in again")
|
|
126
|
+
if err == "access_denied":
|
|
127
|
+
raise RuntimeError("login denied by user")
|
|
128
|
+
raise RuntimeError(f"token exchange failed ({r.status_code}): {r.text}")
|
|
129
|
+
raise RuntimeError("device code expired before user completed login")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Output helpers — toggle between human-friendly rich tables and --json.
|
|
2
|
+
|
|
3
|
+
JSON mode is process-global, set by the product CLI's Typer callback via
|
|
4
|
+
`set_json_mode()` and read everywhere via `is_json_mode()`.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
_JSON_ENV = "_PARADIGX_CLI_JSON"
|
|
17
|
+
_console = Console()
|
|
18
|
+
_err_console = Console(stderr=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def set_json_mode(enabled: bool) -> None:
|
|
22
|
+
if enabled:
|
|
23
|
+
os.environ[_JSON_ENV] = "1"
|
|
24
|
+
else:
|
|
25
|
+
os.environ.pop(_JSON_ENV, None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_json_mode() -> bool:
|
|
29
|
+
return os.environ.get(_JSON_ENV) == "1"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def emit(payload: Any, *, table_columns: list[tuple[str, str]] | None = None) -> None:
|
|
33
|
+
"""Print `payload` as JSON (agent mode) or a rich table / kv list (human).
|
|
34
|
+
|
|
35
|
+
`table_columns` is a list of `(header, dict_key)` pairs used when payload
|
|
36
|
+
is a list. Single dicts render as a key/value list.
|
|
37
|
+
"""
|
|
38
|
+
if is_json_mode():
|
|
39
|
+
print(json.dumps(payload, indent=2, default=str))
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
if isinstance(payload, list) and table_columns:
|
|
43
|
+
table = Table(show_header=True, header_style="bold")
|
|
44
|
+
for header, _ in table_columns:
|
|
45
|
+
table.add_column(header)
|
|
46
|
+
for row in payload:
|
|
47
|
+
table.add_row(*[str(row.get(k, "")) for _, k in table_columns])
|
|
48
|
+
_console.print(table)
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
if isinstance(payload, dict):
|
|
52
|
+
for k, v in payload.items():
|
|
53
|
+
_console.print(f"[bold]{k}[/bold]: {v}")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
_console.print(payload)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def info(msg: str) -> None:
|
|
60
|
+
if not is_json_mode():
|
|
61
|
+
_console.print(msg)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def success(msg: str) -> None:
|
|
65
|
+
if not is_json_mode():
|
|
66
|
+
# ASCII-safe marker — Windows legacy GBK consoles can't encode U+2713.
|
|
67
|
+
_console.print(f"[green]OK[/green] {msg}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def error(msg: str, *, code: int = 1) -> None:
|
|
71
|
+
"""Print an error and exit. code: 1 user / 2 network / 3 server."""
|
|
72
|
+
if is_json_mode():
|
|
73
|
+
print(json.dumps({"error": msg}, indent=2))
|
|
74
|
+
else:
|
|
75
|
+
_err_console.print(f"[red]error[/red]: {msg}")
|
|
76
|
+
sys.exit(code)
|
|
File without changes
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Tests for paradigx-cli-core.
|
|
2
|
+
|
|
3
|
+
`Path.home` is redirected to tmp_path so the real ~/.paradigx/ is never
|
|
4
|
+
touched; httpx is monkeypatched so no real network calls happen.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest.mock import MagicMock
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from paradigx_cli_core import auth as auth_mod
|
|
16
|
+
from paradigx_cli_core import client as client_mod
|
|
17
|
+
from paradigx_cli_core import config as config_mod
|
|
18
|
+
from paradigx_cli_core import output as output_mod
|
|
19
|
+
from paradigx_cli_core.config import Credentials
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def fake_home(tmp_path, monkeypatch):
|
|
24
|
+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
25
|
+
return tmp_path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture(autouse=True)
|
|
29
|
+
def _clean_json_mode(monkeypatch):
|
|
30
|
+
monkeypatch.delenv("_PARADIGX_CLI_JSON", raising=False)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _mock_httpx(monkeypatch, *, status=200, json_body=None):
|
|
34
|
+
"""Patch httpx.Client so .post / .request return a canned response."""
|
|
35
|
+
import httpx
|
|
36
|
+
|
|
37
|
+
resp = MagicMock(spec=httpx.Response)
|
|
38
|
+
resp.is_success = 200 <= status < 300
|
|
39
|
+
resp.status_code = status
|
|
40
|
+
resp.json.return_value = json_body or {}
|
|
41
|
+
resp.text = json.dumps(json_body) if json_body is not None else ""
|
|
42
|
+
resp.content = (resp.text or "x").encode()
|
|
43
|
+
resp.headers = {"content-type": "application/json"}
|
|
44
|
+
resp.reason_phrase = "OK" if resp.is_success else "Error"
|
|
45
|
+
|
|
46
|
+
client = MagicMock()
|
|
47
|
+
client.__enter__ = MagicMock(return_value=client)
|
|
48
|
+
client.__exit__ = MagicMock(return_value=False)
|
|
49
|
+
client.post = MagicMock(return_value=resp)
|
|
50
|
+
client.request = MagicMock(return_value=resp)
|
|
51
|
+
monkeypatch.setattr("httpx.Client", lambda **kw: client)
|
|
52
|
+
return client
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ─── config ──────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_credentials_roundtrip(fake_home):
|
|
59
|
+
config_mod.save_credentials(
|
|
60
|
+
Credentials(access_token="abc", refresh_token="r", expires_at=123,
|
|
61
|
+
resource="res-1", api_url="https://botu.io")
|
|
62
|
+
)
|
|
63
|
+
loaded = config_mod.load_credentials("https://botu.io")
|
|
64
|
+
assert loaded.access_token == "abc"
|
|
65
|
+
assert loaded.refresh_token == "r"
|
|
66
|
+
assert loaded.resource == "res-1"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_load_none_when_empty(fake_home):
|
|
70
|
+
assert config_mod.load_credentials("https://botu.io") is None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_store_separates_by_api_url(fake_home):
|
|
74
|
+
config_mod.save_credentials(
|
|
75
|
+
Credentials(access_token="botu-tok", resource="r1", api_url="https://botu.io")
|
|
76
|
+
)
|
|
77
|
+
config_mod.save_credentials(
|
|
78
|
+
Credentials(access_token="tr-tok", resource="r2", api_url="https://api.tokenroute.io")
|
|
79
|
+
)
|
|
80
|
+
assert config_mod.load_credentials("https://botu.io").access_token == "botu-tok"
|
|
81
|
+
assert config_mod.load_credentials("https://api.tokenroute.io").access_token == "tr-tok"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_clear_credentials(fake_home):
|
|
85
|
+
config_mod.save_credentials(Credentials(access_token="x", resource="r", api_url="https://botu.io"))
|
|
86
|
+
assert config_mod.clear_credentials("https://botu.io") is True
|
|
87
|
+
assert config_mod.load_credentials("https://botu.io") is None
|
|
88
|
+
assert config_mod.clear_credentials("https://botu.io") is False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_import_legacy(fake_home, tmp_path):
|
|
92
|
+
legacy = tmp_path / "credentials.json"
|
|
93
|
+
legacy.write_text(json.dumps({
|
|
94
|
+
"access_token": "old-tok", "refresh_token": "old-ref",
|
|
95
|
+
"expires_at": 999, "issuer": "https://auth.paradigx.com", "client_id": "c1",
|
|
96
|
+
}), encoding="utf-8")
|
|
97
|
+
assert config_mod.import_legacy(
|
|
98
|
+
legacy, "https://api.tokenroute.io", token_endpoint="https://auth.paradigx.com/oidc/token"
|
|
99
|
+
) is True
|
|
100
|
+
got = config_mod.load_credentials("https://api.tokenroute.io")
|
|
101
|
+
assert got.access_token == "old-tok"
|
|
102
|
+
assert got.token_endpoint == "https://auth.paradigx.com/oidc/token"
|
|
103
|
+
# idempotent — second call is a no-op
|
|
104
|
+
assert config_mod.import_legacy(legacy, "https://api.tokenroute.io") is False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_import_legacy_missing_file(fake_home, tmp_path):
|
|
108
|
+
assert config_mod.import_legacy(tmp_path / "nope.json", "https://botu.io") is False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_import_legacy_resource_fallback(fake_home, tmp_path):
|
|
112
|
+
# pre-0.2 files predate the `resource` field — the caller supplies it so
|
|
113
|
+
# refresh keeps minting JWT (not opaque) tokens.
|
|
114
|
+
legacy = tmp_path / "credentials.json"
|
|
115
|
+
legacy.write_text(
|
|
116
|
+
json.dumps({"access_token": "t", "refresh_token": "r", "issuer": "https://i"}),
|
|
117
|
+
encoding="utf-8",
|
|
118
|
+
)
|
|
119
|
+
config_mod.import_legacy(
|
|
120
|
+
legacy, "https://api.tokenroute.io", resource="https://api.tokenroute.io"
|
|
121
|
+
)
|
|
122
|
+
got = config_mod.load_credentials("https://api.tokenroute.io")
|
|
123
|
+
assert got.resource == "https://api.tokenroute.io"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ─── auth: token endpoint resolution + refresh ───────────────────────
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_token_endpoint_prefers_stored():
|
|
130
|
+
c = Credentials(access_token="x", token_endpoint="https://e/token", issuer="https://i")
|
|
131
|
+
assert auth_mod._token_endpoint(c) == "https://e/token"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_token_endpoint_derives_botu_style():
|
|
135
|
+
# botu issuer already ends with /oidc
|
|
136
|
+
c = Credentials(access_token="x", issuer="https://auth.paradigx.com/oidc")
|
|
137
|
+
assert auth_mod._token_endpoint(c) == "https://auth.paradigx.com/oidc/token"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_token_endpoint_derives_tokenroute_style():
|
|
141
|
+
# tokenroute issuer has no /oidc
|
|
142
|
+
c = Credentials(access_token="x", issuer="https://auth.paradigx.com")
|
|
143
|
+
assert auth_mod._token_endpoint(c) == "https://auth.paradigx.com/oidc/token"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_refresh_success(fake_home, monkeypatch):
|
|
147
|
+
_mock_httpx(monkeypatch, status=200, json_body={"access_token": "new-tok", "expires_in": 3600})
|
|
148
|
+
creds = Credentials(access_token="old", refresh_token="r", client_id="c",
|
|
149
|
+
token_endpoint="https://e/token", api_url="https://botu.io")
|
|
150
|
+
out = auth_mod.refresh(creds)
|
|
151
|
+
assert out.access_token == "new-tok"
|
|
152
|
+
assert out.refresh_token == "r" # kept when response omits a new one
|
|
153
|
+
assert out.expires_at > time.time()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_refresh_rejected_raises_needlogin(fake_home, monkeypatch):
|
|
157
|
+
_mock_httpx(monkeypatch, status=400, json_body={"error": "invalid_grant"})
|
|
158
|
+
creds = Credentials(access_token="old", refresh_token="r", client_id="c",
|
|
159
|
+
token_endpoint="https://e/token")
|
|
160
|
+
with pytest.raises(auth_mod.NeedLogin):
|
|
161
|
+
auth_mod.refresh(creds)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_refresh_no_refresh_token_raises():
|
|
165
|
+
with pytest.raises(auth_mod.NeedLogin):
|
|
166
|
+
auth_mod.refresh(Credentials(access_token="x", token_endpoint="https://e/token"))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ─── auth: valid_access_token ────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_valid_access_token_fresh(fake_home):
|
|
173
|
+
config_mod.save_credentials(Credentials(
|
|
174
|
+
access_token="fresh", expires_at=int(time.time()) + 3600, api_url="https://botu.io"))
|
|
175
|
+
assert auth_mod.valid_access_token("https://botu.io") == "fresh"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_valid_access_token_none_raises(fake_home):
|
|
179
|
+
with pytest.raises(auth_mod.NeedLogin):
|
|
180
|
+
auth_mod.valid_access_token("https://botu.io")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_valid_access_token_expired_refreshes(fake_home, monkeypatch):
|
|
184
|
+
config_mod.save_credentials(Credentials(
|
|
185
|
+
access_token="stale", refresh_token="r", client_id="c",
|
|
186
|
+
token_endpoint="https://e/token",
|
|
187
|
+
expires_at=int(time.time()) - 10, api_url="https://botu.io"))
|
|
188
|
+
_mock_httpx(monkeypatch, status=200, json_body={"access_token": "refreshed", "expires_in": 3600})
|
|
189
|
+
assert auth_mod.valid_access_token("https://botu.io") == "refreshed"
|
|
190
|
+
# persisted
|
|
191
|
+
assert config_mod.load_credentials("https://botu.io").access_token == "refreshed"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ─── auth: do_login ──────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_do_login(fake_home, monkeypatch):
|
|
198
|
+
from paradigx_cli_core.device_flow import DeviceCodeResponse, DiscoveryInfo
|
|
199
|
+
|
|
200
|
+
disc = DiscoveryInfo(
|
|
201
|
+
issuer="https://auth.paradigx.com/oidc", client_id="cli", resource="res",
|
|
202
|
+
scopes=["openid"], device_authorization_endpoint="https://e/device",
|
|
203
|
+
token_endpoint="https://auth.paradigx.com/oidc/token")
|
|
204
|
+
code = DeviceCodeResponse("dc", "USER-1", "https://v", "https://v?c=1", 600, 5)
|
|
205
|
+
monkeypatch.setattr(auth_mod, "fetch_discovery", lambda u: disc)
|
|
206
|
+
monkeypatch.setattr(auth_mod, "request_device_code", lambda d: code)
|
|
207
|
+
monkeypatch.setattr(auth_mod, "poll_for_token", lambda d, c: {
|
|
208
|
+
"access_token": "jwt", "refresh_token": "rt", "expires_in": 3600})
|
|
209
|
+
|
|
210
|
+
seen = []
|
|
211
|
+
creds = auth_mod.do_login("https://botu.io/api/auth/discovery/", "https://botu.io",
|
|
212
|
+
on_code=lambda c: seen.append(c.user_code))
|
|
213
|
+
assert seen == ["USER-1"]
|
|
214
|
+
assert creds.access_token == "jwt"
|
|
215
|
+
assert creds.token_endpoint == "https://auth.paradigx.com/oidc/token"
|
|
216
|
+
assert config_mod.load_credentials("https://botu.io").access_token == "jwt"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ─── client ──────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_with_trailing_slash():
|
|
223
|
+
assert client_mod.with_trailing_slash("/api/sites") == "/api/sites/"
|
|
224
|
+
assert client_mod.with_trailing_slash("/api/sites/") == "/api/sites/"
|
|
225
|
+
assert client_mod.with_trailing_slash("/api/usage?site=s") == "/api/usage/?site=s"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_exit_code_for():
|
|
229
|
+
assert client_mod.exit_code_for(client_mod.ApiError(0, "net")) == 2
|
|
230
|
+
assert client_mod.exit_code_for(client_mod.ApiError(404, "nf")) == 1
|
|
231
|
+
assert client_mod.exit_code_for(client_mod.ApiError(500, "boom")) == 3
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_request_success(monkeypatch):
|
|
235
|
+
_mock_httpx(monkeypatch, status=200, json_body={"ok": True})
|
|
236
|
+
out = client_mod.request("GET", "https://botu.io", "/api/sites", token="t", trailing_slash=True)
|
|
237
|
+
assert out == {"ok": True}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_request_error_raises_apierror(monkeypatch):
|
|
241
|
+
_mock_httpx(monkeypatch, status=404, json_body={"error": "not_found", "detail": "no site"})
|
|
242
|
+
with pytest.raises(client_mod.ApiError) as ei:
|
|
243
|
+
client_mod.request("GET", "https://botu.io", "/api/sites/x", token="t")
|
|
244
|
+
assert ei.value.status == 404
|
|
245
|
+
assert "not_found" in ei.value.message and "no site" in ei.value.message
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ─── output ──────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_json_mode_toggle():
|
|
252
|
+
assert output_mod.is_json_mode() is False
|
|
253
|
+
output_mod.set_json_mode(True)
|
|
254
|
+
assert output_mod.is_json_mode() is True
|
|
255
|
+
output_mod.set_json_mode(False)
|
|
256
|
+
assert output_mod.is_json_mode() is False
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_emit_json(capsys):
|
|
260
|
+
output_mod.set_json_mode(True)
|
|
261
|
+
try:
|
|
262
|
+
output_mod.emit({"a": 1})
|
|
263
|
+
finally:
|
|
264
|
+
output_mod.set_json_mode(False)
|
|
265
|
+
assert json.loads(capsys.readouterr().out.strip()) == {"a": 1}
|