intflow-authctl 0.2.1__tar.gz → 0.2.2__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.
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/PKG-INFO +21 -1
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/README.md +20 -0
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/pyproject.toml +1 -1
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/__init__.py +1 -1
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/api_client.py +9 -4
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/commands.py +65 -18
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/config.py +73 -7
- intflow_authctl-0.2.2/src/intflow_authctl/errors.py +14 -0
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/output.py +18 -12
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/parser.py +18 -2
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/secret_output.py +13 -5
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/tokens.py +11 -6
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/validation.py +4 -5
- intflow_authctl-0.2.1/src/intflow_authctl/errors.py +0 -5
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/.gitignore +0 -0
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/audit.py +0 -0
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/cli.py +0 -0
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/clients.py +0 -0
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/command_utils.py +0 -0
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/constants.py +0 -0
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/credentials.py +0 -0
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/discovery.py +0 -0
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/external_access.py +0 -0
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/py.typed +0 -0
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/users.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: intflow-authctl
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Intflow Auth operator CLI.
|
|
5
5
|
Author: Intflow
|
|
6
6
|
License: Proprietary
|
|
@@ -114,3 +114,23 @@ through your approved secret delivery mechanism and remove it when no longer nee
|
|
|
114
114
|
Public clients cannot hold secrets. Use confidential clients for trusted backends,
|
|
115
115
|
register exact callback URLs, and use PKCE S256. Add `offline_access` only when
|
|
116
116
|
refresh tokens are needed. App administrator assignment is a separate operation.
|
|
117
|
+
|
|
118
|
+
## Recovery and local diagnostics
|
|
119
|
+
|
|
120
|
+
`intflow-authctl --version` identifies the installed version and distribution.
|
|
121
|
+
`doctor` remains read-only; `doctor --check-store` explicitly tests local storage
|
|
122
|
+
with a temporary nonsecret write/read/delete probe and needs no login or server.
|
|
123
|
+
Login runs the same preflight before requesting browser approval.
|
|
124
|
+
|
|
125
|
+
Login polling honors server intervals and bounded HTTP 429 Retry-After guidance.
|
|
126
|
+
It does not retry transport failures or 5xx responses, because one-time redemption
|
|
127
|
+
may already have succeeded. Re-login never revokes server tokens. It removes only
|
|
128
|
+
a previous local entry owned by the same config; legacy/shared entries are retained
|
|
129
|
+
with a warning to preserve other profiles. Local logout can clear a config whose
|
|
130
|
+
keyring entry is already missing. Use `logout --revoke` for explicit remote revocation.
|
|
131
|
+
|
|
132
|
+
`tokens create --name automation --ttl 1h --secret-file /private/new-token.json`
|
|
133
|
+
stores the full one-time response in a new protected file and returns `token_file`
|
|
134
|
+
instead of the bearer. Without that option, existing one-time JSON stdout is unchanged.
|
|
135
|
+
If issuance succeeds but storage fails, inspect and explicitly revoke the unused
|
|
136
|
+
token before repeating issuance. File output uses the same protections as client secrets.
|
|
@@ -93,3 +93,23 @@ through your approved secret delivery mechanism and remove it when no longer nee
|
|
|
93
93
|
Public clients cannot hold secrets. Use confidential clients for trusted backends,
|
|
94
94
|
register exact callback URLs, and use PKCE S256. Add `offline_access` only when
|
|
95
95
|
refresh tokens are needed. App administrator assignment is a separate operation.
|
|
96
|
+
|
|
97
|
+
## Recovery and local diagnostics
|
|
98
|
+
|
|
99
|
+
`intflow-authctl --version` identifies the installed version and distribution.
|
|
100
|
+
`doctor` remains read-only; `doctor --check-store` explicitly tests local storage
|
|
101
|
+
with a temporary nonsecret write/read/delete probe and needs no login or server.
|
|
102
|
+
Login runs the same preflight before requesting browser approval.
|
|
103
|
+
|
|
104
|
+
Login polling honors server intervals and bounded HTTP 429 Retry-After guidance.
|
|
105
|
+
It does not retry transport failures or 5xx responses, because one-time redemption
|
|
106
|
+
may already have succeeded. Re-login never revokes server tokens. It removes only
|
|
107
|
+
a previous local entry owned by the same config; legacy/shared entries are retained
|
|
108
|
+
with a warning to preserve other profiles. Local logout can clear a config whose
|
|
109
|
+
keyring entry is already missing. Use `logout --revoke` for explicit remote revocation.
|
|
110
|
+
|
|
111
|
+
`tokens create --name automation --ttl 1h --secret-file /private/new-token.json`
|
|
112
|
+
stores the full one-time response in a new protected file and returns `token_file`
|
|
113
|
+
instead of the bearer. Without that option, existing one-time JSON stdout is unchanged.
|
|
114
|
+
If issuance succeeds but storage fails, inspect and explicitly revoke the unused
|
|
115
|
+
token before repeating issuance. File output uses the same protections as client secrets.
|
|
@@ -6,7 +6,7 @@ import httpx
|
|
|
6
6
|
|
|
7
7
|
from intflow_authctl.constants import DEFAULT_AUTHCTL_TIMEOUT_SECONDS
|
|
8
8
|
from intflow_authctl.discovery import permission_hint
|
|
9
|
-
from intflow_authctl.errors import AuthctlError
|
|
9
|
+
from intflow_authctl.errors import AuthctlError, AuthctlHttpError
|
|
10
10
|
from intflow_authctl.validation import _normalize_base_url
|
|
11
11
|
|
|
12
12
|
MAX_RETRY_AFTER_SECONDS = 86_400
|
|
@@ -46,6 +46,7 @@ def _api_request_without_auth(
|
|
|
46
46
|
*,
|
|
47
47
|
client_factory,
|
|
48
48
|
json_body: dict[str, object] | None = None,
|
|
49
|
+
timeout_seconds: float = DEFAULT_AUTHCTL_TIMEOUT_SECONDS,
|
|
49
50
|
) -> dict[str, object]:
|
|
50
51
|
return _api_request_to_base_url(
|
|
51
52
|
base_url,
|
|
@@ -55,6 +56,7 @@ def _api_request_without_auth(
|
|
|
55
56
|
json_body=json_body,
|
|
56
57
|
params=None,
|
|
57
58
|
client_factory=client_factory,
|
|
59
|
+
timeout_seconds=timeout_seconds,
|
|
58
60
|
)
|
|
59
61
|
|
|
60
62
|
|
|
@@ -67,13 +69,14 @@ def _api_request_to_base_url(
|
|
|
67
69
|
client_factory,
|
|
68
70
|
json_body: dict[str, object] | None,
|
|
69
71
|
params: dict[str, object] | None,
|
|
72
|
+
timeout_seconds: float = DEFAULT_AUTHCTL_TIMEOUT_SECONDS,
|
|
70
73
|
) -> dict[str, object]:
|
|
71
74
|
base_url = _normalize_base_url(base_url)
|
|
72
75
|
try:
|
|
73
76
|
with client_factory(
|
|
74
77
|
base_url=base_url,
|
|
75
78
|
headers=headers,
|
|
76
|
-
timeout=DEFAULT_AUTHCTL_TIMEOUT_SECONDS,
|
|
79
|
+
timeout=min(timeout_seconds, DEFAULT_AUTHCTL_TIMEOUT_SECONDS),
|
|
77
80
|
) as client:
|
|
78
81
|
request_kwargs: dict[str, object] = {"params": params}
|
|
79
82
|
if json_body is not None:
|
|
@@ -93,10 +96,12 @@ def _api_request_to_base_url(
|
|
|
93
96
|
if response.status_code >= 400:
|
|
94
97
|
retry_after = _retry_after_seconds(response.headers.get("retry-after"))
|
|
95
98
|
retry_message = "" if retry_after is None else f" Retry after {retry_after} seconds."
|
|
96
|
-
raise
|
|
99
|
+
raise AuthctlHttpError(
|
|
97
100
|
f"Admin API request failed: {response.status_code} "
|
|
98
101
|
f"{_safe_error_message(response)}.{retry_message}"
|
|
99
|
-
f"{permission_hint(response.status_code, method, path)}"
|
|
102
|
+
f"{permission_hint(response.status_code, method, path)}",
|
|
103
|
+
response.status_code,
|
|
104
|
+
retry_after,
|
|
100
105
|
)
|
|
101
106
|
if response.status_code == 204 or not response.content:
|
|
102
107
|
return {"status": "ok"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Command dispatch for the Intflow Auth operator CLI."""
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
import math
|
|
4
5
|
import os
|
|
5
6
|
import time
|
|
6
7
|
import webbrowser
|
|
@@ -17,13 +18,14 @@ from intflow_authctl.clients import _run_authctl_clients_command
|
|
|
17
18
|
from intflow_authctl.config import (
|
|
18
19
|
_authctl_config_path,
|
|
19
20
|
_authctl_credential_store,
|
|
21
|
+
_check_credential_storage,
|
|
20
22
|
_delete_authctl_config,
|
|
21
23
|
_load_authctl_config,
|
|
22
24
|
_save_authctl_config,
|
|
23
25
|
)
|
|
24
26
|
from intflow_authctl.credentials import AuthctlCredentialStore
|
|
25
27
|
from intflow_authctl.discovery import documentation
|
|
26
|
-
from intflow_authctl.errors import AuthctlError
|
|
28
|
+
from intflow_authctl.errors import AuthctlError, AuthctlHttpError
|
|
27
29
|
from intflow_authctl.external_access import _run_authctl_external_access_command
|
|
28
30
|
from intflow_authctl.tokens import _run_authctl_tokens_command
|
|
29
31
|
from intflow_authctl.users import _run_authctl_users_command
|
|
@@ -51,6 +53,7 @@ def _run_authctl_command(
|
|
|
51
53
|
webbrowser.open(str(result["api_docs"]))
|
|
52
54
|
return result
|
|
53
55
|
if args.command == "login":
|
|
56
|
+
_check_credential_storage(config_path, selected_credential_store)
|
|
54
57
|
if args.token is not None:
|
|
55
58
|
print(
|
|
56
59
|
"WARNING: --token is deprecated because command arguments may enter "
|
|
@@ -76,9 +79,9 @@ def _run_authctl_command(
|
|
|
76
79
|
)
|
|
77
80
|
|
|
78
81
|
if args.command == "logout":
|
|
79
|
-
config = _load_authctl_config(config_path, selected_credential_store)
|
|
80
|
-
_warn_if_file_config(config, config_path, error_output)
|
|
81
82
|
if args.revoke:
|
|
83
|
+
config = _load_authctl_config(config_path, selected_credential_store)
|
|
84
|
+
_warn_if_file_config(config, config_path, error_output)
|
|
82
85
|
token_id = config.get("token_id")
|
|
83
86
|
if not isinstance(token_id, str) or not token_id:
|
|
84
87
|
whoami = _api_request(
|
|
@@ -104,6 +107,17 @@ def _run_authctl_command(
|
|
|
104
107
|
_delete_authctl_config(config_path, selected_credential_store)
|
|
105
108
|
return {"status": "logged_out"}
|
|
106
109
|
|
|
110
|
+
if args.command == "doctor" and args.check_store:
|
|
111
|
+
_check_credential_storage(config_path, selected_credential_store)
|
|
112
|
+
return {
|
|
113
|
+
"status": "ok",
|
|
114
|
+
"checks": ["config_writable", "credential_store_probe"],
|
|
115
|
+
"credential_backend": (
|
|
116
|
+
selected_credential_store.backend_name if selected_credential_store else "file"
|
|
117
|
+
),
|
|
118
|
+
"next": "intflow-authctl login",
|
|
119
|
+
}
|
|
120
|
+
|
|
107
121
|
config = _load_authctl_config(config_path, selected_credential_store)
|
|
108
122
|
_warn_if_file_config(config, config_path, error_output)
|
|
109
123
|
if args.command in {"whoami", "doctor"}:
|
|
@@ -207,6 +221,9 @@ def _run_authctl_browser_login(
|
|
|
207
221
|
) -> dict[str, object]:
|
|
208
222
|
base_url = _normalize_base_url(args.base_url)
|
|
209
223
|
ttl_seconds = _parse_ttl_seconds(args.ttl)
|
|
224
|
+
timeout = _positive_float(args.timeout_seconds, "timeout-seconds")
|
|
225
|
+
if not math.isfinite(args.poll_interval):
|
|
226
|
+
raise AuthctlError("poll-interval must be finite.")
|
|
210
227
|
start_body: dict[str, object] = {
|
|
211
228
|
"name": args.name,
|
|
212
229
|
"ttl_seconds": ttl_seconds,
|
|
@@ -227,28 +244,58 @@ def _run_authctl_browser_login(
|
|
|
227
244
|
if not args.no_browser:
|
|
228
245
|
webbrowser.open(verification_uri)
|
|
229
246
|
|
|
230
|
-
deadline = time.monotonic() +
|
|
231
|
-
|
|
247
|
+
deadline = time.monotonic() + timeout
|
|
248
|
+
advertised = transaction.get("poll_interval_seconds")
|
|
249
|
+
server_interval = (
|
|
250
|
+
float(advertised)
|
|
251
|
+
if isinstance(advertised, (float, int)) and math.isfinite(advertised) and advertised > 0
|
|
252
|
+
else 2.0
|
|
253
|
+
)
|
|
254
|
+
poll_interval = max(args.poll_interval, server_interval, 1.0)
|
|
232
255
|
while True:
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
"
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
256
|
+
remaining = deadline - time.monotonic()
|
|
257
|
+
if remaining <= 0:
|
|
258
|
+
raise AuthctlError("CLI login timed out before browser approval completed.")
|
|
259
|
+
try:
|
|
260
|
+
response = _api_request_without_auth(
|
|
261
|
+
base_url,
|
|
262
|
+
"POST",
|
|
263
|
+
f"/v1/cli-login-transactions/{transaction_id}:redeem",
|
|
264
|
+
json_body={"poll_secret": poll_secret},
|
|
265
|
+
client_factory=client_factory,
|
|
266
|
+
timeout_seconds=remaining,
|
|
267
|
+
)
|
|
268
|
+
except AuthctlHttpError as exc:
|
|
269
|
+
# Retry only a definite rejection before redemption; never replay a lost response.
|
|
270
|
+
if exc.status != 429:
|
|
271
|
+
raise
|
|
272
|
+
delay = max(poll_interval, exc.retry_after or server_interval)
|
|
273
|
+
print(
|
|
274
|
+
"Login polling was rate limited; waiting within the login deadline.",
|
|
275
|
+
file=error_output,
|
|
276
|
+
)
|
|
277
|
+
time.sleep(min(delay, max(deadline - time.monotonic(), 0.0)))
|
|
278
|
+
continue
|
|
240
279
|
if response.get("status") != "pending":
|
|
241
280
|
token = _required_response_string(response, "token")
|
|
242
281
|
operator_token = response.get("operator_token")
|
|
243
282
|
config: dict[str, object] = {"base_url": base_url, "token": token}
|
|
244
283
|
if isinstance(operator_token, dict) and operator_token.get("id"):
|
|
245
284
|
config["token_id"] = str(operator_token["id"])
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
285
|
+
try:
|
|
286
|
+
_save_authctl_config(
|
|
287
|
+
config_path,
|
|
288
|
+
config,
|
|
289
|
+
token=token,
|
|
290
|
+
credential_store=credential_store,
|
|
291
|
+
)
|
|
292
|
+
except (AuthctlError, OSError):
|
|
293
|
+
raise AuthctlError(
|
|
294
|
+
"Login token was issued but local storage failed. Run doctor --check-store, "
|
|
295
|
+
"then log in again; inspect tokens list and explicitly revoke "
|
|
296
|
+
"the unused token. "
|
|
297
|
+
"No token was printed or automatically revoked."
|
|
298
|
+
) from None
|
|
252
299
|
return {
|
|
253
300
|
"status": "logged_in",
|
|
254
301
|
"base_url": base_url,
|
|
@@ -72,9 +72,7 @@ def _load_authctl_config(
|
|
|
72
72
|
if not isinstance(service, str) or not isinstance(account, str):
|
|
73
73
|
raise AuthctlError(f"Invalid CLI config file: {path}")
|
|
74
74
|
if credential_store is None:
|
|
75
|
-
raise AuthctlError(
|
|
76
|
-
"CLI config uses an OS credential store; use --credential-store auto."
|
|
77
|
-
)
|
|
75
|
+
raise AuthctlError("CLI config uses an OS credential store; use --credential-store auto.")
|
|
78
76
|
store = credential_store
|
|
79
77
|
if backend != store.backend_name:
|
|
80
78
|
raise AuthctlError(f"Invalid CLI config file: {path}")
|
|
@@ -95,9 +93,11 @@ def _save_authctl_config(
|
|
|
95
93
|
token: str,
|
|
96
94
|
credential_store: AuthctlCredentialStore | None,
|
|
97
95
|
) -> None:
|
|
96
|
+
previous = _load_raw_authctl_config(path)
|
|
97
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
98
|
stored_config = dict(config)
|
|
99
99
|
stored_config.pop("token", None)
|
|
100
|
-
credential_account = _credential_account(stored_config)
|
|
100
|
+
credential_account = f"{_credential_account(stored_config)}|local-{secrets.token_hex(16)}"
|
|
101
101
|
secret_stored = False
|
|
102
102
|
if credential_store is not None:
|
|
103
103
|
try:
|
|
@@ -112,6 +112,7 @@ def _save_authctl_config(
|
|
|
112
112
|
"credential_backend": credential_store.backend_name,
|
|
113
113
|
"credential_service": AUTHCTL_CREDENTIAL_SERVICE,
|
|
114
114
|
"credential_account": credential_account,
|
|
115
|
+
"credential_owner": _config_owner(path),
|
|
115
116
|
}
|
|
116
117
|
)
|
|
117
118
|
except AuthctlCredentialStoreError as exc:
|
|
@@ -122,7 +123,6 @@ def _save_authctl_config(
|
|
|
122
123
|
else:
|
|
123
124
|
stored_config.update({"credential_backend": "file", "token": token})
|
|
124
125
|
|
|
125
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
126
126
|
temporary_path = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
|
127
127
|
replaced = False
|
|
128
128
|
try:
|
|
@@ -150,7 +150,72 @@ def _save_authctl_config(
|
|
|
150
150
|
if secret_stored and credential_store is not None:
|
|
151
151
|
with suppress(AuthctlCredentialStoreError):
|
|
152
152
|
credential_store.delete_password(AUTHCTL_CREDENTIAL_SERVICE, credential_account)
|
|
153
|
-
raise
|
|
153
|
+
raise AuthctlError(
|
|
154
|
+
"Could not save CLI configuration; the previous login was retained where possible."
|
|
155
|
+
) from None
|
|
156
|
+
if (
|
|
157
|
+
previous
|
|
158
|
+
and previous.get("credential_account")
|
|
159
|
+
and previous.get("credential_backend") != "file"
|
|
160
|
+
):
|
|
161
|
+
if (
|
|
162
|
+
credential_store is not None
|
|
163
|
+
and previous.get("credential_backend") == credential_store.backend_name
|
|
164
|
+
and previous.get("credential_owner") == _config_owner(path)
|
|
165
|
+
and previous.get("credential_service") == AUTHCTL_CREDENTIAL_SERVICE
|
|
166
|
+
):
|
|
167
|
+
try:
|
|
168
|
+
credential_store.delete_password(
|
|
169
|
+
AUTHCTL_CREDENTIAL_SERVICE, str(previous["credential_account"])
|
|
170
|
+
)
|
|
171
|
+
except AuthctlCredentialStoreError:
|
|
172
|
+
_storage_warning(
|
|
173
|
+
"Login saved, but the previous local credential could not be removed."
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
_storage_warning(
|
|
177
|
+
"Previous legacy/shared credential retained to protect other profiles."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _storage_warning(message: str) -> None:
|
|
182
|
+
# Diagnostics must not interfere with successful persistence or expose secrets.
|
|
183
|
+
import sys
|
|
184
|
+
|
|
185
|
+
print(f"WARNING: {message} Remote tokens were not revoked.", file=sys.stderr)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _config_owner(path: Path) -> str:
|
|
189
|
+
return os.path.normcase(os.path.abspath(path))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _check_credential_storage(path: Path, store: AuthctlCredentialStore | None) -> None:
|
|
193
|
+
probe = f"probe-{secrets.token_hex(16)}"
|
|
194
|
+
temporary = path.parent / f".authctl-{probe}"
|
|
195
|
+
try:
|
|
196
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
if path.exists() and not path.is_file():
|
|
198
|
+
raise OSError("Not a file")
|
|
199
|
+
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
200
|
+
with os.fdopen(descriptor, "w") as probe_file:
|
|
201
|
+
probe_file.write(probe)
|
|
202
|
+
probe_file.flush()
|
|
203
|
+
os.fsync(probe_file.fileno())
|
|
204
|
+
if store is not None:
|
|
205
|
+
try:
|
|
206
|
+
store.set_password(AUTHCTL_CREDENTIAL_SERVICE, probe, probe)
|
|
207
|
+
if store.get_password(AUTHCTL_CREDENTIAL_SERVICE, probe) != probe:
|
|
208
|
+
raise AuthctlCredentialStoreError("Probe failed")
|
|
209
|
+
finally:
|
|
210
|
+
store.delete_password(AUTHCTL_CREDENTIAL_SERVICE, probe)
|
|
211
|
+
except (OSError, AuthctlCredentialStoreError):
|
|
212
|
+
raise AuthctlError(
|
|
213
|
+
"Credential storage is unavailable; no login request was sent. Check config directory "
|
|
214
|
+
"and OS keyring, or explicitly select --credential-store file if acceptable."
|
|
215
|
+
) from None
|
|
216
|
+
finally:
|
|
217
|
+
with suppress(OSError):
|
|
218
|
+
temporary.unlink(missing_ok=True)
|
|
154
219
|
|
|
155
220
|
|
|
156
221
|
def _delete_authctl_config(
|
|
@@ -168,7 +233,8 @@ def _delete_authctl_config(
|
|
|
168
233
|
)
|
|
169
234
|
store = credential_store
|
|
170
235
|
try:
|
|
171
|
-
store.
|
|
236
|
+
if store.get_password(service, account) is not None:
|
|
237
|
+
store.delete_password(service, account)
|
|
172
238
|
except AuthctlCredentialStoreError as exc:
|
|
173
239
|
raise AuthctlError(
|
|
174
240
|
"Stored CLI credential could not be deleted; local config was retained."
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Error types for the Intflow Auth operator CLI."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AuthctlError(RuntimeError):
|
|
5
|
+
"""Raised when the routine Admin API CLI cannot complete a command."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthctlHttpError(AuthctlError):
|
|
9
|
+
"""Structured HTTP metadata without retaining upstream bodies or credentials."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, status: int, retry_after: int | None) -> None:
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.status = status
|
|
14
|
+
self.retry_after = retry_after
|
|
@@ -30,24 +30,21 @@ def _print_table(
|
|
|
30
30
|
) -> None:
|
|
31
31
|
columns, rows = _table_shape(args, payload)
|
|
32
32
|
rendered_rows = [
|
|
33
|
-
[_format_table_value(row.get(field)) for field, _title in columns]
|
|
34
|
-
for row in rows
|
|
33
|
+
[_format_table_value(row.get(field)) for field, _title in columns] for row in rows
|
|
35
34
|
]
|
|
36
35
|
headers = [title for _field, title in columns]
|
|
37
36
|
widths = [
|
|
38
37
|
max(len(headers[index]), *(len(row[index]) for row in rendered_rows))
|
|
39
38
|
for index in range(len(headers))
|
|
40
39
|
]
|
|
41
|
-
header_line = " ".join(
|
|
42
|
-
header.ljust(widths[index]) for index, header in enumerate(headers)
|
|
43
|
-
)
|
|
40
|
+
header_line = " ".join(header.ljust(widths[index]) for index, header in enumerate(headers))
|
|
44
41
|
print(header_line, file=output)
|
|
45
42
|
print(" ".join("-" * width for width in widths), file=output)
|
|
46
43
|
for row in rendered_rows:
|
|
47
44
|
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)), file=output)
|
|
48
45
|
next_cursor = payload.get("next_cursor")
|
|
49
46
|
if isinstance(next_cursor, str) and next_cursor:
|
|
50
|
-
print(f"Next cursor: {next_cursor}", file=output)
|
|
47
|
+
print(f"Next cursor: {_safe_table_text(next_cursor)}", file=output)
|
|
51
48
|
|
|
52
49
|
|
|
53
50
|
def _table_shape(
|
|
@@ -123,10 +120,7 @@ def _table_shape(
|
|
|
123
120
|
("correlation_id", "CORRELATION"),
|
|
124
121
|
("occurred_at", "OCCURRED_AT"),
|
|
125
122
|
], _response_list(payload, "events")
|
|
126
|
-
if
|
|
127
|
-
args.command == "external-access"
|
|
128
|
-
and args.external_access_command == "list"
|
|
129
|
-
):
|
|
123
|
+
if args.command == "external-access" and args.external_access_command == "list":
|
|
130
124
|
return [
|
|
131
125
|
("id", "ID"),
|
|
132
126
|
("email", "EMAIL"),
|
|
@@ -167,10 +161,22 @@ def _format_table_value(value: object) -> str:
|
|
|
167
161
|
if isinstance(value, bool):
|
|
168
162
|
return "true" if value else "false"
|
|
169
163
|
if isinstance(value, list):
|
|
170
|
-
return ",".join(str(item) for item in value)
|
|
164
|
+
return ",".join(_safe_table_text(str(item)) for item in value)
|
|
171
165
|
if isinstance(value, dict):
|
|
172
166
|
return json.dumps(value, ensure_ascii=False, default=_json_default)
|
|
173
|
-
return str(value)
|
|
167
|
+
return _safe_table_text(str(value))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _safe_table_text(value: str) -> str:
|
|
171
|
+
return "".join(
|
|
172
|
+
f"\\u{ord(char):04x}"
|
|
173
|
+
if ord(char) < 32
|
|
174
|
+
or 127 <= ord(char) <= 159
|
|
175
|
+
or 0x202A <= ord(char) <= 0x202E
|
|
176
|
+
or 0x2066 <= ord(char) <= 0x2069
|
|
177
|
+
else char
|
|
178
|
+
for char in value
|
|
179
|
+
)
|
|
174
180
|
|
|
175
181
|
|
|
176
182
|
def _json_default(value: Any) -> str:
|
|
@@ -5,6 +5,7 @@ import difflib
|
|
|
5
5
|
import re
|
|
6
6
|
from typing import NoReturn
|
|
7
7
|
|
|
8
|
+
from intflow_authctl import __version__
|
|
8
9
|
from intflow_authctl.constants import (
|
|
9
10
|
AUTHCTL_CONFIG_ENV,
|
|
10
11
|
DEFAULT_AUTHCTL_BASE_URL,
|
|
@@ -138,6 +139,9 @@ def _build_authctl_parser() -> argparse.ArgumentParser:
|
|
|
138
139
|
"--config",
|
|
139
140
|
help=f"Credential config path. Defaults to ${AUTHCTL_CONFIG_ENV} or user config.",
|
|
140
141
|
)
|
|
142
|
+
parser.add_argument(
|
|
143
|
+
"--version", "-V", action="version", version=f"intflow-authctl {__version__} (python)"
|
|
144
|
+
)
|
|
141
145
|
parser.add_argument(
|
|
142
146
|
"--credential-store",
|
|
143
147
|
choices=["auto", "file"],
|
|
@@ -153,12 +157,18 @@ def _build_authctl_parser() -> argparse.ArgumentParser:
|
|
|
153
157
|
parser_class=AuthctlArgumentParser,
|
|
154
158
|
)
|
|
155
159
|
|
|
156
|
-
subparsers.add_parser(
|
|
160
|
+
doctor_parser = subparsers.add_parser(
|
|
157
161
|
"doctor",
|
|
158
162
|
help="Check credentials and current roles/scopes without changes.",
|
|
159
163
|
description="Read-only check of configuration, credentials and Admin API identity. "
|
|
160
164
|
"Run login first. This does not grant roles or change credentials.",
|
|
161
165
|
)
|
|
166
|
+
doctor_parser.add_argument(
|
|
167
|
+
"--check-store",
|
|
168
|
+
action="store_true",
|
|
169
|
+
help="Test local storage before login using a temporary nonsecret "
|
|
170
|
+
"write/read/delete probe. No server request.",
|
|
171
|
+
)
|
|
162
172
|
docs_parser = subparsers.add_parser(
|
|
163
173
|
"docs", help="Show onboarding guidance and API documentation links."
|
|
164
174
|
)
|
|
@@ -228,7 +238,8 @@ def _build_authctl_parser() -> argparse.ArgumentParser:
|
|
|
228
238
|
"--poll-interval",
|
|
229
239
|
type=float,
|
|
230
240
|
default=2.0,
|
|
231
|
-
help="Seconds between
|
|
241
|
+
help="Seconds between polling attempts. Defaults to 2; server minimum "
|
|
242
|
+
"and Retry-After take precedence.",
|
|
232
243
|
)
|
|
233
244
|
login_parser.add_argument(
|
|
234
245
|
"--timeout-seconds",
|
|
@@ -471,6 +482,11 @@ def _build_authctl_parser() -> argparse.ArgumentParser:
|
|
|
471
482
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
472
483
|
)
|
|
473
484
|
token_create_parser.add_argument("--name", required=True, help="Operator token name.")
|
|
485
|
+
token_create_parser.add_argument(
|
|
486
|
+
"--secret-file",
|
|
487
|
+
help="Save the one-time token response to a NEW protected file "
|
|
488
|
+
"instead of printing the token.",
|
|
489
|
+
)
|
|
474
490
|
token_create_parser.add_argument(
|
|
475
491
|
"--scope",
|
|
476
492
|
action="append",
|
|
@@ -13,7 +13,10 @@ from intflow_authctl.errors import AuthctlError
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def with_secret_file(
|
|
16
|
-
path: str | None,
|
|
16
|
+
path: str | None,
|
|
17
|
+
operation: Callable[[], dict[str, object]],
|
|
18
|
+
*,
|
|
19
|
+
secret_field: str = "client_secret",
|
|
17
20
|
) -> dict[str, object]:
|
|
18
21
|
if path is None:
|
|
19
22
|
return operation()
|
|
@@ -49,9 +52,9 @@ def with_secret_file(
|
|
|
49
52
|
"Cannot protect --secret-file; no remote mutation was attempted."
|
|
50
53
|
) from None
|
|
51
54
|
result = operation()
|
|
52
|
-
if not isinstance(result.get(
|
|
55
|
+
if not isinstance(result.get(secret_field), str):
|
|
53
56
|
raise AuthctlError(
|
|
54
|
-
"Response omitted
|
|
57
|
+
f"Response omitted {secret_field}; inspect the resource before retrying."
|
|
55
58
|
)
|
|
56
59
|
try:
|
|
57
60
|
data = (json.dumps(result, indent=2) + "\n").encode()
|
|
@@ -60,14 +63,19 @@ def with_secret_file(
|
|
|
60
63
|
output_file.flush()
|
|
61
64
|
os.fsync(output_file.fileno())
|
|
62
65
|
except OSError:
|
|
66
|
+
if secret_field == "token":
|
|
67
|
+
raise AuthctlError(
|
|
68
|
+
"Token was issued but secret storage failed. Inspect tokens list and revoke "
|
|
69
|
+
"the unused token before creating another."
|
|
70
|
+
) from None
|
|
63
71
|
raise AuthctlError(
|
|
64
72
|
"Client was changed but secret storage failed. "
|
|
65
73
|
"Inspect the client and rotate its secret "
|
|
66
74
|
"to a working destination; do not repeat client creation."
|
|
67
75
|
) from None
|
|
68
76
|
completed = True
|
|
69
|
-
return {k: v for k, v in result.items() if k !=
|
|
70
|
-
"
|
|
77
|
+
return {k: v for k, v in result.items() if k != secret_field} | {
|
|
78
|
+
f"{secret_field}_file": str(target)
|
|
71
79
|
}
|
|
72
80
|
finally:
|
|
73
81
|
os.close(fd)
|
|
@@ -7,6 +7,7 @@ from typing import TextIO
|
|
|
7
7
|
from intflow_authctl.api_client import _api_request
|
|
8
8
|
from intflow_authctl.command_utils import _request_params
|
|
9
9
|
from intflow_authctl.errors import AuthctlError
|
|
10
|
+
from intflow_authctl.secret_output import with_secret_file
|
|
10
11
|
from intflow_authctl.validation import _normalize_base_url, _parse_ttl_seconds
|
|
11
12
|
|
|
12
13
|
|
|
@@ -46,12 +47,16 @@ def _run_authctl_tokens_command(
|
|
|
46
47
|
body["ttl_seconds"] = _parse_ttl_seconds(args.ttl)
|
|
47
48
|
elif args.expires_at:
|
|
48
49
|
body["expires_at"] = args.expires_at
|
|
49
|
-
return
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
return with_secret_file(
|
|
51
|
+
args.secret_file,
|
|
52
|
+
lambda: _api_request(
|
|
53
|
+
config,
|
|
54
|
+
"POST",
|
|
55
|
+
"/v1/admin/operator-tokens",
|
|
56
|
+
json_body=body,
|
|
57
|
+
client_factory=client_factory,
|
|
58
|
+
),
|
|
59
|
+
secret_field="token",
|
|
55
60
|
)
|
|
56
61
|
if args.tokens_command == "create-no-expiry":
|
|
57
62
|
whoami = _api_request(
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import ipaddress
|
|
5
|
+
import math
|
|
5
6
|
from urllib.parse import urlsplit
|
|
6
7
|
|
|
7
8
|
from intflow_authctl.errors import AuthctlError
|
|
@@ -24,8 +25,8 @@ class OAuthClientValidationError(ValueError):
|
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def _positive_float(value: float, name: str) -> float:
|
|
27
|
-
if value <= 0:
|
|
28
|
-
raise AuthctlError(f"{name} must be positive.")
|
|
28
|
+
if not math.isfinite(value) or value <= 0:
|
|
29
|
+
raise AuthctlError(f"{name} must be positive and finite.")
|
|
29
30
|
return value
|
|
30
31
|
|
|
31
32
|
|
|
@@ -161,9 +162,7 @@ def _normalize_base_url(value: str) -> str:
|
|
|
161
162
|
if not normalized:
|
|
162
163
|
raise AuthctlError("--base-url must not be empty.")
|
|
163
164
|
if len(normalized) > MAX_ADMIN_BASE_URL_LENGTH:
|
|
164
|
-
raise AuthctlError(
|
|
165
|
-
f"--base-url must be at most {MAX_ADMIN_BASE_URL_LENGTH} characters."
|
|
166
|
-
)
|
|
165
|
+
raise AuthctlError(f"--base-url must be at most {MAX_ADMIN_BASE_URL_LENGTH} characters.")
|
|
167
166
|
if any(character.isspace() for character in normalized) or "\\" in normalized:
|
|
168
167
|
raise AuthctlError("--base-url must not contain whitespace or backslashes.")
|
|
169
168
|
if "?" in normalized or "#" in normalized:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|