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.
Files changed (25) hide show
  1. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/PKG-INFO +21 -1
  2. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/README.md +20 -0
  3. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/pyproject.toml +1 -1
  4. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/__init__.py +1 -1
  5. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/api_client.py +9 -4
  6. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/commands.py +65 -18
  7. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/config.py +73 -7
  8. intflow_authctl-0.2.2/src/intflow_authctl/errors.py +14 -0
  9. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/output.py +18 -12
  10. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/parser.py +18 -2
  11. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/secret_output.py +13 -5
  12. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/tokens.py +11 -6
  13. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/validation.py +4 -5
  14. intflow_authctl-0.2.1/src/intflow_authctl/errors.py +0 -5
  15. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/.gitignore +0 -0
  16. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/audit.py +0 -0
  17. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/cli.py +0 -0
  18. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/clients.py +0 -0
  19. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/command_utils.py +0 -0
  20. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/constants.py +0 -0
  21. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/credentials.py +0 -0
  22. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/discovery.py +0 -0
  23. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/external_access.py +0 -0
  24. {intflow_authctl-0.2.1 → intflow_authctl-0.2.2}/src/intflow_authctl/py.typed +0 -0
  25. {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.1
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "intflow-authctl"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "Intflow Auth operator CLI."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -1,3 +1,3 @@
1
1
  """Internal implementation package for the Intflow Auth operator CLI."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.2"
@@ -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 AuthctlError(
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() + _positive_float(args.timeout_seconds, "timeout-seconds")
231
- poll_interval = max(args.poll_interval, 0.0)
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
- response = _api_request_without_auth(
234
- base_url,
235
- "POST",
236
- f"/v1/cli-login-transactions/{transaction_id}:redeem",
237
- json_body={"poll_secret": poll_secret},
238
- client_factory=client_factory,
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
- _save_authctl_config(
247
- config_path,
248
- config,
249
- token=token,
250
- credential_store=credential_store,
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.delete_password(service, account)
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 browser-approval polling attempts. Defaults to 2.",
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, operation: Callable[[], dict[str, object]]
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("client_secret"), str):
55
+ if not isinstance(result.get(secret_field), str):
53
56
  raise AuthctlError(
54
- "Response omitted client_secret; inspect the client before retrying."
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 != "client_secret"} | {
70
- "client_secret_file": str(target)
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 _api_request(
50
- config,
51
- "POST",
52
- "/v1/admin/operator-tokens",
53
- json_body=body,
54
- client_factory=client_factory,
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:
@@ -1,5 +0,0 @@
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."""