openapi-cli4ai 0.3.0__tar.gz → 0.4.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.
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/PKG-INFO +1 -1
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/pyproject.toml +1 -1
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/src/openapi_cli4ai/cli.py +490 -46
- openapi_cli4ai-0.4.0/tests/test_auth_flows.py +1271 -0
- openapi_cli4ai-0.4.0/tests/test_commands.py +1414 -0
- openapi_cli4ai-0.4.0/tests/test_coverage_gaps.py +1125 -0
- openapi_cli4ai-0.4.0/tests/test_device_flow.py +635 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/.github/CODEOWNERS +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/.github/dependabot.yml +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/.github/release.yml +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/.github/workflows/ci.yml +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/.github/workflows/codeql.yml +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/.github/workflows/publish.yml +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/.gitignore +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/CHANGELOG.md +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/CONTRIBUTING.md +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/LICENSE +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/README.md +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/SECURITY.md +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/demo.gif +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/examples/profiles.toml.example +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/openapi-cli4ai +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/src/openapi_cli4ai/__init__.py +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/src/openapi_cli4ai/__main__.py +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/__init__.py +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/conftest.py +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/fixtures/petstore_spec.json +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/test_auth.py +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/test_caching.py +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/test_integration.py +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/test_oidc.py +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/test_profile_management.py +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/test_run_command.py +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/test_spec_parsing.py +0 -0
- {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/test_sse_streaming.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openapi-cli4ai
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Turn any REST API with an OpenAPI spec into an AI-ready CLI
|
|
5
5
|
Project-URL: Homepage, https://github.com/dbgorilla/openapi-cli4ai
|
|
6
6
|
Project-URL: Repository, https://github.com/dbgorilla/openapi-cli4ai
|
|
@@ -23,6 +23,7 @@ Environment variables:
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
25
|
import base64
|
|
26
|
+
import binascii
|
|
26
27
|
import hashlib
|
|
27
28
|
import json
|
|
28
29
|
import os
|
|
@@ -51,7 +52,7 @@ from rich.panel import Panel # noqa: E402
|
|
|
51
52
|
from rich.table import Table # noqa: E402
|
|
52
53
|
|
|
53
54
|
# ── Constants ──────────────────────────────────────────────────────────────────
|
|
54
|
-
VERSION = "0.
|
|
55
|
+
VERSION = "0.4.0"
|
|
55
56
|
APP_NAME = "openapi-cli4ai"
|
|
56
57
|
CONFIG_FILE = Path.home() / ".openapi-cli4ai.toml"
|
|
57
58
|
CACHE_DIR = Path.home() / ".cache" / APP_NAME
|
|
@@ -380,7 +381,7 @@ def get_auth_headers(profile: dict, quiet: bool = False) -> dict:
|
|
|
380
381
|
return {}
|
|
381
382
|
elif auth_type == "bearer":
|
|
382
383
|
return _bearer_auth(profile, auth_config, quiet)
|
|
383
|
-
elif auth_type
|
|
384
|
+
elif auth_type in ("oidc", "device", "auto"):
|
|
384
385
|
return _oidc_auth(profile, auth_config, quiet)
|
|
385
386
|
elif auth_type == "api-key":
|
|
386
387
|
return _api_key_auth(auth_config, quiet)
|
|
@@ -578,7 +579,13 @@ class _OIDCCallbackHandler(BaseHTTPRequestHandler):
|
|
|
578
579
|
pass # Suppress HTTP server logging
|
|
579
580
|
|
|
580
581
|
|
|
581
|
-
def _oidc_login(
|
|
582
|
+
def _oidc_login(
|
|
583
|
+
auth_config: dict,
|
|
584
|
+
profile_name: str,
|
|
585
|
+
no_browser: bool = False,
|
|
586
|
+
verify: bool = True,
|
|
587
|
+
base_url: str = "",
|
|
588
|
+
) -> None:
|
|
582
589
|
"""Run OIDC Authorization Code + PKCE flow.
|
|
583
590
|
|
|
584
591
|
With browser (default): opens browser, listens on localhost for callback.
|
|
@@ -628,6 +635,8 @@ def _oidc_login(auth_config: dict, profile_name: str, no_browser: bool = False,
|
|
|
628
635
|
code_verifier=code_verifier,
|
|
629
636
|
profile_name=profile_name,
|
|
630
637
|
verify=verify,
|
|
638
|
+
auth_config=auth_config,
|
|
639
|
+
base_url=base_url,
|
|
631
640
|
)
|
|
632
641
|
|
|
633
642
|
|
|
@@ -692,6 +701,8 @@ def _oidc_exchange_code(
|
|
|
692
701
|
code_verifier: str,
|
|
693
702
|
profile_name: str,
|
|
694
703
|
verify: bool = True,
|
|
704
|
+
auth_config: dict | None = None,
|
|
705
|
+
base_url: str = "",
|
|
695
706
|
) -> None:
|
|
696
707
|
"""Exchange an authorization code for tokens and cache them."""
|
|
697
708
|
try:
|
|
@@ -713,6 +724,11 @@ def _oidc_exchange_code(
|
|
|
713
724
|
raise typer.Exit(1)
|
|
714
725
|
|
|
715
726
|
token_data = resp.json()
|
|
727
|
+
|
|
728
|
+
# Two-phase auth: exchange IdP tokens for local API tokens
|
|
729
|
+
if auth_config and auth_config.get("token_exchange_endpoint") and base_url:
|
|
730
|
+
token_data = _token_exchange(token_data, auth_config, base_url, verify=verify)
|
|
731
|
+
|
|
716
732
|
if "expires_in" in token_data:
|
|
717
733
|
token_data["expires_at"] = time.time() + token_data["expires_in"]
|
|
718
734
|
elif "expires_at" not in token_data:
|
|
@@ -731,6 +747,228 @@ def _oidc_exchange_code(
|
|
|
731
747
|
raise typer.Exit(1)
|
|
732
748
|
|
|
733
749
|
|
|
750
|
+
# ── Token Exchange (two-phase auth) ───────────────────────────────────────────
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _token_exchange(
|
|
754
|
+
token_data: dict,
|
|
755
|
+
auth_config: dict,
|
|
756
|
+
base_url: str,
|
|
757
|
+
verify: bool = True,
|
|
758
|
+
) -> dict:
|
|
759
|
+
"""Exchange IdP tokens for local API tokens via a token exchange endpoint.
|
|
760
|
+
|
|
761
|
+
If token_exchange_endpoint is configured, POST the IdP tokens to it and
|
|
762
|
+
return the exchange response. Otherwise, return the original token_data.
|
|
763
|
+
"""
|
|
764
|
+
exchange_endpoint = auth_config.get("token_exchange_endpoint")
|
|
765
|
+
if not exchange_endpoint:
|
|
766
|
+
return token_data
|
|
767
|
+
|
|
768
|
+
body_template = auth_config.get(
|
|
769
|
+
"token_exchange_body",
|
|
770
|
+
'{"access_token": "{access_token}"}',
|
|
771
|
+
)
|
|
772
|
+
body_str = body_template.replace("{access_token}", token_data.get("access_token", "")).replace(
|
|
773
|
+
"{refresh_token}", token_data.get("refresh_token", "")
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
exchange_url = f"{base_url.rstrip('/')}{exchange_endpoint}"
|
|
777
|
+
try:
|
|
778
|
+
with httpx.Client(verify=verify, timeout=30.0) as client:
|
|
779
|
+
resp = client.post(
|
|
780
|
+
exchange_url,
|
|
781
|
+
content=body_str,
|
|
782
|
+
headers={"Content-Type": "application/json"},
|
|
783
|
+
)
|
|
784
|
+
if resp.status_code != 200:
|
|
785
|
+
console.print(f"[red]Token exchange failed ({resp.status_code}):[/red]")
|
|
786
|
+
console.print(resp.text)
|
|
787
|
+
raise typer.Exit(1)
|
|
788
|
+
return resp.json()
|
|
789
|
+
except httpx.ConnectError:
|
|
790
|
+
console.print(f"[red]Cannot connect to {exchange_url}[/red]")
|
|
791
|
+
raise typer.Exit(1)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
# ── OAuth 2.0 Device Authorization Flow (RFC 8628) ───────────────────────────
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _device_discover_endpoints(auth_config: dict, verify: bool = True) -> dict:
|
|
798
|
+
"""Discover device authorization and token endpoints.
|
|
799
|
+
|
|
800
|
+
Priority: device_config_url > issuer_url (well-known) > explicit endpoints.
|
|
801
|
+
Returns dict with device_authorization_endpoint, token_endpoint, client_id.
|
|
802
|
+
"""
|
|
803
|
+
device_config_url = auth_config.get("device_config_url")
|
|
804
|
+
issuer_url = auth_config.get("issuer_url")
|
|
805
|
+
|
|
806
|
+
if device_config_url:
|
|
807
|
+
try:
|
|
808
|
+
with httpx.Client(verify=verify, timeout=15.0) as client:
|
|
809
|
+
resp = client.get(device_config_url)
|
|
810
|
+
if resp.status_code == 200:
|
|
811
|
+
config = resp.json()
|
|
812
|
+
return {
|
|
813
|
+
"device_authorization_endpoint": config["device_authorization_endpoint"],
|
|
814
|
+
"token_endpoint": config["token_endpoint"],
|
|
815
|
+
"client_id": config.get("client_id", auth_config.get("client_id", "")),
|
|
816
|
+
}
|
|
817
|
+
except (httpx.HTTPError, KeyError, json.JSONDecodeError) as e:
|
|
818
|
+
console.print(f"[red]Failed to fetch device config from {device_config_url}: {e}[/red]")
|
|
819
|
+
raise typer.Exit(1)
|
|
820
|
+
|
|
821
|
+
if issuer_url:
|
|
822
|
+
well_known_url = f"{issuer_url.rstrip('/')}/.well-known/openid-configuration"
|
|
823
|
+
try:
|
|
824
|
+
with httpx.Client(verify=verify, timeout=15.0) as client:
|
|
825
|
+
resp = client.get(well_known_url)
|
|
826
|
+
if resp.status_code == 200:
|
|
827
|
+
oidc_config = resp.json()
|
|
828
|
+
device_ep = oidc_config.get("device_authorization_endpoint")
|
|
829
|
+
token_ep = oidc_config.get("token_endpoint")
|
|
830
|
+
if not device_ep:
|
|
831
|
+
console.print(f"[red]Issuer {issuer_url} does not advertise device_authorization_endpoint.[/red]")
|
|
832
|
+
raise typer.Exit(1)
|
|
833
|
+
return {
|
|
834
|
+
"device_authorization_endpoint": device_ep,
|
|
835
|
+
"token_endpoint": token_ep,
|
|
836
|
+
"client_id": auth_config.get("client_id", ""),
|
|
837
|
+
}
|
|
838
|
+
except (httpx.HTTPError, json.JSONDecodeError) as e:
|
|
839
|
+
console.print(f"[red]Failed to fetch OIDC discovery from {well_known_url}: {e}[/red]")
|
|
840
|
+
raise typer.Exit(1)
|
|
841
|
+
|
|
842
|
+
# Explicit endpoints
|
|
843
|
+
device_ep = auth_config.get("device_authorization_endpoint")
|
|
844
|
+
token_ep = auth_config.get("token_endpoint")
|
|
845
|
+
client_id = auth_config.get("client_id", "")
|
|
846
|
+
if not device_ep or not token_ep:
|
|
847
|
+
console.print(
|
|
848
|
+
"[red]Device auth requires device_config_url, issuer_url, or explicit "
|
|
849
|
+
"device_authorization_endpoint + token_endpoint.[/red]"
|
|
850
|
+
)
|
|
851
|
+
raise typer.Exit(1)
|
|
852
|
+
return {
|
|
853
|
+
"device_authorization_endpoint": device_ep,
|
|
854
|
+
"token_endpoint": token_ep,
|
|
855
|
+
"client_id": client_id,
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _device_login(
|
|
860
|
+
auth_config: dict, profile_name: str, profile: dict, no_browser: bool = False, verify: bool = True
|
|
861
|
+
) -> None:
|
|
862
|
+
"""Run OAuth 2.0 Device Authorization Grant (RFC 8628) flow."""
|
|
863
|
+
endpoints = _device_discover_endpoints(auth_config, verify=verify)
|
|
864
|
+
device_ep = endpoints["device_authorization_endpoint"]
|
|
865
|
+
token_ep = endpoints["token_endpoint"]
|
|
866
|
+
client_id = endpoints.get("client_id") or auth_config.get("client_id", "")
|
|
867
|
+
scopes = auth_config.get("scopes", "openid")
|
|
868
|
+
|
|
869
|
+
if not client_id:
|
|
870
|
+
console.print("[red]Device auth requires client_id.[/red]")
|
|
871
|
+
raise typer.Exit(1)
|
|
872
|
+
|
|
873
|
+
# Step 1: Request device code
|
|
874
|
+
try:
|
|
875
|
+
with httpx.Client(verify=verify, timeout=30.0) as client:
|
|
876
|
+
resp = client.post(
|
|
877
|
+
device_ep,
|
|
878
|
+
data={"client_id": client_id, "scope": scopes},
|
|
879
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
880
|
+
)
|
|
881
|
+
if resp.status_code != 200:
|
|
882
|
+
console.print(f"[red]Device authorization request failed ({resp.status_code}):[/red]")
|
|
883
|
+
console.print(resp.text)
|
|
884
|
+
raise typer.Exit(1)
|
|
885
|
+
except httpx.ConnectError:
|
|
886
|
+
console.print(f"[red]Cannot connect to {device_ep}[/red]")
|
|
887
|
+
raise typer.Exit(1)
|
|
888
|
+
|
|
889
|
+
device_data = resp.json()
|
|
890
|
+
device_code = device_data["device_code"]
|
|
891
|
+
user_code = device_data["user_code"]
|
|
892
|
+
verification_uri = device_data["verification_uri"]
|
|
893
|
+
verification_uri_complete = device_data.get("verification_uri_complete")
|
|
894
|
+
expires_in = device_data.get("expires_in", 600)
|
|
895
|
+
interval = device_data.get("interval", 5)
|
|
896
|
+
|
|
897
|
+
# Step 2: Display instructions
|
|
898
|
+
display_uri = verification_uri_complete or verification_uri
|
|
899
|
+
console.print(f"\n[bold]Open this URL in your browser:[/bold]\n {display_uri}\n")
|
|
900
|
+
console.print(f"[bold]Code:[/bold] {user_code}")
|
|
901
|
+
console.print("[dim]Waiting for authorization...[/dim]\n")
|
|
902
|
+
|
|
903
|
+
if not no_browser:
|
|
904
|
+
webbrowser.open(display_uri)
|
|
905
|
+
|
|
906
|
+
# Step 3: Poll for token
|
|
907
|
+
deadline = time.time() + expires_in
|
|
908
|
+
with httpx.Client(verify=verify, timeout=30.0) as client:
|
|
909
|
+
while time.time() < deadline:
|
|
910
|
+
time.sleep(interval)
|
|
911
|
+
try:
|
|
912
|
+
poll_resp = client.post(
|
|
913
|
+
token_ep,
|
|
914
|
+
data={
|
|
915
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
916
|
+
"client_id": client_id,
|
|
917
|
+
"device_code": device_code,
|
|
918
|
+
},
|
|
919
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
920
|
+
)
|
|
921
|
+
except httpx.HTTPError:
|
|
922
|
+
continue
|
|
923
|
+
|
|
924
|
+
if poll_resp.status_code == 200:
|
|
925
|
+
token_data = poll_resp.json()
|
|
926
|
+
break
|
|
927
|
+
else:
|
|
928
|
+
try:
|
|
929
|
+
error_data = poll_resp.json()
|
|
930
|
+
error_code = error_data.get("error", "")
|
|
931
|
+
except (json.JSONDecodeError, ValueError):
|
|
932
|
+
continue
|
|
933
|
+
|
|
934
|
+
if error_code == "authorization_pending":
|
|
935
|
+
continue
|
|
936
|
+
elif error_code == "slow_down":
|
|
937
|
+
interval += 5
|
|
938
|
+
continue
|
|
939
|
+
elif error_code == "expired_token":
|
|
940
|
+
console.print("[red]Device code expired. Please try again.[/red]")
|
|
941
|
+
raise typer.Exit(1)
|
|
942
|
+
elif error_code == "access_denied":
|
|
943
|
+
console.print("[red]Authorization was denied.[/red]")
|
|
944
|
+
raise typer.Exit(1)
|
|
945
|
+
else:
|
|
946
|
+
console.print(f"[red]Device flow error: {error_code}[/red]")
|
|
947
|
+
raise typer.Exit(1)
|
|
948
|
+
else:
|
|
949
|
+
console.print("[red]Device code expired (timeout). Please try again.[/red]")
|
|
950
|
+
raise typer.Exit(1)
|
|
951
|
+
|
|
952
|
+
# Step 4: Token exchange (two-phase auth) if configured
|
|
953
|
+
base_url = profile.get("base_url", "")
|
|
954
|
+
if auth_config.get("token_exchange_endpoint") and base_url:
|
|
955
|
+
token_data = _token_exchange(token_data, auth_config, base_url, verify=verify)
|
|
956
|
+
|
|
957
|
+
# Step 5: Cache token
|
|
958
|
+
if "expires_in" in token_data:
|
|
959
|
+
token_data["expires_at"] = time.time() + token_data["expires_in"]
|
|
960
|
+
elif "expires_at" not in token_data:
|
|
961
|
+
token_data["expires_at"] = time.time() + 86400
|
|
962
|
+
|
|
963
|
+
token_cache = CACHE_DIR / f"{profile_name}_token.json"
|
|
964
|
+
ensure_dirs()
|
|
965
|
+
token_cache.write_text(json.dumps(token_data))
|
|
966
|
+
token_cache.chmod(0o600)
|
|
967
|
+
|
|
968
|
+
console.print("[green]Logged in successfully![/green]")
|
|
969
|
+
console.print(f"[dim]Token cached at {token_cache}[/dim]")
|
|
970
|
+
|
|
971
|
+
|
|
734
972
|
def _api_key_auth(auth_config: dict, quiet: bool = False) -> dict:
|
|
735
973
|
"""Handle API key auth via custom header."""
|
|
736
974
|
env_var = auth_config.get("env_var", "")
|
|
@@ -1324,7 +1562,23 @@ def cmd_init(
|
|
|
1324
1562
|
Optional[str], typer.Option("--spec", "-s", help="Path to OpenAPI spec (auto-detected if omitted)")
|
|
1325
1563
|
] = None,
|
|
1326
1564
|
spec_url: Annotated[Optional[str], typer.Option("--spec-url", help="Full URL to OpenAPI spec file")] = None,
|
|
1327
|
-
auth_type: Annotated[
|
|
1565
|
+
auth_type: Annotated[
|
|
1566
|
+
str, typer.Option("--auth", help="Auth type: bearer, oidc, device, auto, api-key, basic, none")
|
|
1567
|
+
] = "none",
|
|
1568
|
+
# Non-interactive auth flags
|
|
1569
|
+
issuer_url: Annotated[Optional[str], typer.Option("--issuer-url", help="OIDC issuer URL for discovery")] = None,
|
|
1570
|
+
client_id: Annotated[Optional[str], typer.Option("--client-id", help="OAuth client ID")] = None,
|
|
1571
|
+
scopes: Annotated[Optional[str], typer.Option("--scopes", help="OAuth scopes")] = None,
|
|
1572
|
+
device_config_url: Annotated[
|
|
1573
|
+
Optional[str], typer.Option("--device-config-url", help="Device flow config discovery URL")
|
|
1574
|
+
] = None,
|
|
1575
|
+
authorize_url: Annotated[
|
|
1576
|
+
Optional[str], typer.Option("--authorize-url", help="OIDC authorization endpoint URL")
|
|
1577
|
+
] = None,
|
|
1578
|
+
token_url: Annotated[Optional[str], typer.Option("--token-url", help="OIDC token endpoint URL")] = None,
|
|
1579
|
+
token_exchange_endpoint: Annotated[
|
|
1580
|
+
Optional[str], typer.Option("--token-exchange-endpoint", help="Token exchange endpoint path for two-phase auth")
|
|
1581
|
+
] = None,
|
|
1328
1582
|
) -> None:
|
|
1329
1583
|
"""Initialize a new API profile with guided setup.
|
|
1330
1584
|
|
|
@@ -1333,7 +1587,8 @@ def cmd_init(
|
|
|
1333
1587
|
Examples:
|
|
1334
1588
|
openapi-cli4ai init petstore --url https://petstore3.swagger.io/api/v3
|
|
1335
1589
|
openapi-cli4ai init myapp --url http://localhost:8000 --auth bearer
|
|
1336
|
-
openapi-cli4ai init
|
|
1590
|
+
openapi-cli4ai init myapp --url http://localhost:8000 --auth device --issuer-url https://auth.example.com/realms/myrealm --client-id my-cli
|
|
1591
|
+
openapi-cli4ai init myapp --url http://localhost:8000 --auth oidc --authorize-url https://auth.example.com/authorize --token-url https://auth.example.com/token --client-id my-client
|
|
1337
1592
|
"""
|
|
1338
1593
|
if not url:
|
|
1339
1594
|
url = typer.prompt("Base URL of the API")
|
|
@@ -1410,21 +1665,14 @@ def cmd_init(
|
|
|
1410
1665
|
}
|
|
1411
1666
|
console.print("[dim]Run 'openapi-cli4ai login --username <user>' to authenticate.[/dim]")
|
|
1412
1667
|
elif auth_type == "oidc":
|
|
1413
|
-
authorize_url
|
|
1414
|
-
token_url = typer.prompt("Token URL (full URL)")
|
|
1415
|
-
client_id_val = typer.prompt("Client ID")
|
|
1416
|
-
scopes = typer.prompt("Scopes", default="openid")
|
|
1417
|
-
redirect_uri_val = typer.prompt("Redirect URI (or leave blank for localhost callback)", default="")
|
|
1418
|
-
if redirect_uri_val:
|
|
1419
|
-
profile["auth"]["redirect_uri"] = redirect_uri_val
|
|
1420
|
-
else:
|
|
1421
|
-
cb_port = typer.prompt("Local callback port", default="8484")
|
|
1422
|
-
profile["auth"]["callback_port"] = int(cb_port)
|
|
1423
|
-
profile["auth"]["authorize_url"] = authorize_url
|
|
1424
|
-
profile["auth"]["token_url"] = token_url
|
|
1425
|
-
profile["auth"]["client_id"] = client_id_val
|
|
1426
|
-
profile["auth"]["scopes"] = scopes
|
|
1668
|
+
_init_oidc_auth(profile, authorize_url, token_url, client_id, scopes, issuer_url, token_exchange_endpoint)
|
|
1427
1669
|
console.print("[dim]Run 'openapi-cli4ai login' to authenticate via browser.[/dim]")
|
|
1670
|
+
elif auth_type == "device":
|
|
1671
|
+
_init_device_auth(profile, device_config_url, issuer_url, client_id, scopes, token_exchange_endpoint)
|
|
1672
|
+
console.print("[dim]Run 'openapi-cli4ai login' to authenticate.[/dim]")
|
|
1673
|
+
elif auth_type == "auto":
|
|
1674
|
+
_init_auto_auth(profile, issuer_url, client_id, scopes, token_exchange_endpoint)
|
|
1675
|
+
console.print("[dim]Run 'openapi-cli4ai login' to authenticate (flow auto-detected).[/dim]")
|
|
1428
1676
|
elif auth_type == "api-key":
|
|
1429
1677
|
env_var = typer.prompt("Environment variable for API key", default=f"{name.upper()}_API_KEY")
|
|
1430
1678
|
header_name = typer.prompt("Header name", default="Authorization")
|
|
@@ -1475,6 +1723,99 @@ def cmd_init(
|
|
|
1475
1723
|
console.print(" openapi-cli4ai call GET /path [dim]# Call an endpoint[/dim]")
|
|
1476
1724
|
|
|
1477
1725
|
|
|
1726
|
+
def _init_oidc_auth(
|
|
1727
|
+
profile: dict,
|
|
1728
|
+
authorize_url: str | None,
|
|
1729
|
+
token_url: str | None,
|
|
1730
|
+
client_id: str | None,
|
|
1731
|
+
scopes: str | None,
|
|
1732
|
+
issuer_url: str | None,
|
|
1733
|
+
token_exchange_endpoint: str | None,
|
|
1734
|
+
) -> None:
|
|
1735
|
+
"""Set up OIDC auth config in profile, prompting only for missing values."""
|
|
1736
|
+
if issuer_url:
|
|
1737
|
+
profile["auth"]["issuer_url"] = issuer_url
|
|
1738
|
+
if not authorize_url:
|
|
1739
|
+
authorize_url = typer.prompt("Authorization URL (full URL)")
|
|
1740
|
+
if not token_url:
|
|
1741
|
+
token_url = typer.prompt("Token URL (full URL)")
|
|
1742
|
+
if not client_id:
|
|
1743
|
+
client_id = typer.prompt("Client ID")
|
|
1744
|
+
if not scopes:
|
|
1745
|
+
scopes = typer.prompt("Scopes", default="openid")
|
|
1746
|
+
redirect_uri_val = typer.prompt("Redirect URI (or leave blank for localhost callback)", default="")
|
|
1747
|
+
if redirect_uri_val:
|
|
1748
|
+
profile["auth"]["redirect_uri"] = redirect_uri_val
|
|
1749
|
+
else:
|
|
1750
|
+
cb_port = typer.prompt("Local callback port", default="8484")
|
|
1751
|
+
profile["auth"]["callback_port"] = int(cb_port)
|
|
1752
|
+
profile["auth"]["authorize_url"] = authorize_url
|
|
1753
|
+
profile["auth"]["token_url"] = token_url
|
|
1754
|
+
profile["auth"]["client_id"] = client_id
|
|
1755
|
+
profile["auth"]["scopes"] = scopes
|
|
1756
|
+
if token_exchange_endpoint:
|
|
1757
|
+
profile["auth"]["token_exchange_endpoint"] = token_exchange_endpoint
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
def _init_device_auth(
|
|
1761
|
+
profile: dict,
|
|
1762
|
+
device_config_url: str | None,
|
|
1763
|
+
issuer_url: str | None,
|
|
1764
|
+
client_id: str | None,
|
|
1765
|
+
scopes: str | None,
|
|
1766
|
+
token_exchange_endpoint: str | None,
|
|
1767
|
+
) -> None:
|
|
1768
|
+
"""Set up device flow auth config in profile, prompting only for missing values."""
|
|
1769
|
+
if device_config_url:
|
|
1770
|
+
profile["auth"]["device_config_url"] = device_config_url
|
|
1771
|
+
elif issuer_url:
|
|
1772
|
+
profile["auth"]["issuer_url"] = issuer_url
|
|
1773
|
+
else:
|
|
1774
|
+
use_discovery = typer.confirm("Use a device-config discovery URL?", default=False)
|
|
1775
|
+
if use_discovery:
|
|
1776
|
+
profile["auth"]["device_config_url"] = typer.prompt("Device config URL")
|
|
1777
|
+
else:
|
|
1778
|
+
use_issuer = typer.confirm("Use issuer URL for OIDC discovery?", default=True)
|
|
1779
|
+
if use_issuer:
|
|
1780
|
+
profile["auth"]["issuer_url"] = typer.prompt("Issuer URL (e.g., https://accounts.google.com)")
|
|
1781
|
+
else:
|
|
1782
|
+
if not client_id:
|
|
1783
|
+
client_id = typer.prompt("OAuth Client ID")
|
|
1784
|
+
device_ep = typer.prompt("Device authorization endpoint URL")
|
|
1785
|
+
token_ep = typer.prompt("Token endpoint URL")
|
|
1786
|
+
profile["auth"]["device_authorization_endpoint"] = device_ep
|
|
1787
|
+
profile["auth"]["token_endpoint"] = token_ep
|
|
1788
|
+
|
|
1789
|
+
if not client_id and "client_id" not in profile["auth"]:
|
|
1790
|
+
client_id = typer.prompt("OAuth Client ID")
|
|
1791
|
+
if client_id:
|
|
1792
|
+
profile["auth"]["client_id"] = client_id
|
|
1793
|
+
if scopes:
|
|
1794
|
+
profile["auth"]["scopes"] = scopes
|
|
1795
|
+
if token_exchange_endpoint:
|
|
1796
|
+
profile["auth"]["token_exchange_endpoint"] = token_exchange_endpoint
|
|
1797
|
+
|
|
1798
|
+
|
|
1799
|
+
def _init_auto_auth(
|
|
1800
|
+
profile: dict,
|
|
1801
|
+
issuer_url: str | None,
|
|
1802
|
+
client_id: str | None,
|
|
1803
|
+
scopes: str | None,
|
|
1804
|
+
token_exchange_endpoint: str | None,
|
|
1805
|
+
) -> None:
|
|
1806
|
+
"""Set up auto-detect auth config in profile, prompting only for missing values."""
|
|
1807
|
+
if not issuer_url:
|
|
1808
|
+
issuer_url = typer.prompt("Issuer URL (e.g., https://accounts.google.com)")
|
|
1809
|
+
profile["auth"]["issuer_url"] = issuer_url
|
|
1810
|
+
if not client_id:
|
|
1811
|
+
client_id = typer.prompt("OAuth Client ID")
|
|
1812
|
+
profile["auth"]["client_id"] = client_id
|
|
1813
|
+
if scopes:
|
|
1814
|
+
profile["auth"]["scopes"] = scopes
|
|
1815
|
+
if token_exchange_endpoint:
|
|
1816
|
+
profile["auth"]["token_exchange_endpoint"] = token_exchange_endpoint
|
|
1817
|
+
|
|
1818
|
+
|
|
1478
1819
|
# ── Commands: login ────────────────────────────────────────────────────────────
|
|
1479
1820
|
@app.command("login")
|
|
1480
1821
|
def cmd_login(
|
|
@@ -1486,17 +1827,24 @@ def cmd_login(
|
|
|
1486
1827
|
password_stdin: Annotated[bool, typer.Option("--password-stdin", help="Read password from stdin")] = False,
|
|
1487
1828
|
no_browser: Annotated[
|
|
1488
1829
|
bool,
|
|
1489
|
-
typer.Option("--no-browser", help="OIDC: print login URL instead of opening browser (for headless/SSH)"),
|
|
1830
|
+
typer.Option("--no-browser", help="OIDC/device: print login URL instead of opening browser (for headless/SSH)"),
|
|
1831
|
+
] = False,
|
|
1832
|
+
access_token: Annotated[str, typer.Option("--access-token", help="Inject a pre-obtained access token")] = "",
|
|
1833
|
+
refresh_token: Annotated[str, typer.Option("--refresh-token", help="Inject a pre-obtained refresh token")] = "",
|
|
1834
|
+
access_token_stdin: Annotated[
|
|
1835
|
+
bool, typer.Option("--access-token-stdin", help="Read access token from stdin")
|
|
1490
1836
|
] = False,
|
|
1491
1837
|
) -> None:
|
|
1492
1838
|
"""Login to an API that uses OAuth/token-endpoint authentication.
|
|
1493
1839
|
|
|
1494
|
-
Supports
|
|
1840
|
+
Supports auth modes:
|
|
1495
1841
|
- auth.type=bearer with token_endpoint: username/password grant
|
|
1496
1842
|
- auth.type=oidc: Authorization Code + PKCE flow (browser or --no-browser)
|
|
1843
|
+
- auth.type=device: OAuth 2.0 Device Authorization Grant (RFC 8628)
|
|
1844
|
+
- auth.type=auto: auto-detect best flow from OIDC discovery
|
|
1845
|
+
- --access-token / --access-token-stdin: inject a pre-obtained token
|
|
1497
1846
|
|
|
1498
|
-
For OIDC, use --no-browser on headless machines
|
|
1499
|
-
then prompts you to paste back the redirect URL after authenticating.
|
|
1847
|
+
For OIDC/device, use --no-browser on headless machines.
|
|
1500
1848
|
|
|
1501
1849
|
Password input methods (bearer mode, in priority order):
|
|
1502
1850
|
1. --password-file /path/to/file
|
|
@@ -1506,23 +1854,38 @@ def cmd_login(
|
|
|
1506
1854
|
"""
|
|
1507
1855
|
profile_name, profile = get_active_profile()
|
|
1508
1856
|
auth_config = profile.get("auth", {})
|
|
1857
|
+
auth_type = auth_config.get("type", "none")
|
|
1858
|
+
|
|
1859
|
+
# Token injection — works with any auth type
|
|
1860
|
+
if access_token or access_token_stdin:
|
|
1861
|
+
_inject_token(profile_name, access_token, refresh_token, access_token_stdin)
|
|
1862
|
+
return
|
|
1863
|
+
|
|
1864
|
+
# Auto-detect flow from OIDC discovery
|
|
1865
|
+
if auth_type == "auto":
|
|
1866
|
+
verify = profile.get("verify_ssl", True) and get_verify_ssl()
|
|
1867
|
+
resolved_type = _auto_detect_flow(auth_config, verify=verify)
|
|
1868
|
+
auth_type = resolved_type
|
|
1509
1869
|
|
|
1510
1870
|
# OIDC flow
|
|
1511
|
-
if
|
|
1871
|
+
if auth_type == "oidc":
|
|
1512
1872
|
verify = profile.get("verify_ssl", True) and get_verify_ssl()
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1873
|
+
base_url = profile.get("base_url", "").rstrip("/")
|
|
1874
|
+
_oidc_login(auth_config, profile_name, no_browser=no_browser, verify=verify, base_url=base_url)
|
|
1875
|
+
_try_post_login_spec_fetch(profile)
|
|
1876
|
+
return
|
|
1877
|
+
|
|
1878
|
+
# Device flow
|
|
1879
|
+
if auth_type == "device":
|
|
1880
|
+
verify = profile.get("verify_ssl", True) and get_verify_ssl()
|
|
1881
|
+
_device_login(auth_config, profile_name, profile, no_browser=no_browser, verify=verify)
|
|
1882
|
+
_try_post_login_spec_fetch(profile)
|
|
1522
1883
|
return
|
|
1523
1884
|
|
|
1524
|
-
if
|
|
1525
|
-
console.print(
|
|
1885
|
+
if auth_type != "bearer" or not auth_config.get("token_endpoint"):
|
|
1886
|
+
console.print(
|
|
1887
|
+
"[yellow]Login is for profiles with bearer auth + token_endpoint, oidc, device, or auto.[/yellow]"
|
|
1888
|
+
)
|
|
1526
1889
|
console.print("[dim]If your API uses a static token or API key, set the environment variable instead.[/dim]")
|
|
1527
1890
|
raise typer.Exit(1)
|
|
1528
1891
|
|
|
@@ -1594,23 +1957,104 @@ def cmd_login(
|
|
|
1594
1957
|
console.print("[green]Logged in successfully![/green]")
|
|
1595
1958
|
console.print(f"[dim]Token cached at {token_cache}[/dim]")
|
|
1596
1959
|
|
|
1597
|
-
|
|
1598
|
-
spec_url = _resolve_spec_url(profile)
|
|
1599
|
-
cache_file, _ = _spec_cache_paths(spec_url)
|
|
1600
|
-
if not cache_file.exists():
|
|
1601
|
-
try:
|
|
1602
|
-
spec = fetch_spec(profile, refresh=True)
|
|
1603
|
-
endpoints = extract_endpoint_summaries(spec)
|
|
1604
|
-
spec_title = spec.get("info", {}).get("title", "Unknown")
|
|
1605
|
-
console.print(f"[green]Fetched spec: {spec_title} ({len(endpoints)} endpoints)[/green]")
|
|
1606
|
-
except (typer.Exit, Exception):
|
|
1607
|
-
pass # Spec fetch is best-effort after login
|
|
1960
|
+
_try_post_login_spec_fetch(profile)
|
|
1608
1961
|
|
|
1609
1962
|
except httpx.ConnectError:
|
|
1610
1963
|
console.print(f"[red]Cannot connect to {base_url}[/red]")
|
|
1611
1964
|
raise typer.Exit(1)
|
|
1612
1965
|
|
|
1613
1966
|
|
|
1967
|
+
def _inject_token(profile_name: str, access_token: str, refresh_token: str, from_stdin: bool) -> None:
|
|
1968
|
+
"""Inject a pre-obtained token into the token cache."""
|
|
1969
|
+
if from_stdin:
|
|
1970
|
+
if sys.stdin.isatty():
|
|
1971
|
+
console.print("[red]--access-token-stdin requires piped input.[/red]")
|
|
1972
|
+
raise typer.Exit(1)
|
|
1973
|
+
access_token = sys.stdin.read().strip()
|
|
1974
|
+
|
|
1975
|
+
if not access_token:
|
|
1976
|
+
console.print("[red]No access token provided.[/red]")
|
|
1977
|
+
raise typer.Exit(1)
|
|
1978
|
+
|
|
1979
|
+
token_data: dict = {"access_token": access_token}
|
|
1980
|
+
if refresh_token:
|
|
1981
|
+
token_data["refresh_token"] = refresh_token
|
|
1982
|
+
|
|
1983
|
+
# Try to extract exp from JWT payload (without validation)
|
|
1984
|
+
try:
|
|
1985
|
+
payload_b64 = access_token.split(".")[1]
|
|
1986
|
+
# Add padding
|
|
1987
|
+
padded = payload_b64 + "=" * (4 - len(payload_b64) % 4)
|
|
1988
|
+
payload = json.loads(base64.urlsafe_b64decode(padded))
|
|
1989
|
+
if "exp" in payload:
|
|
1990
|
+
token_data["expires_at"] = payload["exp"]
|
|
1991
|
+
except (IndexError, ValueError, json.JSONDecodeError, binascii.Error):
|
|
1992
|
+
pass
|
|
1993
|
+
|
|
1994
|
+
if "expires_at" not in token_data:
|
|
1995
|
+
token_data["expires_at"] = time.time() + 3600
|
|
1996
|
+
|
|
1997
|
+
token_cache = CACHE_DIR / f"{profile_name}_token.json"
|
|
1998
|
+
ensure_dirs()
|
|
1999
|
+
token_cache.write_text(json.dumps(token_data))
|
|
2000
|
+
token_cache.chmod(0o600)
|
|
2001
|
+
|
|
2002
|
+
console.print("[green]Token injected successfully![/green]")
|
|
2003
|
+
console.print(f"[dim]Token cached at {token_cache}[/dim]")
|
|
2004
|
+
|
|
2005
|
+
|
|
2006
|
+
def _auto_detect_flow(auth_config: dict, verify: bool = True) -> str:
|
|
2007
|
+
"""Auto-detect the best OAuth flow from OIDC discovery.
|
|
2008
|
+
|
|
2009
|
+
Returns 'device' if device_authorization_endpoint is available, else 'oidc'.
|
|
2010
|
+
"""
|
|
2011
|
+
issuer_url = auth_config.get("issuer_url")
|
|
2012
|
+
if not issuer_url:
|
|
2013
|
+
console.print("[red]Auto auth type requires issuer_url.[/red]")
|
|
2014
|
+
raise typer.Exit(1)
|
|
2015
|
+
|
|
2016
|
+
well_known_url = f"{issuer_url.rstrip('/')}/.well-known/openid-configuration"
|
|
2017
|
+
try:
|
|
2018
|
+
with httpx.Client(verify=verify, timeout=15.0) as client:
|
|
2019
|
+
resp = client.get(well_known_url)
|
|
2020
|
+
if resp.status_code != 200:
|
|
2021
|
+
console.print(f"[red]Failed to fetch OIDC discovery ({resp.status_code})[/red]")
|
|
2022
|
+
raise typer.Exit(1)
|
|
2023
|
+
|
|
2024
|
+
oidc_config = resp.json()
|
|
2025
|
+
except (httpx.HTTPError, json.JSONDecodeError) as e:
|
|
2026
|
+
console.print(f"[red]Failed to fetch OIDC discovery: {e}[/red]")
|
|
2027
|
+
raise typer.Exit(1)
|
|
2028
|
+
|
|
2029
|
+
# Check if device flow is supported
|
|
2030
|
+
grant_types = oidc_config.get("grant_types_supported", [])
|
|
2031
|
+
device_ep = oidc_config.get("device_authorization_endpoint")
|
|
2032
|
+
|
|
2033
|
+
if device_ep and "urn:ietf:params:oauth:grant-type:device_code" in grant_types:
|
|
2034
|
+
# Populate auth_config with discovered endpoints for device flow
|
|
2035
|
+
auth_config.setdefault("device_authorization_endpoint", device_ep)
|
|
2036
|
+
auth_config.setdefault("token_endpoint", oidc_config.get("token_endpoint", ""))
|
|
2037
|
+
console.print("[dim]Auto-detected: using device authorization flow[/dim]")
|
|
2038
|
+
return "device"
|
|
2039
|
+
|
|
2040
|
+
# Fall back to PKCE
|
|
2041
|
+
auth_config.setdefault("authorize_url", oidc_config.get("authorization_endpoint", ""))
|
|
2042
|
+
auth_config.setdefault("token_url", oidc_config.get("token_endpoint", ""))
|
|
2043
|
+
console.print("[dim]Auto-detected: using Authorization Code + PKCE flow[/dim]")
|
|
2044
|
+
return "oidc"
|
|
2045
|
+
|
|
2046
|
+
|
|
2047
|
+
def _try_post_login_spec_fetch(profile: dict) -> None:
|
|
2048
|
+
"""Best-effort spec fetch after successful login."""
|
|
2049
|
+
try:
|
|
2050
|
+
spec = fetch_spec(profile, refresh=True)
|
|
2051
|
+
endpoints = extract_endpoint_summaries(spec)
|
|
2052
|
+
spec_title = spec.get("info", {}).get("title", "Unknown")
|
|
2053
|
+
console.print(f"[green]Fetched spec: {spec_title} ({len(endpoints)} endpoints)[/green]")
|
|
2054
|
+
except (typer.Exit, Exception):
|
|
2055
|
+
pass
|
|
2056
|
+
|
|
2057
|
+
|
|
1614
2058
|
@app.command("logout")
|
|
1615
2059
|
def cmd_logout() -> None:
|
|
1616
2060
|
"""Clear cached authentication tokens for the active profile."""
|