Skip to content

Commit de55b90

Browse files
committed
Promote the MFA script to a packaged CLI: uvx evnex auth
- evnex.cli exposes an 'evnex' console script (evnex auth status/signin/enroll-totp/confirm-totp/disable), so 'uvx evnex auth' works with nothing installed - qrcode becomes an optional extra; without it the CLI still prints the otpauth:// URI and secret, with a hint to run 'uvx --with qrcode evnex' - README documents the CLI including answering sign-in challenges from a password manager via --code-command (1Password CLI v2 example) - Replace a real account UUID in test fixtures with a synthetic one
1 parent 9ea06ea commit de55b90

7 files changed

Lines changed: 199 additions & 69 deletions

File tree

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,35 @@ await auth.set_mfa_preference() # disable MFA entirely
134134
Completing a new TOTP enrollment replaces the previously registered
135135
authenticator device.
136136

137+
## Command line
138+
139+
Everything above is also available as a CLI, runnable directly with
140+
[uv](https://docs.astral.sh/uv/):
141+
142+
```shell
143+
export EVNEX_CLIENT_USERNAME=you@example.com
144+
export EVNEX_CLIENT_PASSWORD=<your password>
145+
146+
uvx evnex auth status # which MFA methods are enabled
147+
uvx evnex auth enroll-totp # start enrolling a (new) TOTP device
148+
uvx evnex auth confirm-totp 123456 --device-name "My phone"
149+
uvx evnex auth disable # turn MFA off entirely
150+
```
151+
152+
`enroll-totp` prints an `otpauth://` URI you can paste straight into a
153+
password manager's one-time password field. For a scannable QR code (in the
154+
terminal, or the browser with `--browser`), include the optional qrcode
155+
dependency: `uvx --with qrcode evnex auth enroll-totp`.
156+
157+
Session tokens are cached (mode 0600, `~/.cache/evnex/tokens.json` by
158+
default) so an MFA sign-in is only needed occasionally. To answer sign-in
159+
challenges from a password manager instead of typing codes — for example
160+
with the [1Password CLI](https://developer.1password.com/docs/cli/) v2+:
161+
162+
```shell
163+
uvx evnex auth --code-command 'op item get Evnex --otp' signin
164+
```
165+
137166
## Examples
138167

139168
`python-evnex` is intended as a library, but a few example scripts are provided in the `examples` folder.
Lines changed: 112 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,15 @@
1-
#!/usr/bin/env -S uv run --script
2-
# /// script
3-
# requires-python = ">=3.11"
4-
# dependencies = [
5-
# "evnex",
6-
# "qrcode>=8.0",
7-
# ]
8-
#
9-
# [tool.uv.sources]
10-
# evnex = { path = "..", editable = true }
11-
# ///
12-
"""Manage MFA on your EVNEX account: view status, enroll or replace a TOTP
13-
device, or disable MFA — none of which the EVNEX app currently exposes.
14-
15-
Usage (credentials via EVNEX_CLIENT_USERNAME / EVNEX_CLIENT_PASSWORD):
16-
17-
uv run examples/manage_mfa.py status
18-
uv run examples/manage_mfa.py enroll-totp [--browser]
19-
uv run examples/manage_mfa.py confirm-totp CODE [--device-name NAME]
20-
uv run examples/manage_mfa.py disable --yes
21-
22-
Session tokens are cached (0600) so MFA sign-in is only needed once; pass
23-
--code to answer a sign-in challenge non-interactively.
1+
"""Command line interface for the EVNEX Cloud API client.
2+
3+
Run without installing anything via uv:
4+
5+
uvx evnex auth status
6+
uvx evnex auth enroll-totp
7+
uvx evnex auth confirm-totp CODE --device-name NAME
8+
uvx evnex auth disable
9+
10+
Credentials come from EVNEX_CLIENT_USERNAME / EVNEX_CLIENT_PASSWORD (or are
11+
prompted for). Session tokens are cached with 0600 permissions so an MFA
12+
sign-in is only needed occasionally, not per command.
2413
"""
2514

2615
from __future__ import annotations
@@ -35,9 +24,6 @@
3524
import webbrowser
3625
from pathlib import Path
3726

38-
import qrcode
39-
import qrcode.image.svg
40-
4127
from evnex.auth import AuthChallenge, EvnexAuth, TokenSet
4228
from evnex.errors import EvnexAuthError, ReauthenticationRequiredError
4329

@@ -66,6 +52,20 @@ def _load_tokens(cache: Path) -> TokenSet | None:
6652
return None
6753

6854

55+
async def _challenge_code(args: argparse.Namespace, challenge: AuthChallenge) -> str:
56+
if args.code:
57+
code, args.code = args.code, None # a code is single-use
58+
return str(code)
59+
if args.code_command:
60+
proc = await asyncio.create_subprocess_shell(
61+
args.code_command, stdout=asyncio.subprocess.PIPE
62+
)
63+
stdout, _ = await proc.communicate()
64+
print("Code obtained from --code-command", file=sys.stderr)
65+
return stdout.decode().strip()
66+
return input(f"Enter the 6-digit code ({challenge.name}): ")
67+
68+
6969
async def signed_in_auth(args: argparse.Namespace) -> EvnexAuth:
7070
"""Return an EvnexAuth with a usable session, signing in if needed."""
7171
cache: Path = args.token_cache
@@ -85,23 +85,26 @@ async def signed_in_auth(args: argparse.Namespace) -> EvnexAuth:
8585
)
8686
result = await auth.start_authentication(username, password)
8787
while isinstance(result, AuthChallenge):
88-
if args.code:
89-
code, args.code = args.code, None # a code is single-use
90-
elif args.code_command:
91-
proc = await asyncio.create_subprocess_shell(
92-
args.code_command, stdout=asyncio.subprocess.PIPE
93-
)
94-
stdout, _ = await proc.communicate()
95-
code = stdout.decode().strip()
96-
print("Code obtained from --code-command", file=sys.stderr)
97-
else:
98-
code = input(f"Enter the 6-digit code ({result.name}): ")
99-
result = await auth.respond_to_challenge(result, code)
88+
result = await auth.respond_to_challenge(
89+
result, await _challenge_code(args, result)
90+
)
10091
print(f"Signed in as {username}; session cached at {cache}", file=sys.stderr)
10192
return auth
10293

10394

10495
def show_qr(uri: str, open_browser: bool) -> None:
96+
"""Render the enrollment QR in the terminal, and optionally a browser."""
97+
try:
98+
import qrcode
99+
import qrcode.image.svg
100+
except ImportError:
101+
print(
102+
"(for a scannable QR code, run with the qrcode package:"
103+
" uvx --with qrcode evnex ...)",
104+
file=sys.stderr,
105+
)
106+
return
107+
105108
qr = qrcode.QRCode(border=2)
106109
qr.add_data(uri)
107110
qr.print_ascii(tty=sys.stdout.isatty())
@@ -130,15 +133,12 @@ async def cmd_enroll_totp(args: argparse.Namespace) -> None:
130133
account = os.environ.get("EVNEX_CLIENT_USERNAME", "evnex-account")
131134
uri = enrollment.provisioning_uri(account)
132135

133-
print("Scan this QR code with your authenticator app, or paste the")
136+
print("Scan the QR code with your authenticator app, or paste the")
134137
print("otpauth URI into a password manager's one-time password field:\n")
135138
print(f" {uri}\n")
136139
print(f"(bare secret for manual entry: {enrollment.secret})\n")
137140
show_qr(uri, open_browser=args.browser)
138-
print(
139-
"\nThen run: uv run examples/manage_mfa.py confirm-totp CODE"
140-
" [--device-name NAME]"
141-
)
141+
print("\nThen run: evnex auth confirm-totp CODE [--device-name NAME]")
142142

143143

144144
async def cmd_confirm_totp(args: argparse.Namespace) -> None:
@@ -166,31 +166,64 @@ async def cmd_signin(args: argparse.Namespace) -> None:
166166
await signed_in_auth(args)
167167

168168

169-
def main() -> None:
170-
parser = argparse.ArgumentParser(description=__doc__)
171-
parser.add_argument(
169+
def build_parser() -> argparse.ArgumentParser:
170+
parser = argparse.ArgumentParser(
171+
prog="evnex",
172+
description="Command line interface for the EVNEX Cloud API.",
173+
)
174+
sub = parser.add_subparsers(dest="command", required=True)
175+
176+
auth = sub.add_parser(
177+
"auth",
178+
help="manage authentication and MFA for your EVNEX account",
179+
description=(
180+
"Sign in and manage MFA for your EVNEX account. Credentials come "
181+
"from EVNEX_CLIENT_USERNAME / EVNEX_CLIENT_PASSWORD or prompts; "
182+
"session tokens are cached so an MFA code is only needed once."
183+
),
184+
)
185+
auth.add_argument(
172186
"--token-cache",
173187
type=Path,
174188
default=DEFAULT_CACHE,
175189
help=f"where to cache session tokens (default: {DEFAULT_CACHE})",
176190
)
177-
parser.add_argument("--code", help="6-digit code to answer a sign-in MFA challenge")
178-
parser.add_argument(
191+
auth.add_argument(
192+
"--code",
193+
help="6-digit code to answer a sign-in MFA challenge non-interactively",
194+
)
195+
auth.add_argument(
179196
"--code-command",
180-
help="shell command that prints a current MFA code, e.g. "
181-
"'op item get Evnex --otp' for 1Password",
197+
help="shell command printing a current MFA code, e.g. "
198+
"'op item get Evnex --otp' with the 1Password CLI",
199+
)
200+
auth_sub = auth.add_subparsers(dest="auth_command", required=True)
201+
202+
auth_sub.add_parser("status", help="show which MFA methods are enabled")
203+
auth_sub.add_parser("signin", help="sign in and cache session tokens")
204+
205+
enroll = auth_sub.add_parser(
206+
"enroll-totp",
207+
help="enroll a new TOTP authenticator device",
208+
description=(
209+
"Start enrolling a TOTP device: prints the otpauth:// URI (paste "
210+
"into a password manager's one-time password field) and a QR code. "
211+
"Completing enrollment with confirm-totp replaces any previously "
212+
"registered device."
213+
),
182214
)
183-
sub = parser.add_subparsers(dest="command", required=True)
184-
185-
sub.add_parser("status", help="show which MFA methods are enabled")
186-
sub.add_parser("signin", help="sign in and cache session tokens")
187-
188-
enroll = sub.add_parser("enroll-totp", help="enroll a (new) TOTP device")
189215
enroll.add_argument(
190216
"--browser", action="store_true", help="also open the QR code in a browser"
191217
)
192218

193-
confirm = sub.add_parser("confirm-totp", help="confirm the new TOTP device")
219+
confirm = auth_sub.add_parser(
220+
"confirm-totp",
221+
help="verify the new TOTP device and enable it",
222+
description=(
223+
"Verify a code generated by the newly enrolled device. By default "
224+
"this also makes TOTP the preferred MFA method."
225+
),
226+
)
194227
confirm.add_argument("totp_code", help="6-digit code from the new device")
195228
confirm.add_argument("--device-name", default="", help="friendly device name")
196229
confirm.add_argument(
@@ -200,22 +233,34 @@ def main() -> None:
200233
help="register the device without changing the MFA preference",
201234
)
202235

203-
disable = sub.add_parser("disable", help="turn MFA off for the account")
236+
disable = auth_sub.add_parser(
237+
"disable",
238+
help="turn MFA off for the account",
239+
description="Disable all MFA methods for the account.",
240+
)
204241
disable.add_argument("--yes", action="store_true", help="skip confirmation")
205242

206-
args = parser.parse_args()
207-
handler = {
208-
"status": cmd_status,
209-
"signin": cmd_signin,
210-
"enroll-totp": cmd_enroll_totp,
211-
"confirm-totp": cmd_confirm_totp,
212-
"disable": cmd_disable,
213-
}[args.command]
243+
return parser
244+
245+
246+
HANDLERS = {
247+
"status": cmd_status,
248+
"signin": cmd_signin,
249+
"enroll-totp": cmd_enroll_totp,
250+
"confirm-totp": cmd_confirm_totp,
251+
"disable": cmd_disable,
252+
}
253+
254+
255+
def main() -> None:
256+
args = build_parser().parse_args()
214257
try:
215-
asyncio.run(handler(args))
258+
asyncio.run(HANDLERS[args.auth_command](args))
216259
except EvnexAuthError as err:
217260
print(f"Authentication error: {err}", file=sys.stderr)
218261
sys.exit(1)
262+
except KeyboardInterrupt:
263+
sys.exit(130)
219264

220265

221266
if __name__ == "__main__":

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ dependencies = [
1919
"pydantic-settings>=2.2,<3.0",
2020
]
2121

22+
[project.optional-dependencies]
23+
cli = ["qrcode>=8.0"]
24+
25+
[project.scripts]
26+
evnex = "evnex.cli:main"
27+
2228
[dependency-groups]
2329
dev = [
2430
"ruff>=0.12.10",

tests/test_auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636

3737
USER_PAYLOAD = {
3838
"data": {
39-
"id": "b102b5e3-2b00-4f6b-9b0c-b579c609f969",
39+
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
4040
"createdDate": "2022-01-01T00:00:00Z",
4141
"updatedDate": "2022-01-01T00:00:00Z",
4242
"name": "Test User",

tests/test_cli.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Smoke tests for the CLI parser wiring."""
2+
3+
import pytest
4+
5+
from evnex.cli import HANDLERS, build_parser
6+
7+
8+
def test_all_auth_commands_parse_and_have_handlers():
9+
parser = build_parser()
10+
for command, argv in [
11+
("status", ["auth", "status"]),
12+
("signin", ["auth", "signin"]),
13+
("enroll-totp", ["auth", "enroll-totp", "--browser"]),
14+
("confirm-totp", ["auth", "confirm-totp", "123456", "--device-name", "x"]),
15+
("disable", ["auth", "disable", "--yes"]),
16+
]:
17+
args = parser.parse_args(argv)
18+
assert args.auth_command == command
19+
assert command in HANDLERS
20+
21+
22+
def test_code_command_option_parses():
23+
args = build_parser().parse_args(
24+
["auth", "--code-command", "op item get Evnex --otp", "status"]
25+
)
26+
assert args.code_command == "op item get Evnex --otp"
27+
28+
29+
def test_missing_subcommand_errors():
30+
with pytest.raises(SystemExit):
31+
build_parser().parse_args(["auth"])

tests/test_schema.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
def test_user_without_name_validates():
99
payload = {
1010
"data": {
11-
"id": "b102b5e3-2b00-4f6b-9b0c-b579c609f969",
11+
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
1212
"createdDate": "2022-01-01T00:00:00Z",
1313
"updatedDate": "2022-01-01T00:00:00Z",
1414
"email": "user@example.com",

uv.lock

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)