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
2615from __future__ import annotations
3524import webbrowser
3625from pathlib import Path
3726
38- import qrcode
39- import qrcode .image .svg
40-
4127from evnex .auth import AuthChallenge , EvnexAuth , TokenSet
4228from 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+
6969async 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
10495def 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- "\n Then run: uv run examples/manage_mfa.py confirm-totp CODE"
140- " [--device-name NAME]"
141- )
141+ print ("\n Then run: evnex auth confirm-totp CODE [--device-name NAME]" )
142142
143143
144144async 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
221266if __name__ == "__main__" :
0 commit comments