openapi-cli4ai 0.1.1__tar.gz → 0.3.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 (34) hide show
  1. openapi_cli4ai-0.3.0/.github/release.yml +20 -0
  2. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/.github/workflows/ci.yml +2 -2
  3. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/.github/workflows/codeql.yml +2 -2
  4. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/.github/workflows/publish.yml +2 -2
  5. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/CHANGELOG.md +25 -0
  6. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/PKG-INFO +62 -8
  7. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/README.md +61 -7
  8. openapi_cli4ai-0.3.0/demo.gif +0 -0
  9. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/pyproject.toml +1 -1
  10. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/src/openapi_cli4ai/cli.py +482 -7
  11. openapi_cli4ai-0.3.0/tests/test_oidc.py +130 -0
  12. openapi_cli4ai-0.3.0/tests/test_run_command.py +165 -0
  13. openapi_cli4ai-0.1.1/demo.gif +0 -0
  14. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/.github/CODEOWNERS +0 -0
  15. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  16. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  17. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/.github/dependabot.yml +0 -0
  18. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/.gitignore +0 -0
  19. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/CONTRIBUTING.md +0 -0
  20. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/LICENSE +0 -0
  21. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/SECURITY.md +0 -0
  22. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/examples/profiles.toml.example +0 -0
  23. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/openapi-cli4ai +0 -0
  24. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/src/openapi_cli4ai/__init__.py +0 -0
  25. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/src/openapi_cli4ai/__main__.py +0 -0
  26. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/tests/__init__.py +0 -0
  27. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/tests/conftest.py +0 -0
  28. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/tests/fixtures/petstore_spec.json +0 -0
  29. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/tests/test_auth.py +0 -0
  30. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/tests/test_caching.py +0 -0
  31. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/tests/test_integration.py +0 -0
  32. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/tests/test_profile_management.py +0 -0
  33. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/tests/test_spec_parsing.py +0 -0
  34. {openapi_cli4ai-0.1.1 → openapi_cli4ai-0.3.0}/tests/test_sse_streaming.py +0 -0
@@ -0,0 +1,20 @@
1
+ changelog:
2
+ categories:
3
+ - title: New Features
4
+ labels:
5
+ - enhancement
6
+ - title: Bug Fixes
7
+ labels:
8
+ - bug
9
+ - title: Security
10
+ labels:
11
+ - security
12
+ - title: Dependencies
13
+ labels:
14
+ - dependencies
15
+ - title: Other Changes
16
+ labels:
17
+ - "*"
18
+ exclude:
19
+ labels:
20
+ - skip-changelog
@@ -17,7 +17,7 @@ jobs:
17
17
  python-version: ["3.11", "3.12", "3.13"]
18
18
  steps:
19
19
  - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
20
- - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
20
+ - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
21
21
  with:
22
22
  enable-cache: true
23
23
  - name: Set up Python ${{ matrix.python-version }}
@@ -29,7 +29,7 @@ jobs:
29
29
  runs-on: ubuntu-latest
30
30
  steps:
31
31
  - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
32
- - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
32
+ - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
33
33
  - name: Ruff check
34
34
  run: uvx ruff check .
35
35
  - name: Ruff format check
@@ -17,7 +17,7 @@ jobs:
17
17
  runs-on: ubuntu-latest
18
18
  steps:
19
19
  - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
20
- - uses: github/codeql-action/init@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 # v3
20
+ - uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
21
21
  with:
22
22
  languages: python
23
- - uses: github/codeql-action/analyze@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 # v3
23
+ - uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
@@ -14,7 +14,7 @@ jobs:
14
14
  contents: read
15
15
  steps:
16
16
  - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
17
- - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
17
+ - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
18
18
  - name: Build sdist and wheel
19
19
  run: uv build
20
20
  - name: Upload artifacts
@@ -42,7 +42,7 @@ jobs:
42
42
  name: dist
43
43
  path: dist/
44
44
  - name: Generate attestations
45
- uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
45
+ uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
46
46
  with:
47
47
  subject-path: dist/*
48
48
  - name: Publish to PyPI
@@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.3.0] - 2026-03-28
8
+
9
+ ### Added
10
+
11
+ - OIDC Authorization Code + PKCE auth type (`auth.type = "oidc"`)
12
+ - Browser-based login with localhost callback
13
+ - `--no-browser` flag for headless/SSH environments
14
+ - CSRF protection via state parameter validation
15
+ - Tested with Auth0 and Keycloak
16
+
17
+ ## [0.2.0] - 2026-03-26
18
+
19
+ ### Added
20
+
21
+ - `run` command — call API operations by name with auto-routed inputs
22
+ - Case-insensitive operationId matching
23
+ - Fuzzy suggestions when an operationId isn't found
24
+ - Auto-generated release notes via `.github/release.yml`
25
+
26
+ ### Fixed
27
+
28
+ - `typer>=0.12` crashed with click 8.2+ — bumped to `>=0.24` (#3)
29
+ - `--format json` appended non-JSON summary line breaking machine parsing
30
+ - `init` auto-detect ignored `--insecure` flag
31
+
7
32
  ## [0.1.0] - 2026-03-25
8
33
 
9
34
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openapi-cli4ai
3
- Version: 0.1.1
3
+ Version: 0.3.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
@@ -41,19 +41,24 @@ Let your AI coding agent (Claude Code, Cursor, Codex, Copilot) talk to any REST
41
41
 
42
42
  ## Install
43
43
 
44
- ```bash
45
- # Requires uv (https://docs.astral.sh/uv/)
46
- uv pip install openapi-cli4ai
44
+ **Requires:** [uv](https://docs.astral.sh/uv/getting-started/installation/)
47
45
 
48
- # Or run directly without installing
46
+ ```bash
47
+ # Recommended — run directly, no install needed
49
48
  uvx openapi-cli4ai --help
49
+
50
+ # Or install globally
51
+ uv tool install openapi-cli4ai
52
+
53
+ # Or install into a virtual environment
54
+ uv pip install openapi-cli4ai
50
55
  ```
51
56
 
52
57
  ## Quick Start
53
58
 
54
59
  ```bash
55
- # Point it at any API with an OpenAPI spec
56
- openapi-cli4ai init petstore --url https://petstore3.swagger.io/api/v3
60
+ # Point it at any API with an OpenAPI spec (prefix with uvx if not installed)
61
+ uvx openapi-cli4ai init petstore --url https://petstore3.swagger.io/api/v3
57
62
 
58
63
  # Discover endpoints
59
64
  openapi-cli4ai endpoints
@@ -61,7 +66,10 @@ openapi-cli4ai endpoints
61
66
  # Search endpoints
62
67
  openapi-cli4ai endpoints -s pet
63
68
 
64
- # Call an endpoint
69
+ # Run an operation by name (inputs auto-routed from the spec)
70
+ openapi-cli4ai run findPetsByStatus --input '{"status": "available"}'
71
+
72
+ # Or call an endpoint directly
65
73
  openapi-cli4ai call GET /pet/findByStatus --query status=available
66
74
  ```
67
75
 
@@ -118,6 +126,27 @@ openapi-cli4ai init myapi --url https://api.example.com --spec-url https://examp
118
126
  openapi-cli4ai -k init myapi --url https://staging.internal.example.com
119
127
  ```
120
128
 
129
+ ### `run` — Run an operation by name
130
+
131
+ ```bash
132
+ # Query parameter — auto-routed from the spec
133
+ openapi-cli4ai run findPetsByStatus --input '{"status": "available"}'
134
+
135
+ # Path parameter — substituted into the URL
136
+ openapi-cli4ai run getPetById --input '{"petId": 123}'
137
+
138
+ # Request body — keys not matching a parameter go to the body
139
+ openapi-cli4ai run addPet --input '{"name": "Rex", "status": "available"}'
140
+
141
+ # Load input from a file
142
+ openapi-cli4ai run addPet --input-file pet.json
143
+
144
+ # Raw JSON output
145
+ openapi-cli4ai run getInventory --json
146
+ ```
147
+
148
+ The spec tells the tool where each parameter goes (path, query, header, body). You just pass a flat JSON object.
149
+
121
150
  ### `endpoints` — Discover API endpoints
122
151
 
123
152
  ```bash
@@ -193,6 +222,12 @@ openapi-cli4ai login --username admin --password-file /path/to/secret
193
222
  # Login with password from stdin
194
223
  echo "my-password" | openapi-cli4ai login --username admin --password-stdin
195
224
 
225
+ # OIDC login (opens browser for Auth0, Keycloak, etc.)
226
+ openapi-cli4ai login
227
+
228
+ # OIDC login without browser (headless/SSH — prints URL, you paste redirect back)
229
+ openapi-cli4ai login --no-browser
230
+
196
231
  # Logout (clear cached tokens)
197
232
  openapi-cli4ai logout
198
233
  ```
@@ -240,6 +275,22 @@ account_type = "USERNAME"
240
275
 
241
276
  Payload placeholders: `{username}` and `{password}` come from the login prompt. `{env:VAR_NAME}` pulls from environment variables or a `.env` file (loaded automatically).
242
277
 
278
+ OIDC example (Auth0, Keycloak, or any OIDC provider):
279
+
280
+ ```toml
281
+ [profiles.my-oidc-app]
282
+ base_url = "https://api.example.com"
283
+ openapi_path = "/openapi.json"
284
+
285
+ [profiles.my-oidc-app.auth]
286
+ type = "oidc"
287
+ authorize_url = "https://your-idp.com/authorize"
288
+ token_url = "https://your-idp.com/oauth/token"
289
+ client_id = "your-client-id"
290
+ scopes = "openid profile email"
291
+ callback_port = 9876
292
+ ```
293
+
243
294
  ### Auth Types
244
295
 
245
296
  | Type | Use Case | Config Fields |
@@ -247,6 +298,7 @@ Payload placeholders: `{username}` and `{password}` come from the login prompt.
247
298
  | `none` | Public APIs | — |
248
299
  | `bearer` | Token from env var | `token_env_var` |
249
300
  | `bearer` | OAuth token endpoint | `token_endpoint`, `refresh_endpoint`, `payload` |
301
+ | `oidc` | OIDC Authorization Code + PKCE | `authorize_url`, `token_url`, `client_id`, `scopes` |
250
302
  | `api-key` | API key in header | `env_var`, `header`, `prefix` |
251
303
  | `basic` | HTTP Basic auth | `username_env_var`, `password_env_var` |
252
304
 
@@ -263,6 +315,8 @@ Payload placeholders: `{username}` and `{password}` come from the login prompt.
263
315
  | [Jira Cloud](https://developer.atlassian.com/cloud/jira/platform/rest/v3/) | 581 | Basic | `init jira --url https://your-domain.atlassian.net --spec-url ... --auth basic` |
264
316
  | [OpenRouter](https://openrouter.ai) | 36 | Bearer | `init openrouter --url https://openrouter.ai/api/v1 --spec-url ... --auth bearer` |
265
317
  | [DBGorilla](https://dbgorilla.com) | 500+ | Token endpoint | See `examples/profiles.toml.example` |
318
+ | [Auth0](https://auth0.com) | — | OIDC + PKCE | `init myapp --url https://api.example.com --auth oidc` |
319
+ | [Keycloak](https://www.keycloak.org) | — | OIDC + PKCE | `init myapp --url https://api.example.com --auth oidc` |
266
320
 
267
321
  Works with any AI agent that has shell access — Claude Code, Cursor, GitHub Copilot, or anything that can run `endpoints` and `call`.
268
322
 
@@ -13,19 +13,24 @@ Let your AI coding agent (Claude Code, Cursor, Codex, Copilot) talk to any REST
13
13
 
14
14
  ## Install
15
15
 
16
- ```bash
17
- # Requires uv (https://docs.astral.sh/uv/)
18
- uv pip install openapi-cli4ai
16
+ **Requires:** [uv](https://docs.astral.sh/uv/getting-started/installation/)
19
17
 
20
- # Or run directly without installing
18
+ ```bash
19
+ # Recommended — run directly, no install needed
21
20
  uvx openapi-cli4ai --help
21
+
22
+ # Or install globally
23
+ uv tool install openapi-cli4ai
24
+
25
+ # Or install into a virtual environment
26
+ uv pip install openapi-cli4ai
22
27
  ```
23
28
 
24
29
  ## Quick Start
25
30
 
26
31
  ```bash
27
- # Point it at any API with an OpenAPI spec
28
- openapi-cli4ai init petstore --url https://petstore3.swagger.io/api/v3
32
+ # Point it at any API with an OpenAPI spec (prefix with uvx if not installed)
33
+ uvx openapi-cli4ai init petstore --url https://petstore3.swagger.io/api/v3
29
34
 
30
35
  # Discover endpoints
31
36
  openapi-cli4ai endpoints
@@ -33,7 +38,10 @@ openapi-cli4ai endpoints
33
38
  # Search endpoints
34
39
  openapi-cli4ai endpoints -s pet
35
40
 
36
- # Call an endpoint
41
+ # Run an operation by name (inputs auto-routed from the spec)
42
+ openapi-cli4ai run findPetsByStatus --input '{"status": "available"}'
43
+
44
+ # Or call an endpoint directly
37
45
  openapi-cli4ai call GET /pet/findByStatus --query status=available
38
46
  ```
39
47
 
@@ -90,6 +98,27 @@ openapi-cli4ai init myapi --url https://api.example.com --spec-url https://examp
90
98
  openapi-cli4ai -k init myapi --url https://staging.internal.example.com
91
99
  ```
92
100
 
101
+ ### `run` — Run an operation by name
102
+
103
+ ```bash
104
+ # Query parameter — auto-routed from the spec
105
+ openapi-cli4ai run findPetsByStatus --input '{"status": "available"}'
106
+
107
+ # Path parameter — substituted into the URL
108
+ openapi-cli4ai run getPetById --input '{"petId": 123}'
109
+
110
+ # Request body — keys not matching a parameter go to the body
111
+ openapi-cli4ai run addPet --input '{"name": "Rex", "status": "available"}'
112
+
113
+ # Load input from a file
114
+ openapi-cli4ai run addPet --input-file pet.json
115
+
116
+ # Raw JSON output
117
+ openapi-cli4ai run getInventory --json
118
+ ```
119
+
120
+ The spec tells the tool where each parameter goes (path, query, header, body). You just pass a flat JSON object.
121
+
93
122
  ### `endpoints` — Discover API endpoints
94
123
 
95
124
  ```bash
@@ -165,6 +194,12 @@ openapi-cli4ai login --username admin --password-file /path/to/secret
165
194
  # Login with password from stdin
166
195
  echo "my-password" | openapi-cli4ai login --username admin --password-stdin
167
196
 
197
+ # OIDC login (opens browser for Auth0, Keycloak, etc.)
198
+ openapi-cli4ai login
199
+
200
+ # OIDC login without browser (headless/SSH — prints URL, you paste redirect back)
201
+ openapi-cli4ai login --no-browser
202
+
168
203
  # Logout (clear cached tokens)
169
204
  openapi-cli4ai logout
170
205
  ```
@@ -212,6 +247,22 @@ account_type = "USERNAME"
212
247
 
213
248
  Payload placeholders: `{username}` and `{password}` come from the login prompt. `{env:VAR_NAME}` pulls from environment variables or a `.env` file (loaded automatically).
214
249
 
250
+ OIDC example (Auth0, Keycloak, or any OIDC provider):
251
+
252
+ ```toml
253
+ [profiles.my-oidc-app]
254
+ base_url = "https://api.example.com"
255
+ openapi_path = "/openapi.json"
256
+
257
+ [profiles.my-oidc-app.auth]
258
+ type = "oidc"
259
+ authorize_url = "https://your-idp.com/authorize"
260
+ token_url = "https://your-idp.com/oauth/token"
261
+ client_id = "your-client-id"
262
+ scopes = "openid profile email"
263
+ callback_port = 9876
264
+ ```
265
+
215
266
  ### Auth Types
216
267
 
217
268
  | Type | Use Case | Config Fields |
@@ -219,6 +270,7 @@ Payload placeholders: `{username}` and `{password}` come from the login prompt.
219
270
  | `none` | Public APIs | — |
220
271
  | `bearer` | Token from env var | `token_env_var` |
221
272
  | `bearer` | OAuth token endpoint | `token_endpoint`, `refresh_endpoint`, `payload` |
273
+ | `oidc` | OIDC Authorization Code + PKCE | `authorize_url`, `token_url`, `client_id`, `scopes` |
222
274
  | `api-key` | API key in header | `env_var`, `header`, `prefix` |
223
275
  | `basic` | HTTP Basic auth | `username_env_var`, `password_env_var` |
224
276
 
@@ -235,6 +287,8 @@ Payload placeholders: `{username}` and `{password}` come from the login prompt.
235
287
  | [Jira Cloud](https://developer.atlassian.com/cloud/jira/platform/rest/v3/) | 581 | Basic | `init jira --url https://your-domain.atlassian.net --spec-url ... --auth basic` |
236
288
  | [OpenRouter](https://openrouter.ai) | 36 | Bearer | `init openrouter --url https://openrouter.ai/api/v1 --spec-url ... --auth bearer` |
237
289
  | [DBGorilla](https://dbgorilla.com) | 500+ | Token endpoint | See `examples/profiles.toml.example` |
290
+ | [Auth0](https://auth0.com) | — | OIDC + PKCE | `init myapp --url https://api.example.com --auth oidc` |
291
+ | [Keycloak](https://www.keycloak.org) | — | OIDC + PKCE | `init myapp --url https://api.example.com --auth oidc` |
238
292
 
239
293
  Works with any AI agent that has shell access — Claude Code, Cursor, GitHub Copilot, or anything that can run `endpoints` and `call`.
240
294
 
Binary file
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openapi-cli4ai"
7
- version = "0.1.1"
7
+ version = "0.3.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"
@@ -27,9 +27,13 @@ import hashlib
27
27
  import json
28
28
  import os
29
29
  import re
30
+ import secrets
30
31
  import sys
31
32
  import time
32
33
  import tomllib
34
+ import urllib.parse
35
+ import webbrowser
36
+ from http.server import BaseHTTPRequestHandler, HTTPServer
33
37
  from pathlib import Path
34
38
  from typing import Annotated, Optional
35
39
 
@@ -47,7 +51,7 @@ from rich.panel import Panel # noqa: E402
47
51
  from rich.table import Table # noqa: E402
48
52
 
49
53
  # ── Constants ──────────────────────────────────────────────────────────────────
50
- VERSION = "0.1.1"
54
+ VERSION = "0.3.0"
51
55
  APP_NAME = "openapi-cli4ai"
52
56
  CONFIG_FILE = Path.home() / ".openapi-cli4ai.toml"
53
57
  CACHE_DIR = Path.home() / ".cache" / APP_NAME
@@ -376,6 +380,8 @@ def get_auth_headers(profile: dict, quiet: bool = False) -> dict:
376
380
  return {}
377
381
  elif auth_type == "bearer":
378
382
  return _bearer_auth(profile, auth_config, quiet)
383
+ elif auth_type == "oidc":
384
+ return _oidc_auth(profile, auth_config, quiet)
379
385
  elif auth_type == "api-key":
380
386
  return _api_key_auth(auth_config, quiet)
381
387
  elif auth_type == "basic":
@@ -469,6 +475,262 @@ def _try_refresh_token(profile: dict, auth_config: dict, cached: dict) -> dict |
469
475
  return None
470
476
 
471
477
 
478
+ # ── OIDC (Authorization Code + PKCE) ─────────────────────────────────────────
479
+
480
+
481
+ def _oidc_auth(profile: dict, auth_config: dict, quiet: bool = False) -> dict:
482
+ """Handle OIDC auth -- cached token with form-encoded refresh."""
483
+ profile_name = profile.get("_name", "default")
484
+ token_cache = CACHE_DIR / f"{profile_name}_token.json"
485
+
486
+ if token_cache.exists():
487
+ try:
488
+ cached = json.loads(token_cache.read_text())
489
+ expires_at = cached.get("expires_at", 0)
490
+
491
+ # Valid token -- use it
492
+ if time.time() < (expires_at - 300):
493
+ return {"Authorization": f"Bearer {cached['access_token']}"}
494
+
495
+ # Try refresh (form-encoded, standard OIDC)
496
+ if "refresh_token" in cached:
497
+ verify = profile.get("verify_ssl", True) and get_verify_ssl()
498
+ refreshed = _oidc_refresh(auth_config, cached, verify=verify)
499
+ if refreshed:
500
+ refreshed["expires_at"] = time.time() + refreshed.get("expires_in", 300)
501
+ ensure_dirs()
502
+ token_cache.write_text(json.dumps(refreshed))
503
+ token_cache.chmod(0o600)
504
+ return {"Authorization": f"Bearer {refreshed['access_token']}"}
505
+ except (json.JSONDecodeError, OSError, KeyError):
506
+ pass
507
+
508
+ if not quiet:
509
+ console.print("[yellow]Token expired or missing. Run 'openapi-cli4ai login' to authenticate.[/yellow]")
510
+ raise typer.Exit(1)
511
+
512
+
513
+ def _oidc_refresh(auth_config: dict, cached: dict, verify: bool = True) -> dict | None:
514
+ """Refresh an OIDC token using form-encoded POST (standard OIDC spec)."""
515
+ token_url = auth_config.get("token_url", "")
516
+ client_id = auth_config.get("client_id", "")
517
+ if not token_url or not client_id:
518
+ return None
519
+ try:
520
+ with httpx.Client(verify=verify, timeout=30.0) as client:
521
+ resp = client.post(
522
+ token_url,
523
+ data={
524
+ "grant_type": "refresh_token",
525
+ "client_id": client_id,
526
+ "refresh_token": cached["refresh_token"],
527
+ },
528
+ )
529
+ if resp.status_code == 200:
530
+ return resp.json()
531
+ except Exception:
532
+ pass
533
+ return None
534
+
535
+
536
+ class _OIDCCallbackHandler(BaseHTTPRequestHandler):
537
+ """HTTP handler that captures the OIDC authorization code callback."""
538
+
539
+ auth_code: str | None = None
540
+ error: str | None = None
541
+ expected_state: str | None = None
542
+
543
+ def do_GET(self) -> None:
544
+ params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
545
+
546
+ # Validate state to prevent CSRF
547
+ received_state = params.get("state", [None])[0]
548
+ if _OIDCCallbackHandler.expected_state and received_state != _OIDCCallbackHandler.expected_state:
549
+ _OIDCCallbackHandler.error = "state_mismatch"
550
+ self.send_response(400)
551
+ self.send_header("Content-Type", "text/html")
552
+ self.end_headers()
553
+ self.wfile.write(
554
+ b"<html><body><h2>Login failed</h2><p>State mismatch - possible CSRF attack.</p></body></html>"
555
+ )
556
+ return
557
+
558
+ if "code" in params:
559
+ _OIDCCallbackHandler.auth_code = params["code"][0]
560
+ self.send_response(200)
561
+ self.send_header("Content-Type", "text/html")
562
+ self.end_headers()
563
+ self.wfile.write(
564
+ b"<html><body><h2>Login successful</h2>"
565
+ b"<p>You can close this tab and return to the terminal.</p>"
566
+ b"</body></html>"
567
+ )
568
+ else:
569
+ _OIDCCallbackHandler.error = params.get("error", ["unknown"])[0]
570
+ self.send_response(400)
571
+ self.send_header("Content-Type", "text/html")
572
+ self.end_headers()
573
+ self.wfile.write(
574
+ f"<html><body><h2>Login failed</h2><p>{_OIDCCallbackHandler.error}</p></body></html>".encode()
575
+ )
576
+
577
+ def log_message(self, format: str, *args: object) -> None:
578
+ pass # Suppress HTTP server logging
579
+
580
+
581
+ def _oidc_login(auth_config: dict, profile_name: str, no_browser: bool = False, verify: bool = True) -> None:
582
+ """Run OIDC Authorization Code + PKCE flow.
583
+
584
+ With browser (default): opens browser, listens on localhost for callback.
585
+ Without browser (--no-browser): prints URL, user pastes redirect URL back.
586
+ """
587
+ authorize_url = auth_config.get("authorize_url", "")
588
+ token_url = auth_config.get("token_url", "")
589
+ client_id = auth_config.get("client_id", "")
590
+ scopes = auth_config.get("scopes", "openid")
591
+ callback_port = auth_config.get("callback_port", 8484)
592
+ redirect_uri = auth_config.get("redirect_uri", f"http://localhost:{callback_port}/callback")
593
+
594
+ if not authorize_url or not token_url or not client_id:
595
+ console.print("[red]OIDC auth requires authorize_url, token_url, and client_id.[/red]")
596
+ raise typer.Exit(1)
597
+
598
+ # PKCE: generate code verifier + challenge
599
+ code_verifier = secrets.token_urlsafe(64)
600
+ code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b"=").decode()
601
+ state = secrets.token_urlsafe(32)
602
+
603
+ # Build authorization URL
604
+ auth_params = urllib.parse.urlencode(
605
+ {
606
+ "response_type": "code",
607
+ "client_id": client_id,
608
+ "redirect_uri": redirect_uri,
609
+ "scope": scopes,
610
+ "state": state,
611
+ "code_challenge": code_challenge,
612
+ "code_challenge_method": "S256",
613
+ }
614
+ )
615
+ full_auth_url = f"{authorize_url}?{auth_params}"
616
+
617
+ if no_browser:
618
+ auth_code = _oidc_login_no_browser(full_auth_url)
619
+ else:
620
+ auth_code = _oidc_login_browser(full_auth_url, callback_port, state)
621
+
622
+ # Exchange code for tokens
623
+ _oidc_exchange_code(
624
+ token_url=token_url,
625
+ client_id=client_id,
626
+ auth_code=auth_code,
627
+ redirect_uri=redirect_uri,
628
+ code_verifier=code_verifier,
629
+ profile_name=profile_name,
630
+ verify=verify,
631
+ )
632
+
633
+
634
+ def _oidc_login_browser(full_auth_url: str, callback_port: int, state: str) -> str:
635
+ """Open browser and listen for the OIDC callback on localhost."""
636
+ _OIDCCallbackHandler.auth_code = None
637
+ _OIDCCallbackHandler.error = None
638
+ _OIDCCallbackHandler.expected_state = state
639
+ server = HTTPServer(("127.0.0.1", callback_port), _OIDCCallbackHandler)
640
+ server.timeout = 120
641
+
642
+ console.print(f"[dim]Listening on http://localhost:{callback_port}/callback[/dim]")
643
+ console.print("[bold]Opening browser for login...[/bold]")
644
+ webbrowser.open(full_auth_url)
645
+ console.print("[dim]Waiting for callback (120s timeout)...[/dim]")
646
+
647
+ server.handle_request()
648
+ server.server_close()
649
+
650
+ if _OIDCCallbackHandler.error:
651
+ console.print(f"[red]OIDC error: {_OIDCCallbackHandler.error}[/red]")
652
+ raise typer.Exit(1)
653
+ if not _OIDCCallbackHandler.auth_code:
654
+ console.print("[red]No authorization code received.[/red]")
655
+ raise typer.Exit(1)
656
+
657
+ return _OIDCCallbackHandler.auth_code
658
+
659
+
660
+ def _oidc_login_no_browser(full_auth_url: str) -> str:
661
+ """Print the auth URL, user pastes back the redirect URL containing the code."""
662
+ console.print("\n[bold]Open this URL in any browser to log in:[/bold]\n")
663
+ console.print(f" {full_auth_url}\n")
664
+ console.print(
665
+ "[dim]After login, your browser will redirect to a localhost URL.\n"
666
+ "Copy the full URL from your browser's address bar and paste it below.[/dim]\n"
667
+ )
668
+ redirect_input = typer.prompt("Paste the redirect URL")
669
+
670
+ # Extract the authorization code from the pasted URL
671
+ parsed = urllib.parse.urlparse(redirect_input.strip())
672
+ params = urllib.parse.parse_qs(parsed.query)
673
+
674
+ if "error" in params:
675
+ console.print(f"[red]OIDC error: {params['error'][0]}[/red]")
676
+ raise typer.Exit(1)
677
+
678
+ if "code" not in params:
679
+ console.print("[red]No authorization code found in the URL.[/red]")
680
+ console.print("[dim]Expected a URL like: http://localhost:.../callback?code=...&state=...[/dim]")
681
+ raise typer.Exit(1)
682
+
683
+ return params["code"][0]
684
+
685
+
686
+ def _oidc_exchange_code(
687
+ *,
688
+ token_url: str,
689
+ client_id: str,
690
+ auth_code: str,
691
+ redirect_uri: str,
692
+ code_verifier: str,
693
+ profile_name: str,
694
+ verify: bool = True,
695
+ ) -> None:
696
+ """Exchange an authorization code for tokens and cache them."""
697
+ try:
698
+ with httpx.Client(verify=verify, timeout=30.0) as client:
699
+ resp = client.post(
700
+ token_url,
701
+ data={
702
+ "grant_type": "authorization_code",
703
+ "client_id": client_id,
704
+ "code": auth_code,
705
+ "redirect_uri": redirect_uri,
706
+ "code_verifier": code_verifier,
707
+ },
708
+ )
709
+
710
+ if resp.status_code != 200:
711
+ console.print(f"[red]Token exchange failed ({resp.status_code}):[/red]")
712
+ console.print(resp.text)
713
+ raise typer.Exit(1)
714
+
715
+ token_data = resp.json()
716
+ if "expires_in" in token_data:
717
+ token_data["expires_at"] = time.time() + token_data["expires_in"]
718
+ elif "expires_at" not in token_data:
719
+ token_data["expires_at"] = time.time() + 86400
720
+
721
+ token_cache = CACHE_DIR / f"{profile_name}_token.json"
722
+ ensure_dirs()
723
+ token_cache.write_text(json.dumps(token_data))
724
+ token_cache.chmod(0o600)
725
+
726
+ console.print("[green]Logged in successfully![/green]")
727
+ console.print(f"[dim]Token cached at {token_cache}[/dim]")
728
+
729
+ except httpx.ConnectError:
730
+ console.print(f"[red]Cannot connect to {token_url}[/red]")
731
+ raise typer.Exit(1)
732
+
733
+
472
734
  def _api_key_auth(auth_config: dict, quiet: bool = False) -> dict:
473
735
  """Handle API key auth via custom header."""
474
736
  env_var = auth_config.get("env_var", "")
@@ -878,6 +1140,181 @@ def cmd_call(
878
1140
  console.print(f"[dim]{elapsed:.2f}s[/dim]")
879
1141
 
880
1142
 
1143
+ # ── Commands: run ──────────────────────────────────────────────────────────────
1144
+ def _route_inputs(input_data: dict, parameters: list, has_request_body: bool) -> tuple[dict, dict, dict, dict | None]:
1145
+ """Route flat input keys to path params, query params, headers, and body.
1146
+
1147
+ Returns (path_params, query_params, header_params, body).
1148
+ """
1149
+ path_params = {}
1150
+ query_params = {}
1151
+ header_params = {}
1152
+ body_keys = {}
1153
+
1154
+ # Build a lookup of parameter names to their location
1155
+ param_map: dict[str, str] = {}
1156
+ for p in parameters:
1157
+ if isinstance(p, dict) and "name" in p:
1158
+ param_map[p["name"]] = p.get("in", "query")
1159
+
1160
+ for key, value in input_data.items():
1161
+ location = param_map.get(key)
1162
+ if location == "path":
1163
+ path_params[key] = value
1164
+ elif location == "query":
1165
+ query_params[key] = value
1166
+ elif location == "header":
1167
+ header_params[key] = value
1168
+ elif location:
1169
+ # cookie or other — treat as query
1170
+ query_params[key] = value
1171
+ else:
1172
+ # Not a declared parameter — goes into request body
1173
+ body_keys[key] = value
1174
+
1175
+ body = body_keys if body_keys else None
1176
+ # If there's a requestBody defined but no body keys collected,
1177
+ # and the entire input looks like it could be the body, send it all
1178
+ if has_request_body and not body and not param_map:
1179
+ body = input_data
1180
+
1181
+ return path_params, query_params, header_params, body
1182
+
1183
+
1184
+ @app.command("run")
1185
+ def cmd_run(
1186
+ operation: Annotated[
1187
+ str, typer.Argument(help="Operation ID from the OpenAPI spec (e.g., findPetsByStatus, addPet)")
1188
+ ],
1189
+ input_data: Annotated[
1190
+ Optional[str], typer.Option("--input", "-i", help="Input as JSON (keys auto-routed to path/query/body)")
1191
+ ] = None,
1192
+ input_file: Annotated[Optional[str], typer.Option("--input-file", "-f", help="Read input from a JSON file")] = None,
1193
+ stream: Annotated[bool, typer.Option("--stream", help="Stream SSE response")] = False,
1194
+ raw: Annotated[bool, typer.Option("--raw", help="Print raw response without formatting")] = False,
1195
+ output_json_flag: Annotated[bool, typer.Option("--json", help="Output raw JSON")] = False,
1196
+ ) -> None:
1197
+ """Run an API operation by name. Inputs are auto-routed to the right place.
1198
+
1199
+ The spec defines where each parameter goes (path, query, header, body).
1200
+ You just pass a flat JSON object and the tool figures it out.
1201
+
1202
+ Examples:
1203
+ openapi-cli4ai run findPetsByStatus --input '{"status": "available"}'
1204
+ openapi-cli4ai run getPetById --input '{"petId": 123}'
1205
+ openapi-cli4ai run addPet --input '{"name": "Rex", "status": "available"}'
1206
+ openapi-cli4ai run addPet --input-file pet.json
1207
+ """
1208
+ profile_name, profile = get_active_profile()
1209
+ spec = fetch_spec(profile)
1210
+
1211
+ # Look up the operation in the spec
1212
+ endpoint = extract_full_endpoint_schema(spec, operation)
1213
+ if not endpoint:
1214
+ # Try case-insensitive fuzzy match
1215
+ all_eps = extract_endpoint_summaries(spec)
1216
+ matches = [e for e in all_eps if e["operationId"].lower() == operation.lower()]
1217
+ if matches:
1218
+ endpoint = extract_full_endpoint_schema(spec, matches[0]["operationId"])
1219
+
1220
+ if not endpoint:
1221
+ console.print(f"[red]Operation '{operation}' not found in spec.[/red]")
1222
+ # Suggest similar operations
1223
+ all_eps = extract_endpoint_summaries(spec)
1224
+ op_lower = operation.lower()
1225
+ suggestions = [e["operationId"] for e in all_eps if op_lower in e["operationId"].lower()][:5]
1226
+ if suggestions:
1227
+ console.print("[dim]Did you mean:[/dim]")
1228
+ for s in suggestions:
1229
+ console.print(f" [cyan]{s}[/cyan]")
1230
+ raise typer.Exit(1)
1231
+
1232
+ # Parse input
1233
+ parsed_input = {}
1234
+ if input_file:
1235
+ fp = Path(input_file)
1236
+ if not fp.exists():
1237
+ console.print(f"[red]Input file not found: {input_file}[/red]")
1238
+ raise typer.Exit(1)
1239
+ try:
1240
+ parsed_input = json.loads(fp.read_text())
1241
+ except json.JSONDecodeError as e:
1242
+ console.print(f"[red]Invalid JSON in {input_file}: {e}[/red]")
1243
+ raise typer.Exit(1)
1244
+ elif input_data:
1245
+ try:
1246
+ parsed_input = json.loads(input_data)
1247
+ except json.JSONDecodeError as e:
1248
+ console.print(f"[red]Invalid JSON input: {e}[/red]")
1249
+ raise typer.Exit(1)
1250
+
1251
+ # Route inputs to the right places
1252
+ method = endpoint["method"]
1253
+ path_template = endpoint["path"]
1254
+ parameters = endpoint.get("parameters", [])
1255
+ has_request_body = endpoint.get("requestBody") is not None
1256
+
1257
+ path_params, query_params, header_params, json_body = _route_inputs(parsed_input, parameters, has_request_body)
1258
+
1259
+ # Substitute path parameters
1260
+ full_path = path_template
1261
+ for key, value in path_params.items():
1262
+ full_path = full_path.replace(f"{{{key}}}", str(value))
1263
+
1264
+ # Check for unresolved path params
1265
+ if "{" in full_path:
1266
+ import re as _re
1267
+
1268
+ missing = _re.findall(r"\{(\w+)\}", full_path)
1269
+ console.print(f"[red]Missing required path parameter(s): {', '.join(missing)}[/red]")
1270
+ console.print(f'[dim]Provide them in --input, e.g. --input \'{{"{missing[0]}": "value"}}\'[/dim]')
1271
+ raise typer.Exit(1)
1272
+
1273
+ # Build URL and make request
1274
+ base_url = profile["base_url"].rstrip("/")
1275
+ url = f"{base_url}{full_path}"
1276
+
1277
+ verify = profile.get("verify_ssl", True) and get_verify_ssl()
1278
+ headers = dict(profile.get("headers", {}))
1279
+ headers.update(get_auth_headers(profile))
1280
+ headers.update(header_params)
1281
+
1282
+ start_time = time.perf_counter()
1283
+ console.print(f"[dim]{method} {full_path}...[/dim]")
1284
+
1285
+ with httpx.Client(verify=verify, timeout=60.0, follow_redirects=True) as client:
1286
+ if stream:
1287
+ headers["Accept"] = "text/event-stream"
1288
+ with client.stream(
1289
+ method,
1290
+ url,
1291
+ json=json_body,
1292
+ params=query_params or None,
1293
+ headers=headers,
1294
+ ) as response:
1295
+ if response.status_code >= 400:
1296
+ response.read()
1297
+ _display_error(
1298
+ response.json() if "json" in response.headers.get("content-type", "") else response.text,
1299
+ response.status_code,
1300
+ )
1301
+ raise typer.Exit(1)
1302
+ stream_sse(response)
1303
+ elapsed = time.perf_counter() - start_time
1304
+ console.print(f"[dim]Completed in {elapsed:.2f}s[/dim]")
1305
+ else:
1306
+ response = client.request(
1307
+ method,
1308
+ url,
1309
+ json=json_body,
1310
+ params=query_params or None,
1311
+ headers=headers,
1312
+ )
1313
+ elapsed = time.perf_counter() - start_time
1314
+ handle_response(response, raw=raw, json_output=output_json_flag)
1315
+ console.print(f"[dim]{elapsed:.2f}s[/dim]")
1316
+
1317
+
881
1318
  # ── Commands: init ─────────────────────────────────────────────────────────────
882
1319
  @app.command("init")
883
1320
  def cmd_init(
@@ -887,7 +1324,7 @@ def cmd_init(
887
1324
  Optional[str], typer.Option("--spec", "-s", help="Path to OpenAPI spec (auto-detected if omitted)")
888
1325
  ] = None,
889
1326
  spec_url: Annotated[Optional[str], typer.Option("--spec-url", help="Full URL to OpenAPI spec file")] = None,
890
- auth_type: Annotated[str, typer.Option("--auth", help="Auth type: bearer, api-key, basic, none")] = "none",
1327
+ auth_type: Annotated[str, typer.Option("--auth", help="Auth type: bearer, oidc, api-key, basic, none")] = "none",
891
1328
  ) -> None:
892
1329
  """Initialize a new API profile with guided setup.
893
1330
 
@@ -972,6 +1409,22 @@ def cmd_init(
972
1409
  "password": "{password}",
973
1410
  }
974
1411
  console.print("[dim]Run 'openapi-cli4ai login --username <user>' to authenticate.[/dim]")
1412
+ 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
1427
+ console.print("[dim]Run 'openapi-cli4ai login' to authenticate via browser.[/dim]")
975
1428
  elif auth_type == "api-key":
976
1429
  env_var = typer.prompt("Environment variable for API key", default=f"{name.upper()}_API_KEY")
977
1430
  header_name = typer.prompt("Header name", default="Authorization")
@@ -1031,13 +1484,21 @@ def cmd_login(
1031
1484
  ] = "",
1032
1485
  password_file: Annotated[Optional[str], typer.Option("--password-file", help="Read password from file")] = None,
1033
1486
  password_stdin: Annotated[bool, typer.Option("--password-stdin", help="Read password from stdin")] = False,
1487
+ no_browser: Annotated[
1488
+ bool,
1489
+ typer.Option("--no-browser", help="OIDC: print login URL instead of opening browser (for headless/SSH)"),
1490
+ ] = False,
1034
1491
  ) -> None:
1035
1492
  """Login to an API that uses OAuth/token-endpoint authentication.
1036
1493
 
1037
- This is for APIs with auth.type=bearer and a token_endpoint configured.
1038
- For APIs using static tokens or API keys, just set the environment variable.
1494
+ Supports two auth modes:
1495
+ - auth.type=bearer with token_endpoint: username/password grant
1496
+ - auth.type=oidc: Authorization Code + PKCE flow (browser or --no-browser)
1039
1497
 
1040
- Password input methods (in priority order):
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.
1500
+
1501
+ Password input methods (bearer mode, in priority order):
1041
1502
  1. --password-file /path/to/file
1042
1503
  2. --password-stdin (piped input)
1043
1504
  3. --password flag (avoid for special characters)
@@ -1046,8 +1507,22 @@ def cmd_login(
1046
1507
  profile_name, profile = get_active_profile()
1047
1508
  auth_config = profile.get("auth", {})
1048
1509
 
1510
+ # OIDC flow
1511
+ if auth_config.get("type") == "oidc":
1512
+ 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
1522
+ return
1523
+
1049
1524
  if auth_config.get("type") != "bearer" or not auth_config.get("token_endpoint"):
1050
- console.print("[yellow]Login is for profiles with bearer auth + token_endpoint.[/yellow]")
1525
+ console.print("[yellow]Login is for profiles with bearer auth + token_endpoint, or oidc.[/yellow]")
1051
1526
  console.print("[dim]If your API uses a static token or API key, set the environment variable instead.[/dim]")
1052
1527
  raise typer.Exit(1)
1053
1528
 
@@ -1154,7 +1629,7 @@ def cmd_profile_add(
1154
1629
  name: Annotated[str, typer.Argument(help="Profile name")],
1155
1630
  url: Annotated[str, typer.Option("--url", "-u", help="Base URL of the API")] = "",
1156
1631
  spec_path: Annotated[str, typer.Option("--spec", "-s", help="Path to OpenAPI spec")] = "/openapi.json",
1157
- auth_type: Annotated[str, typer.Option("--auth", help="Auth type: bearer, api-key, basic, none")] = "none",
1632
+ auth_type: Annotated[str, typer.Option("--auth", help="Auth type: bearer, oidc, api-key, basic, none")] = "none",
1158
1633
  ) -> None:
1159
1634
  """Add a new API profile."""
1160
1635
  if not url:
@@ -0,0 +1,130 @@
1
+ """Tests for OIDC Authorization Code + PKCE auth."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import time
9
+
10
+ import pytest
11
+ from click.exceptions import Exit as ClickExit
12
+
13
+
14
+ def test_oidc_auth_returns_cached_token(cli_module, tmp_config):
15
+ """Should return cached token when it's still valid."""
16
+ mod, tmp_path, cache_dir = tmp_config
17
+ token_data = {
18
+ "access_token": "oidc-token-123",
19
+ "expires_at": time.time() + 3600,
20
+ }
21
+ token_file = cache_dir / "testprofile_token.json"
22
+ token_file.write_text(json.dumps(token_data))
23
+ token_file.chmod(0o600)
24
+
25
+ profile = {
26
+ "_name": "testprofile",
27
+ "auth": {
28
+ "type": "oidc",
29
+ "authorize_url": "https://idp.example.com/authorize",
30
+ "token_url": "https://idp.example.com/token",
31
+ "client_id": "my-client",
32
+ },
33
+ }
34
+ headers = mod._oidc_auth(profile, profile["auth"])
35
+ assert headers == {"Authorization": "Bearer oidc-token-123"}
36
+
37
+
38
+ def test_oidc_auth_expired_token_prompts_login(cli_module, tmp_config):
39
+ """Should exit with login prompt when token is expired and no refresh token."""
40
+ mod, tmp_path, cache_dir = tmp_config
41
+ token_data = {
42
+ "access_token": "expired-token",
43
+ "expires_at": time.time() - 100,
44
+ }
45
+ token_file = cache_dir / "testprofile_token.json"
46
+ token_file.write_text(json.dumps(token_data))
47
+
48
+ profile = {
49
+ "_name": "testprofile",
50
+ "auth": {
51
+ "type": "oidc",
52
+ "authorize_url": "https://idp.example.com/authorize",
53
+ "token_url": "https://idp.example.com/token",
54
+ "client_id": "my-client",
55
+ },
56
+ }
57
+ with pytest.raises((SystemExit, ClickExit)):
58
+ mod._oidc_auth(profile, profile["auth"])
59
+
60
+
61
+ def test_oidc_auth_no_cache_prompts_login(cli_module, tmp_config):
62
+ """Should exit with login prompt when no cached token exists."""
63
+ mod, tmp_path, cache_dir = tmp_config
64
+ profile = {
65
+ "_name": "testprofile",
66
+ "auth": {
67
+ "type": "oidc",
68
+ "authorize_url": "https://idp.example.com/authorize",
69
+ "token_url": "https://idp.example.com/token",
70
+ "client_id": "my-client",
71
+ },
72
+ }
73
+ with pytest.raises((SystemExit, ClickExit)):
74
+ mod._oidc_auth(profile, profile["auth"])
75
+
76
+
77
+ def test_oidc_refresh_missing_config(cli_module):
78
+ """Should return None when token_url or client_id is missing."""
79
+ result = cli_module._oidc_refresh({"client_id": "x"}, {"refresh_token": "rt"})
80
+ assert result is None
81
+ result = cli_module._oidc_refresh({"token_url": "x"}, {"refresh_token": "rt"})
82
+ assert result is None
83
+
84
+
85
+ def test_pkce_challenge_computation(cli_module):
86
+ """Verify PKCE S256 challenge is computed correctly per RFC 7636."""
87
+ import secrets
88
+
89
+ verifier = secrets.token_urlsafe(64)
90
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
91
+
92
+ # Verify it's base64url without padding
93
+ assert "=" not in challenge
94
+ assert "+" not in challenge
95
+ assert "/" not in challenge
96
+ # Verify it decodes back to 32 bytes (SHA-256 output)
97
+ padded = challenge + "=" * (4 - len(challenge) % 4)
98
+ decoded = base64.urlsafe_b64decode(padded)
99
+ assert len(decoded) == 32
100
+
101
+
102
+ def test_oidc_callback_handler_state_validation(cli_module):
103
+ """The callback handler class should have expected_state attribute."""
104
+ handler_cls = cli_module._OIDCCallbackHandler
105
+ assert hasattr(handler_cls, "expected_state")
106
+ assert hasattr(handler_cls, "auth_code")
107
+ assert hasattr(handler_cls, "error")
108
+
109
+
110
+ def test_get_auth_headers_dispatches_oidc(cli_module, tmp_config):
111
+ """get_auth_headers should dispatch to _oidc_auth for type=oidc."""
112
+ mod, tmp_path, cache_dir = tmp_config
113
+ token_data = {
114
+ "access_token": "dispatched-token",
115
+ "expires_at": time.time() + 3600,
116
+ }
117
+ token_file = cache_dir / "testprofile_token.json"
118
+ token_file.write_text(json.dumps(token_data))
119
+
120
+ profile = {
121
+ "_name": "testprofile",
122
+ "auth": {
123
+ "type": "oidc",
124
+ "authorize_url": "https://idp.example.com/authorize",
125
+ "token_url": "https://idp.example.com/token",
126
+ "client_id": "my-client",
127
+ },
128
+ }
129
+ headers = mod.get_auth_headers(profile)
130
+ assert headers == {"Authorization": "Bearer dispatched-token"}
@@ -0,0 +1,165 @@
1
+ """Tests for the run command and input routing."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def test_route_inputs_path_params(cli_module):
7
+ """Should route path parameters correctly."""
8
+ parameters = [
9
+ {"name": "petId", "in": "path"},
10
+ {"name": "status", "in": "query"},
11
+ ]
12
+ input_data = {"petId": 123, "status": "available"}
13
+
14
+ path_params, query_params, header_params, body = cli_module._route_inputs(
15
+ input_data, parameters, has_request_body=False
16
+ )
17
+
18
+ assert path_params == {"petId": 123}
19
+ assert query_params == {"status": "available"}
20
+ assert header_params == {}
21
+ assert body is None
22
+
23
+
24
+ def test_route_inputs_query_params(cli_module):
25
+ """Should route query parameters correctly."""
26
+ parameters = [
27
+ {"name": "status", "in": "query"},
28
+ {"name": "limit", "in": "query"},
29
+ ]
30
+ input_data = {"status": "available", "limit": 10}
31
+
32
+ path_params, query_params, header_params, body = cli_module._route_inputs(
33
+ input_data, parameters, has_request_body=False
34
+ )
35
+
36
+ assert path_params == {}
37
+ assert query_params == {"status": "available", "limit": 10}
38
+ assert body is None
39
+
40
+
41
+ def test_route_inputs_header_params(cli_module):
42
+ """Should route header parameters correctly."""
43
+ parameters = [
44
+ {"name": "X-Request-Id", "in": "header"},
45
+ ]
46
+ input_data = {"X-Request-Id": "abc-123", "name": "Rex"}
47
+
48
+ path_params, query_params, header_params, body = cli_module._route_inputs(
49
+ input_data, parameters, has_request_body=True
50
+ )
51
+
52
+ assert header_params == {"X-Request-Id": "abc-123"}
53
+ assert body == {"name": "Rex"}
54
+
55
+
56
+ def test_route_inputs_body_from_undeclared_keys(cli_module):
57
+ """Keys not matching any parameter should go to body."""
58
+ parameters = [
59
+ {"name": "petId", "in": "path"},
60
+ ]
61
+ input_data = {"petId": 1, "name": "Rex", "status": "available"}
62
+
63
+ path_params, query_params, header_params, body = cli_module._route_inputs(
64
+ input_data, parameters, has_request_body=True
65
+ )
66
+
67
+ assert path_params == {"petId": 1}
68
+ assert body == {"name": "Rex", "status": "available"}
69
+
70
+
71
+ def test_route_inputs_all_body_when_no_params(cli_module):
72
+ """When no parameters declared and requestBody exists, send all as body."""
73
+ parameters = []
74
+ input_data = {"name": "Rex", "status": "available"}
75
+
76
+ path_params, query_params, header_params, body = cli_module._route_inputs(
77
+ input_data, parameters, has_request_body=True
78
+ )
79
+
80
+ assert body == {"name": "Rex", "status": "available"}
81
+
82
+
83
+ def test_route_inputs_empty_input(cli_module):
84
+ """Should handle empty input gracefully."""
85
+ parameters = [
86
+ {"name": "status", "in": "query"},
87
+ ]
88
+
89
+ path_params, query_params, header_params, body = cli_module._route_inputs({}, parameters, has_request_body=False)
90
+
91
+ assert path_params == {}
92
+ assert query_params == {}
93
+ assert header_params == {}
94
+ assert body is None
95
+
96
+
97
+ def test_route_inputs_no_body_when_not_declared(cli_module):
98
+ """Undeclared keys without requestBody should still go to body dict."""
99
+ parameters = []
100
+ input_data = {"name": "Rex"}
101
+
102
+ path_params, query_params, header_params, body = cli_module._route_inputs(
103
+ input_data, parameters, has_request_body=False
104
+ )
105
+
106
+ # No requestBody and no param_map — body_keys has content but not the fallback path
107
+ assert body == {"name": "Rex"}
108
+
109
+
110
+ def test_route_inputs_mixed_all_locations(cli_module):
111
+ """Should correctly split input across path, query, header, and body."""
112
+ parameters = [
113
+ {"name": "userId", "in": "path"},
114
+ {"name": "format", "in": "query"},
115
+ {"name": "X-Trace-Id", "in": "header"},
116
+ ]
117
+ input_data = {
118
+ "userId": 42,
119
+ "format": "json",
120
+ "X-Trace-Id": "trace-abc",
121
+ "email": "user@example.com",
122
+ "name": "Alice",
123
+ }
124
+
125
+ path_params, query_params, header_params, body = cli_module._route_inputs(
126
+ input_data, parameters, has_request_body=True
127
+ )
128
+
129
+ assert path_params == {"userId": 42}
130
+ assert query_params == {"format": "json"}
131
+ assert header_params == {"X-Trace-Id": "trace-abc"}
132
+ assert body == {"email": "user@example.com", "name": "Alice"}
133
+
134
+
135
+ def test_extract_full_endpoint_schema_has_parameters(cli_module, petstore_spec):
136
+ """Should extract parameters with 'in' locations from petstore spec."""
137
+ endpoint = cli_module.extract_full_endpoint_schema(petstore_spec, "findPetsByStatus")
138
+ assert endpoint is not None
139
+ assert endpoint["method"] == "GET"
140
+ assert endpoint["path"] == "/pet/findByStatus"
141
+ # findPetsByStatus has a 'status' query parameter
142
+ param_names = [p["name"] for p in endpoint["parameters"]]
143
+ assert "status" in param_names
144
+ status_param = next(p for p in endpoint["parameters"] if p["name"] == "status")
145
+ assert status_param["in"] == "query"
146
+
147
+
148
+ def test_extract_full_endpoint_schema_path_param(cli_module, petstore_spec):
149
+ """Should extract path parameters from petstore spec."""
150
+ endpoint = cli_module.extract_full_endpoint_schema(petstore_spec, "getPetById")
151
+ assert endpoint is not None
152
+ assert endpoint["method"] == "GET"
153
+ assert "/pet/{petId}" == endpoint["path"]
154
+ param_names = [p["name"] for p in endpoint["parameters"]]
155
+ assert "petId" in param_names
156
+ pet_id_param = next(p for p in endpoint["parameters"] if p["name"] == "petId")
157
+ assert pet_id_param["in"] == "path"
158
+
159
+
160
+ def test_extract_full_endpoint_schema_with_request_body(cli_module, petstore_spec):
161
+ """Should detect requestBody on POST endpoints."""
162
+ endpoint = cli_module.extract_full_endpoint_schema(petstore_spec, "addPet")
163
+ assert endpoint is not None
164
+ assert endpoint["method"] == "POST"
165
+ assert endpoint["requestBody"] is not None
Binary file
File without changes