intflow-authctl 0.2.1__tar.gz → 0.2.3__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.3/PKG-INFO +53 -0
- intflow_authctl-0.2.3/README.md +32 -0
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/pyproject.toml +1 -1
- intflow_authctl-0.2.3/src/intflow_authctl/__init__.py +1 -0
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/api_client.py +9 -6
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/audit.py +0 -2
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/cli.py +1 -3
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/clients.py +77 -2
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/command_utils.py +1 -7
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/commands.py +64 -20
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/config.py +73 -9
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/constants.py +0 -2
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/credentials.py +7 -13
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/discovery.py +5 -2
- intflow_authctl-0.2.3/src/intflow_authctl/errors.py +9 -0
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/external_access.py +1 -5
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/output.py +43 -14
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/parser.py +136 -8
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/secret_output.py +13 -7
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/tokens.py +11 -8
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/users.py +0 -2
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/validation.py +8 -10
- intflow_authctl-0.2.1/PKG-INFO +0 -116
- intflow_authctl-0.2.1/README.md +0 -95
- intflow_authctl-0.2.1/src/intflow_authctl/__init__.py +0 -3
- intflow_authctl-0.2.1/src/intflow_authctl/errors.py +0 -5
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/.gitignore +0 -0
- {intflow_authctl-0.2.1 → intflow_authctl-0.2.3}/src/intflow_authctl/py.typed +0 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: intflow-authctl
|
|
3
|
+
Version: 0.2.3
|
|
4
|
+
Summary: Intflow Auth operator CLI.
|
|
5
|
+
Author: Intflow
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: httpx<1,>=0.28
|
|
9
|
+
Requires-Dist: keyring<26,>=25
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: build<2,>=1.2; extra == 'dev'
|
|
12
|
+
Requires-Dist: hatchling==1.31.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: mypy<3,>=1.13; extra == 'dev'
|
|
14
|
+
Requires-Dist: packaging<27,>=24; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest-cov<8,>=6; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest-timeout<3,>=2.4; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest<10,>=8; extra == 'dev'
|
|
18
|
+
Requires-Dist: ruff<1,>=0.9; extra == 'dev'
|
|
19
|
+
Requires-Dist: twine<7,>=6; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# intflow-authctl
|
|
23
|
+
|
|
24
|
+
Intflow Auth 운영자용 CLI의 Python 배포판입니다. 권한을 받은 운영자가 사용자, OAuth client, 운영자 토큰, 감사 기록을 관리할 때 씁니다. 서비스 자체나 데이터베이스 migration은 들어 있지 않습니다.
|
|
25
|
+
|
|
26
|
+
## 요구 사항
|
|
27
|
+
|
|
28
|
+
- Python 3.12 이상
|
|
29
|
+
- Intflow Auth `operator` 또는 `admin` 역할
|
|
30
|
+
|
|
31
|
+
## 설치
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
python -m pip install --user --upgrade intflow-authctl
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 사용 예
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
intflow-authctl login
|
|
41
|
+
intflow-authctl whoami
|
|
42
|
+
intflow-authctl clients list
|
|
43
|
+
intflow-authctl clients rotate-secret <oauth-client-id> --secret-file /private/rotation.json
|
|
44
|
+
intflow-authctl logout --revoke
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
로그인한 운영자 신원과 client 목록을 확인합니다. 교체한 client secret은 `--secret-file`로 지정한 보호된 새 파일에만 저장되고, `logout --revoke`는 서버 토큰도 폐기합니다. 운영자 자격증명은 지원되는 OS 자격증명 저장소에 둡니다.
|
|
48
|
+
|
|
49
|
+
## 문서
|
|
50
|
+
|
|
51
|
+
- 명령과 옵션: `intflow-authctl --help`와 `intflow-authctl docs`, 접근 진단은 `intflow-authctl doctor`
|
|
52
|
+
- 상세 문서: 저장소 `yeon3724/intflow-auth`의 `docs/operations.md` (Routine administration, 영어)
|
|
53
|
+
- 저장소: `yeon3724/intflow-auth` (읽기 전용 미러 `intflow/intflow-auth`)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# intflow-authctl
|
|
2
|
+
|
|
3
|
+
Intflow Auth 운영자용 CLI의 Python 배포판입니다. 권한을 받은 운영자가 사용자, OAuth client, 운영자 토큰, 감사 기록을 관리할 때 씁니다. 서비스 자체나 데이터베이스 migration은 들어 있지 않습니다.
|
|
4
|
+
|
|
5
|
+
## 요구 사항
|
|
6
|
+
|
|
7
|
+
- Python 3.12 이상
|
|
8
|
+
- Intflow Auth `operator` 또는 `admin` 역할
|
|
9
|
+
|
|
10
|
+
## 설치
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
python -m pip install --user --upgrade intflow-authctl
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## 사용 예
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
intflow-authctl login
|
|
20
|
+
intflow-authctl whoami
|
|
21
|
+
intflow-authctl clients list
|
|
22
|
+
intflow-authctl clients rotate-secret <oauth-client-id> --secret-file /private/rotation.json
|
|
23
|
+
intflow-authctl logout --revoke
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
로그인한 운영자 신원과 client 목록을 확인합니다. 교체한 client secret은 `--secret-file`로 지정한 보호된 새 파일에만 저장되고, `logout --revoke`는 서버 토큰도 폐기합니다. 운영자 자격증명은 지원되는 OS 자격증명 저장소에 둡니다.
|
|
27
|
+
|
|
28
|
+
## 문서
|
|
29
|
+
|
|
30
|
+
- 명령과 옵션: `intflow-authctl --help`와 `intflow-authctl docs`, 접근 진단은 `intflow-authctl doctor`
|
|
31
|
+
- 상세 문서: 저장소 `yeon3724/intflow-auth`의 `docs/operations.md` (Routine administration, 영어)
|
|
32
|
+
- 저장소: `yeon3724/intflow-auth` (읽기 전용 미러 `intflow/intflow-auth`)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.3"
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
"""HTTP transport helpers for the Intflow Auth operator CLI."""
|
|
2
|
-
|
|
3
1
|
import re
|
|
4
2
|
|
|
5
3
|
import httpx
|
|
6
4
|
|
|
7
5
|
from intflow_authctl.constants import DEFAULT_AUTHCTL_TIMEOUT_SECONDS
|
|
8
6
|
from intflow_authctl.discovery import permission_hint
|
|
9
|
-
from intflow_authctl.errors import AuthctlError
|
|
7
|
+
from intflow_authctl.errors import AuthctlError, AuthctlHttpError
|
|
10
8
|
from intflow_authctl.validation import _normalize_base_url
|
|
11
9
|
|
|
12
10
|
MAX_RETRY_AFTER_SECONDS = 86_400
|
|
@@ -46,6 +44,7 @@ def _api_request_without_auth(
|
|
|
46
44
|
*,
|
|
47
45
|
client_factory,
|
|
48
46
|
json_body: dict[str, object] | None = None,
|
|
47
|
+
timeout_seconds: float = DEFAULT_AUTHCTL_TIMEOUT_SECONDS,
|
|
49
48
|
) -> dict[str, object]:
|
|
50
49
|
return _api_request_to_base_url(
|
|
51
50
|
base_url,
|
|
@@ -55,6 +54,7 @@ def _api_request_without_auth(
|
|
|
55
54
|
json_body=json_body,
|
|
56
55
|
params=None,
|
|
57
56
|
client_factory=client_factory,
|
|
57
|
+
timeout_seconds=timeout_seconds,
|
|
58
58
|
)
|
|
59
59
|
|
|
60
60
|
|
|
@@ -67,13 +67,14 @@ def _api_request_to_base_url(
|
|
|
67
67
|
client_factory,
|
|
68
68
|
json_body: dict[str, object] | None,
|
|
69
69
|
params: dict[str, object] | None,
|
|
70
|
+
timeout_seconds: float = DEFAULT_AUTHCTL_TIMEOUT_SECONDS,
|
|
70
71
|
) -> dict[str, object]:
|
|
71
72
|
base_url = _normalize_base_url(base_url)
|
|
72
73
|
try:
|
|
73
74
|
with client_factory(
|
|
74
75
|
base_url=base_url,
|
|
75
76
|
headers=headers,
|
|
76
|
-
timeout=DEFAULT_AUTHCTL_TIMEOUT_SECONDS,
|
|
77
|
+
timeout=min(timeout_seconds, DEFAULT_AUTHCTL_TIMEOUT_SECONDS),
|
|
77
78
|
) as client:
|
|
78
79
|
request_kwargs: dict[str, object] = {"params": params}
|
|
79
80
|
if json_body is not None:
|
|
@@ -93,10 +94,12 @@ def _api_request_to_base_url(
|
|
|
93
94
|
if response.status_code >= 400:
|
|
94
95
|
retry_after = _retry_after_seconds(response.headers.get("retry-after"))
|
|
95
96
|
retry_message = "" if retry_after is None else f" Retry after {retry_after} seconds."
|
|
96
|
-
raise
|
|
97
|
+
raise AuthctlHttpError(
|
|
97
98
|
f"Admin API request failed: {response.status_code} "
|
|
98
99
|
f"{_safe_error_message(response)}.{retry_message}"
|
|
99
|
-
f"{permission_hint(response.status_code, method, path)}"
|
|
100
|
+
f"{permission_hint(response.status_code, method, path)}",
|
|
101
|
+
response.status_code,
|
|
102
|
+
retry_after,
|
|
100
103
|
)
|
|
101
104
|
if response.status_code == 204 or not response.content:
|
|
102
105
|
return {"status": "ok"}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
"""Operator Admin API command-line entry point."""
|
|
2
|
-
|
|
3
1
|
import sys
|
|
4
2
|
from typing import TextIO
|
|
5
3
|
|
|
@@ -39,7 +37,7 @@ def authctl_main(
|
|
|
39
37
|
client_factory=httpx.Client,
|
|
40
38
|
credential_store: AuthctlCredentialStore | None = None,
|
|
41
39
|
) -> None:
|
|
42
|
-
|
|
40
|
+
|
|
43
41
|
parser = _build_authctl_parser()
|
|
44
42
|
args = parser.parse_args(argv)
|
|
45
43
|
try:
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
"""OAuth client command handlers for authctl."""
|
|
2
|
-
|
|
3
1
|
import argparse
|
|
4
2
|
|
|
5
3
|
from intflow_authctl.api_client import _api_request
|
|
@@ -63,6 +61,8 @@ def _run_authctl_clients_command(
|
|
|
63
61
|
}
|
|
64
62
|
if args.admin_access_required:
|
|
65
63
|
create_body["admin_access_required"] = True
|
|
64
|
+
if args.assignment_required:
|
|
65
|
+
create_body["assignment_required"] = True
|
|
66
66
|
return with_secret_file(
|
|
67
67
|
args.secret_file,
|
|
68
68
|
lambda: _api_request(
|
|
@@ -89,6 +89,10 @@ def _run_authctl_clients_command(
|
|
|
89
89
|
update_body["admin_access_required"] = True
|
|
90
90
|
if args.no_admin_access_required:
|
|
91
91
|
update_body["admin_access_required"] = False
|
|
92
|
+
if args.assignment_required:
|
|
93
|
+
update_body["assignment_required"] = True
|
|
94
|
+
if args.no_assignment_required:
|
|
95
|
+
update_body["assignment_required"] = False
|
|
92
96
|
if not update_body:
|
|
93
97
|
raise AuthctlError("At least one client update field is required.")
|
|
94
98
|
return _api_request(
|
|
@@ -123,6 +127,18 @@ def _run_authctl_clients_command(
|
|
|
123
127
|
config,
|
|
124
128
|
client_factory=client_factory,
|
|
125
129
|
)
|
|
130
|
+
if args.clients_command == "roles":
|
|
131
|
+
return _run_authctl_client_roles_command(
|
|
132
|
+
args,
|
|
133
|
+
config,
|
|
134
|
+
client_factory=client_factory,
|
|
135
|
+
)
|
|
136
|
+
if args.clients_command == "pending-roles":
|
|
137
|
+
return _run_authctl_client_pending_roles_command(
|
|
138
|
+
args,
|
|
139
|
+
config,
|
|
140
|
+
client_factory=client_factory,
|
|
141
|
+
)
|
|
126
142
|
raise AuthctlError(f"Unsupported clients command: {args.clients_command}")
|
|
127
143
|
|
|
128
144
|
|
|
@@ -158,6 +174,65 @@ def _run_authctl_client_admins_command(
|
|
|
158
174
|
raise AuthctlError(f"Unsupported clients admins command: {args.client_admins_command}")
|
|
159
175
|
|
|
160
176
|
|
|
177
|
+
def _run_authctl_client_roles_command(
|
|
178
|
+
args: argparse.Namespace,
|
|
179
|
+
config: dict[str, object],
|
|
180
|
+
*,
|
|
181
|
+
client_factory,
|
|
182
|
+
) -> dict[str, object]:
|
|
183
|
+
base_path = f"/v1/admin/clients/{args.client_id}"
|
|
184
|
+
if args.client_roles_command == "list":
|
|
185
|
+
return _api_request(
|
|
186
|
+
config,
|
|
187
|
+
"GET",
|
|
188
|
+
f"{base_path}/role-assignments",
|
|
189
|
+
params=_request_params(args, "limit", "cursor", extra={"role_name": args.role}),
|
|
190
|
+
client_factory=client_factory,
|
|
191
|
+
)
|
|
192
|
+
if args.client_roles_command in {"grant", "revoke"}:
|
|
193
|
+
return _api_request(
|
|
194
|
+
config,
|
|
195
|
+
"PUT" if args.client_roles_command == "grant" else "DELETE",
|
|
196
|
+
f"{base_path}/roles/{args.role_name}/users/{args.user_id}",
|
|
197
|
+
client_factory=client_factory,
|
|
198
|
+
)
|
|
199
|
+
raise AuthctlError(f"Unsupported clients roles command: {args.client_roles_command}")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _run_authctl_client_pending_roles_command(
|
|
203
|
+
args: argparse.Namespace,
|
|
204
|
+
config: dict[str, object],
|
|
205
|
+
*,
|
|
206
|
+
client_factory,
|
|
207
|
+
) -> dict[str, object]:
|
|
208
|
+
base_path = f"/v1/admin/clients/{args.client_id}/pending-role-grants"
|
|
209
|
+
command = args.client_pending_roles_command
|
|
210
|
+
if command == "list":
|
|
211
|
+
return _api_request(
|
|
212
|
+
config,
|
|
213
|
+
"GET",
|
|
214
|
+
base_path,
|
|
215
|
+
params=_request_params(args, "email", "limit", "cursor"),
|
|
216
|
+
client_factory=client_factory,
|
|
217
|
+
)
|
|
218
|
+
if command == "create":
|
|
219
|
+
return _api_request(
|
|
220
|
+
config,
|
|
221
|
+
"POST",
|
|
222
|
+
base_path,
|
|
223
|
+
json_body={"email": args.email, "role_name": args.role_name},
|
|
224
|
+
client_factory=client_factory,
|
|
225
|
+
)
|
|
226
|
+
if command == "delete":
|
|
227
|
+
return _api_request(
|
|
228
|
+
config,
|
|
229
|
+
"DELETE",
|
|
230
|
+
f"{base_path}/{args.grant_id}",
|
|
231
|
+
client_factory=client_factory,
|
|
232
|
+
)
|
|
233
|
+
raise AuthctlError(f"Unsupported clients pending-roles command: {command}")
|
|
234
|
+
|
|
235
|
+
|
|
161
236
|
def _validate_cli_redirect_uris(values: list[str]) -> tuple[str, ...]:
|
|
162
237
|
try:
|
|
163
238
|
return validate_redirect_uris(values)
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
"""Shared helpers for authctl command-family modules."""
|
|
2
|
-
|
|
3
1
|
import argparse
|
|
4
2
|
|
|
5
3
|
from intflow_authctl.output import _response_list
|
|
@@ -10,11 +8,7 @@ def _request_params(
|
|
|
10
8
|
*names: str,
|
|
11
9
|
extra: dict[str, object] | None = None,
|
|
12
10
|
) -> dict[str, object] | None:
|
|
13
|
-
params = {
|
|
14
|
-
name: value
|
|
15
|
-
for name in names
|
|
16
|
-
if (value := getattr(args, name, None)) is not None
|
|
17
|
-
}
|
|
11
|
+
params = {name: value for name in names if (value := getattr(args, name, None)) is not None}
|
|
18
12
|
if extra is not None:
|
|
19
13
|
params.update({name: value for name, value in extra.items() if value is not None})
|
|
20
14
|
return params or None
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
"""Command dispatch for the Intflow Auth operator CLI."""
|
|
2
|
-
|
|
3
1
|
import argparse
|
|
2
|
+
import math
|
|
4
3
|
import os
|
|
5
4
|
import time
|
|
6
5
|
import webbrowser
|
|
@@ -17,13 +16,14 @@ from intflow_authctl.clients import _run_authctl_clients_command
|
|
|
17
16
|
from intflow_authctl.config import (
|
|
18
17
|
_authctl_config_path,
|
|
19
18
|
_authctl_credential_store,
|
|
19
|
+
_check_credential_storage,
|
|
20
20
|
_delete_authctl_config,
|
|
21
21
|
_load_authctl_config,
|
|
22
22
|
_save_authctl_config,
|
|
23
23
|
)
|
|
24
24
|
from intflow_authctl.credentials import AuthctlCredentialStore
|
|
25
25
|
from intflow_authctl.discovery import documentation
|
|
26
|
-
from intflow_authctl.errors import AuthctlError
|
|
26
|
+
from intflow_authctl.errors import AuthctlError, AuthctlHttpError
|
|
27
27
|
from intflow_authctl.external_access import _run_authctl_external_access_command
|
|
28
28
|
from intflow_authctl.tokens import _run_authctl_tokens_command
|
|
29
29
|
from intflow_authctl.users import _run_authctl_users_command
|
|
@@ -51,6 +51,7 @@ def _run_authctl_command(
|
|
|
51
51
|
webbrowser.open(str(result["api_docs"]))
|
|
52
52
|
return result
|
|
53
53
|
if args.command == "login":
|
|
54
|
+
_check_credential_storage(config_path, selected_credential_store)
|
|
54
55
|
if args.token is not None:
|
|
55
56
|
print(
|
|
56
57
|
"WARNING: --token is deprecated because command arguments may enter "
|
|
@@ -76,9 +77,9 @@ def _run_authctl_command(
|
|
|
76
77
|
)
|
|
77
78
|
|
|
78
79
|
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
80
|
if args.revoke:
|
|
81
|
+
config = _load_authctl_config(config_path, selected_credential_store)
|
|
82
|
+
_warn_if_file_config(config, config_path, error_output)
|
|
82
83
|
token_id = config.get("token_id")
|
|
83
84
|
if not isinstance(token_id, str) or not token_id:
|
|
84
85
|
whoami = _api_request(
|
|
@@ -104,6 +105,17 @@ def _run_authctl_command(
|
|
|
104
105
|
_delete_authctl_config(config_path, selected_credential_store)
|
|
105
106
|
return {"status": "logged_out"}
|
|
106
107
|
|
|
108
|
+
if args.command == "doctor" and args.check_store:
|
|
109
|
+
_check_credential_storage(config_path, selected_credential_store)
|
|
110
|
+
return {
|
|
111
|
+
"status": "ok",
|
|
112
|
+
"checks": ["config_writable", "credential_store_probe"],
|
|
113
|
+
"credential_backend": (
|
|
114
|
+
selected_credential_store.backend_name if selected_credential_store else "file"
|
|
115
|
+
),
|
|
116
|
+
"next": "intflow-authctl login",
|
|
117
|
+
}
|
|
118
|
+
|
|
107
119
|
config = _load_authctl_config(config_path, selected_credential_store)
|
|
108
120
|
_warn_if_file_config(config, config_path, error_output)
|
|
109
121
|
if args.command in {"whoami", "doctor"}:
|
|
@@ -207,6 +219,9 @@ def _run_authctl_browser_login(
|
|
|
207
219
|
) -> dict[str, object]:
|
|
208
220
|
base_url = _normalize_base_url(args.base_url)
|
|
209
221
|
ttl_seconds = _parse_ttl_seconds(args.ttl)
|
|
222
|
+
timeout = _positive_float(args.timeout_seconds, "timeout-seconds")
|
|
223
|
+
if not math.isfinite(args.poll_interval):
|
|
224
|
+
raise AuthctlError("poll-interval must be finite.")
|
|
210
225
|
start_body: dict[str, object] = {
|
|
211
226
|
"name": args.name,
|
|
212
227
|
"ttl_seconds": ttl_seconds,
|
|
@@ -227,28 +242,57 @@ def _run_authctl_browser_login(
|
|
|
227
242
|
if not args.no_browser:
|
|
228
243
|
webbrowser.open(verification_uri)
|
|
229
244
|
|
|
230
|
-
deadline = time.monotonic() +
|
|
231
|
-
|
|
245
|
+
deadline = time.monotonic() + timeout
|
|
246
|
+
advertised = transaction.get("poll_interval_seconds")
|
|
247
|
+
server_interval = (
|
|
248
|
+
float(advertised)
|
|
249
|
+
if isinstance(advertised, (float, int)) and math.isfinite(advertised) and advertised > 0
|
|
250
|
+
else 2.0
|
|
251
|
+
)
|
|
252
|
+
poll_interval = max(args.poll_interval, server_interval, 1.0)
|
|
232
253
|
while True:
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
"
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
254
|
+
remaining = deadline - time.monotonic()
|
|
255
|
+
if remaining <= 0:
|
|
256
|
+
raise AuthctlError("CLI login timed out before browser approval completed.")
|
|
257
|
+
try:
|
|
258
|
+
response = _api_request_without_auth(
|
|
259
|
+
base_url,
|
|
260
|
+
"POST",
|
|
261
|
+
f"/v1/cli-login-transactions/{transaction_id}:redeem",
|
|
262
|
+
json_body={"poll_secret": poll_secret},
|
|
263
|
+
client_factory=client_factory,
|
|
264
|
+
timeout_seconds=remaining,
|
|
265
|
+
)
|
|
266
|
+
except AuthctlHttpError as exc:
|
|
267
|
+
if exc.status != 429:
|
|
268
|
+
raise
|
|
269
|
+
delay = max(poll_interval, exc.retry_after or server_interval)
|
|
270
|
+
print(
|
|
271
|
+
"Login polling was rate limited; waiting within the login deadline.",
|
|
272
|
+
file=error_output,
|
|
273
|
+
)
|
|
274
|
+
time.sleep(min(delay, max(deadline - time.monotonic(), 0.0)))
|
|
275
|
+
continue
|
|
240
276
|
if response.get("status") != "pending":
|
|
241
277
|
token = _required_response_string(response, "token")
|
|
242
278
|
operator_token = response.get("operator_token")
|
|
243
279
|
config: dict[str, object] = {"base_url": base_url, "token": token}
|
|
244
280
|
if isinstance(operator_token, dict) and operator_token.get("id"):
|
|
245
281
|
config["token_id"] = str(operator_token["id"])
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
282
|
+
try:
|
|
283
|
+
_save_authctl_config(
|
|
284
|
+
config_path,
|
|
285
|
+
config,
|
|
286
|
+
token=token,
|
|
287
|
+
credential_store=credential_store,
|
|
288
|
+
)
|
|
289
|
+
except (AuthctlError, OSError):
|
|
290
|
+
raise AuthctlError(
|
|
291
|
+
"Login token was issued but local storage failed. Run doctor --check-store, "
|
|
292
|
+
"then log in again; inspect tokens list and explicitly revoke "
|
|
293
|
+
"the unused token. "
|
|
294
|
+
"No token was printed or automatically revoked."
|
|
295
|
+
) from None
|
|
252
296
|
return {
|
|
253
297
|
"status": "logged_in",
|
|
254
298
|
"base_url": base_url,
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
"""Credential configuration helpers for the Intflow Auth operator CLI."""
|
|
2
|
-
|
|
3
1
|
import argparse
|
|
4
2
|
import json
|
|
5
3
|
import os
|
|
@@ -72,9 +70,7 @@ def _load_authctl_config(
|
|
|
72
70
|
if not isinstance(service, str) or not isinstance(account, str):
|
|
73
71
|
raise AuthctlError(f"Invalid CLI config file: {path}")
|
|
74
72
|
if credential_store is None:
|
|
75
|
-
raise AuthctlError(
|
|
76
|
-
"CLI config uses an OS credential store; use --credential-store auto."
|
|
77
|
-
)
|
|
73
|
+
raise AuthctlError("CLI config uses an OS credential store; use --credential-store auto.")
|
|
78
74
|
store = credential_store
|
|
79
75
|
if backend != store.backend_name:
|
|
80
76
|
raise AuthctlError(f"Invalid CLI config file: {path}")
|
|
@@ -95,9 +91,11 @@ def _save_authctl_config(
|
|
|
95
91
|
token: str,
|
|
96
92
|
credential_store: AuthctlCredentialStore | None,
|
|
97
93
|
) -> None:
|
|
94
|
+
previous = _load_raw_authctl_config(path)
|
|
95
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
96
|
stored_config = dict(config)
|
|
99
97
|
stored_config.pop("token", None)
|
|
100
|
-
credential_account = _credential_account(stored_config)
|
|
98
|
+
credential_account = f"{_credential_account(stored_config)}|local-{secrets.token_hex(16)}"
|
|
101
99
|
secret_stored = False
|
|
102
100
|
if credential_store is not None:
|
|
103
101
|
try:
|
|
@@ -112,6 +110,7 @@ def _save_authctl_config(
|
|
|
112
110
|
"credential_backend": credential_store.backend_name,
|
|
113
111
|
"credential_service": AUTHCTL_CREDENTIAL_SERVICE,
|
|
114
112
|
"credential_account": credential_account,
|
|
113
|
+
"credential_owner": _config_owner(path),
|
|
115
114
|
}
|
|
116
115
|
)
|
|
117
116
|
except AuthctlCredentialStoreError as exc:
|
|
@@ -122,7 +121,6 @@ def _save_authctl_config(
|
|
|
122
121
|
else:
|
|
123
122
|
stored_config.update({"credential_backend": "file", "token": token})
|
|
124
123
|
|
|
125
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
126
124
|
temporary_path = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
|
127
125
|
replaced = False
|
|
128
126
|
try:
|
|
@@ -150,7 +148,72 @@ def _save_authctl_config(
|
|
|
150
148
|
if secret_stored and credential_store is not None:
|
|
151
149
|
with suppress(AuthctlCredentialStoreError):
|
|
152
150
|
credential_store.delete_password(AUTHCTL_CREDENTIAL_SERVICE, credential_account)
|
|
153
|
-
raise
|
|
151
|
+
raise AuthctlError(
|
|
152
|
+
"Could not save CLI configuration; the previous login was retained where possible."
|
|
153
|
+
) from None
|
|
154
|
+
if (
|
|
155
|
+
previous
|
|
156
|
+
and previous.get("credential_account")
|
|
157
|
+
and previous.get("credential_backend") != "file"
|
|
158
|
+
):
|
|
159
|
+
if (
|
|
160
|
+
credential_store is not None
|
|
161
|
+
and previous.get("credential_backend") == credential_store.backend_name
|
|
162
|
+
and previous.get("credential_owner") == _config_owner(path)
|
|
163
|
+
and previous.get("credential_service") == AUTHCTL_CREDENTIAL_SERVICE
|
|
164
|
+
):
|
|
165
|
+
try:
|
|
166
|
+
credential_store.delete_password(
|
|
167
|
+
AUTHCTL_CREDENTIAL_SERVICE, str(previous["credential_account"])
|
|
168
|
+
)
|
|
169
|
+
except AuthctlCredentialStoreError:
|
|
170
|
+
_storage_warning(
|
|
171
|
+
"Login saved, but the previous local credential could not be removed."
|
|
172
|
+
)
|
|
173
|
+
else:
|
|
174
|
+
_storage_warning(
|
|
175
|
+
"Previous legacy/shared credential retained to protect other profiles."
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _storage_warning(message: str) -> None:
|
|
180
|
+
|
|
181
|
+
import sys
|
|
182
|
+
|
|
183
|
+
print(f"WARNING: {message} Remote tokens were not revoked.", file=sys.stderr)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _config_owner(path: Path) -> str:
|
|
187
|
+
return os.path.normcase(os.path.abspath(path))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _check_credential_storage(path: Path, store: AuthctlCredentialStore | None) -> None:
|
|
191
|
+
probe = f"probe-{secrets.token_hex(16)}"
|
|
192
|
+
temporary = path.parent / f".authctl-{probe}"
|
|
193
|
+
try:
|
|
194
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
if path.exists() and not path.is_file():
|
|
196
|
+
raise OSError("Not a file")
|
|
197
|
+
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
198
|
+
with os.fdopen(descriptor, "w") as probe_file:
|
|
199
|
+
probe_file.write(probe)
|
|
200
|
+
probe_file.flush()
|
|
201
|
+
os.fsync(probe_file.fileno())
|
|
202
|
+
if store is not None:
|
|
203
|
+
try:
|
|
204
|
+
store.set_password(AUTHCTL_CREDENTIAL_SERVICE, probe, probe)
|
|
205
|
+
if store.get_password(AUTHCTL_CREDENTIAL_SERVICE, probe) != probe:
|
|
206
|
+
raise AuthctlCredentialStoreError("Probe failed")
|
|
207
|
+
finally:
|
|
208
|
+
store.delete_password(AUTHCTL_CREDENTIAL_SERVICE, probe)
|
|
209
|
+
except (OSError, AuthctlCredentialStoreError):
|
|
210
|
+
raise AuthctlError(
|
|
211
|
+
"Credential storage is unavailable; no login request was sent. Check config directory "
|
|
212
|
+
"and OS keyring, or explicitly select --credential-store file if acceptable."
|
|
213
|
+
) from None
|
|
214
|
+
finally:
|
|
215
|
+
with suppress(OSError):
|
|
216
|
+
temporary.unlink(missing_ok=True)
|
|
154
217
|
|
|
155
218
|
|
|
156
219
|
def _delete_authctl_config(
|
|
@@ -168,7 +231,8 @@ def _delete_authctl_config(
|
|
|
168
231
|
)
|
|
169
232
|
store = credential_store
|
|
170
233
|
try:
|
|
171
|
-
store.
|
|
234
|
+
if store.get_password(service, account) is not None:
|
|
235
|
+
store.delete_password(service, account)
|
|
172
236
|
except AuthctlCredentialStoreError as exc:
|
|
173
237
|
raise AuthctlError(
|
|
174
238
|
"Stored CLI credential could not be deleted; local config was retained."
|
|
@@ -1,49 +1,43 @@
|
|
|
1
|
-
"""Credential-store adapters for the intflow-authctl CLI."""
|
|
2
|
-
|
|
3
1
|
from importlib import import_module
|
|
4
2
|
from typing import Protocol
|
|
5
3
|
|
|
6
4
|
|
|
7
5
|
class AuthctlCredentialStore(Protocol):
|
|
8
|
-
"""Minimal credential-store interface used by intflow-authctl."""
|
|
9
|
-
|
|
10
6
|
backend_name: str
|
|
11
7
|
|
|
12
8
|
def get_password(self, service: str, account: str) -> str | None:
|
|
13
|
-
|
|
9
|
+
pass
|
|
14
10
|
|
|
15
11
|
def set_password(self, service: str, account: str, password: str) -> None:
|
|
16
|
-
|
|
12
|
+
pass
|
|
17
13
|
|
|
18
14
|
def delete_password(self, service: str, account: str) -> None:
|
|
19
|
-
|
|
15
|
+
pass
|
|
20
16
|
|
|
21
17
|
|
|
22
18
|
class AuthctlCredentialStoreError(RuntimeError):
|
|
23
|
-
|
|
19
|
+
pass
|
|
24
20
|
|
|
25
21
|
|
|
26
22
|
class KeyringAuthctlCredentialStore:
|
|
27
|
-
"""Credential-store adapter backed by the optional keyring package."""
|
|
28
|
-
|
|
29
23
|
backend_name = "keyring"
|
|
30
24
|
|
|
31
25
|
def get_password(self, service: str, account: str) -> str | None:
|
|
32
|
-
|
|
26
|
+
|
|
33
27
|
try:
|
|
34
28
|
return self._keyring().get_password(service, account)
|
|
35
29
|
except Exception as exc:
|
|
36
30
|
raise AuthctlCredentialStoreError("Credential store lookup failed.") from exc
|
|
37
31
|
|
|
38
32
|
def set_password(self, service: str, account: str, password: str) -> None:
|
|
39
|
-
|
|
33
|
+
|
|
40
34
|
try:
|
|
41
35
|
self._keyring().set_password(service, account, password)
|
|
42
36
|
except Exception as exc:
|
|
43
37
|
raise AuthctlCredentialStoreError("Credential store write failed.") from exc
|
|
44
38
|
|
|
45
39
|
def delete_password(self, service: str, account: str) -> None:
|
|
46
|
-
|
|
40
|
+
|
|
47
41
|
try:
|
|
48
42
|
self._keyring().delete_password(service, account)
|
|
49
43
|
except Exception as exc:
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
def _is_app_role_path(path: str) -> bool:
|
|
2
|
+
return any(
|
|
3
|
+
part in path for part in ("/admins", "/roles/", "/role-assignments", "/pending-role-grants")
|
|
4
|
+
)
|
|
2
5
|
|
|
3
6
|
|
|
4
7
|
def permission_hint(status: int, method: str, path: str) -> str:
|
|
@@ -8,7 +11,7 @@ def permission_hint(status: int, method: str, path: str) -> str:
|
|
|
8
11
|
return ""
|
|
9
12
|
resource = None
|
|
10
13
|
if path.startswith("/v1/admin/clients"):
|
|
11
|
-
resource = "app_roles" if
|
|
14
|
+
resource = "app_roles" if _is_app_role_path(path) else "clients"
|
|
12
15
|
elif path.startswith("/v1/admin/operator-tokens"):
|
|
13
16
|
resource = "tokens"
|
|
14
17
|
elif path.startswith("/v1/admin/audit"):
|