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.
Files changed (37) hide show
  1. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/PKG-INFO +1 -1
  2. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/pyproject.toml +1 -1
  3. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/src/openapi_cli4ai/cli.py +490 -46
  4. openapi_cli4ai-0.4.0/tests/test_auth_flows.py +1271 -0
  5. openapi_cli4ai-0.4.0/tests/test_commands.py +1414 -0
  6. openapi_cli4ai-0.4.0/tests/test_coverage_gaps.py +1125 -0
  7. openapi_cli4ai-0.4.0/tests/test_device_flow.py +635 -0
  8. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/.github/CODEOWNERS +0 -0
  9. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  10. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  11. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/.github/dependabot.yml +0 -0
  12. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/.github/release.yml +0 -0
  13. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/.github/workflows/ci.yml +0 -0
  14. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/.github/workflows/codeql.yml +0 -0
  15. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/.github/workflows/publish.yml +0 -0
  16. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/.gitignore +0 -0
  17. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/CHANGELOG.md +0 -0
  18. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/CONTRIBUTING.md +0 -0
  19. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/LICENSE +0 -0
  20. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/README.md +0 -0
  21. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/SECURITY.md +0 -0
  22. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/demo.gif +0 -0
  23. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/examples/profiles.toml.example +0 -0
  24. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/openapi-cli4ai +0 -0
  25. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/src/openapi_cli4ai/__init__.py +0 -0
  26. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/src/openapi_cli4ai/__main__.py +0 -0
  27. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/__init__.py +0 -0
  28. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/conftest.py +0 -0
  29. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/fixtures/petstore_spec.json +0 -0
  30. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/test_auth.py +0 -0
  31. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/test_caching.py +0 -0
  32. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/test_integration.py +0 -0
  33. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/test_oidc.py +0 -0
  34. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/test_profile_management.py +0 -0
  35. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/test_run_command.py +0 -0
  36. {openapi_cli4ai-0.3.0 → openapi_cli4ai-0.4.0}/tests/test_spec_parsing.py +0 -0
  37. {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.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openapi-cli4ai"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Turn any REST API with an OpenAPI spec into an AI-ready CLI"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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.3.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 == "oidc":
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(auth_config: dict, profile_name: str, no_browser: bool = False, verify: bool = True) -> None:
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[str, typer.Option("--auth", help="Auth type: bearer, oidc, api-key, basic, none")] = "none",
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 github --url https://api.github.com --spec-url https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json --auth bearer
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 = typer.prompt("Authorization URL (full 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 two auth modes:
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: prints the login URL,
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 auth_config.get("type") == "oidc":
1871
+ if auth_type == "oidc":
1512
1872
  verify = profile.get("verify_ssl", True) and get_verify_ssl()
1513
- _oidc_login(auth_config, profile_name, no_browser=no_browser, verify=verify)
1514
- # Try fetching the spec now that we're authenticated
1515
- try:
1516
- spec = fetch_spec(profile, refresh=True)
1517
- endpoints = extract_endpoint_summaries(spec)
1518
- spec_title = spec.get("info", {}).get("title", "Unknown")
1519
- console.print(f"[green]Fetched spec: {spec_title} ({len(endpoints)} endpoints)[/green]")
1520
- except (typer.Exit, Exception):
1521
- pass
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 auth_config.get("type") != "bearer" or not auth_config.get("token_endpoint"):
1525
- console.print("[yellow]Login is for profiles with bearer auth + token_endpoint, or oidc.[/yellow]")
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
- # Try fetching the spec now that we're authenticated
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."""