Skip to content

Commit fb068dc

Browse files
committed
feat: add --use-device-code flag to auth login
Add device code authentication flow for environments where browser access is not available. Users can now authenticate by entering a code at https://microsoft.com/devicelogin from any device with a browser. Usage: fab auth login --use-device-code fab auth login --use-device-code --tenant <tenant_id> The option is also available in the interactive authentication menu as "Device code". Implementation: - Add --use-device-code argument to the auth login parser - Add _acquire_token_by_device_code() using MSAL's initiate_device_flow and acquire_token_by_device_flow APIs - Add device_code_flow_failed() error message - Wrap device code flow in try/finally to ensure flag cleanup on failure - Use silent refresh for subsequent scopes after initial device code auth Assisted-by: GitHub Copilot:claude-opus-4.6
1 parent ce79563 commit fb068dc

7 files changed

Lines changed: 751 additions & 16 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: added
2+
body: Add ``--use-device-code`` flag to ``auth login`` for device code authentication flow
3+
time: 2026-06-30T22:34:39.210039642+02:00
4+
custom:
5+
Author: iemejia
6+
AuthorLink: https://github.com/iemejia

src/fabric_cli/commands/auth/fab_auth.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
def init(args: Namespace) -> Any:
1717
auth_options = [
1818
"Interactive with a web browser",
19+
"Device code",
1920
"Service principal authentication with secret",
2021
"Service principal authentication with certificate",
2122
"Service principal authentication with federated credential",
@@ -27,7 +28,15 @@ def init(args: Namespace) -> Any:
2728
# Clean up stale context files when logging in
2829
Context().cleanup_context_files(cleanup_all_stale=True, cleanup_current=False)
2930

30-
if args.identity:
31+
if args.use_device_code:
32+
FabAuth().set_access_mode("user", args.tenant)
33+
with FabAuth().device_code_flow():
34+
FabAuth().get_access_token(scope=fab_constant.SCOPE_FABRIC_DEFAULT)
35+
FabAuth().get_access_token(scope=fab_constant.SCOPE_ONELAKE_DEFAULT)
36+
FabAuth().get_access_token(scope=fab_constant.SCOPE_AZURE_DEFAULT)
37+
Context().context = FabAuth().get_tenant()
38+
39+
elif args.identity:
3140
FabAuth().set_access_mode("managed_identity")
3241
FabAuth().set_managed_identity(args.username)
3342
FabAuth().get_access_token(scope=fab_constant.SCOPE_FABRIC_DEFAULT)
@@ -73,6 +82,13 @@ def init(args: Namespace) -> Any:
7382
FabAuth().get_access_token(scope=fab_constant.SCOPE_ONELAKE_DEFAULT)
7483
FabAuth().get_access_token(scope=fab_constant.SCOPE_AZURE_DEFAULT)
7584
Context().context = FabAuth().get_tenant()
85+
elif selected_auth == "Device code":
86+
FabAuth().set_access_mode("user", args.tenant)
87+
with FabAuth().device_code_flow():
88+
FabAuth().get_access_token(scope=fab_constant.SCOPE_FABRIC_DEFAULT)
89+
FabAuth().get_access_token(scope=fab_constant.SCOPE_ONELAKE_DEFAULT)
90+
FabAuth().get_access_token(scope=fab_constant.SCOPE_AZURE_DEFAULT)
91+
Context().context = FabAuth().get_tenant()
7692
elif selected_auth.startswith("Service principal authentication"):
7793
fab_logger.log_warning(
7894
"Ensure tenant setting is enabled for Service Principal auth"

src/fabric_cli/core/fab_auth.py

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import os
66
import uuid
77
from binascii import hexlify
8+
from contextlib import contextmanager
89
from typing import Any, NamedTuple, Optional
910

1011
import jwt
@@ -50,11 +51,30 @@ def __init__(self):
5051
# Reset the auth info
5152
self.app: msal.ClientApplication = None
5253
self._auth_info = {}
54+
self._use_device_code = False
5355

5456
# Load the auth info and environment variables
5557
self._load_auth()
5658
self._load_env()
5759

60+
@contextmanager
61+
def device_code_flow(self):
62+
"""Context manager to enable device code authentication flow.
63+
64+
Temporarily enables device code flow for token acquisition within
65+
the context block. Automatically resets the flag on exit, even if
66+
an exception occurs.
67+
68+
Usage:
69+
with FabAuth().device_code_flow():
70+
FabAuth().get_access_token(scope=...)
71+
"""
72+
self._use_device_code = True
73+
try:
74+
yield
75+
finally:
76+
self._use_device_code = False
77+
5878
def _save_auth(self):
5979
from fabric_cli.utils.fab_secure_io import write_restricted_file
6080

@@ -270,6 +290,51 @@ def _get_app(self) -> msal.ClientApplication:
270290
)
271291
return self.app
272292

293+
def _acquire_token_by_device_code(self, scope: list[str]) -> Optional[dict]:
294+
"""
295+
Acquire a token using the device code flow.
296+
297+
This initiates a device code flow, prints the user code and verification
298+
URL to the console, and waits for the user to complete authentication
299+
on another device. If an account already exists in the cache (from a
300+
prior device code flow in the same session), attempts a silent refresh
301+
first to avoid prompting the user multiple times for different scopes.
302+
303+
Args:
304+
scope: The scopes for which to request the token
305+
306+
Returns:
307+
Full MSAL result dictionary containing access_token, expires_on, etc.,
308+
or None if the device flow returns no result. Raises:
309+
FabricCLIError: When device code flow initiation or token acquisition fails
310+
"""
311+
# If we already have an account from a prior device code auth in this
312+
# session, try silent acquisition with force_refresh to use the refresh
313+
# token for a different resource scope.
314+
accounts = self._get_app().get_accounts()
315+
if accounts:
316+
token = self._get_app().acquire_token_silent(
317+
scopes=scope, account=accounts[0], force_refresh=True
318+
)
319+
if token and token.get("access_token"):
320+
return token
321+
322+
flow = self._get_app().initiate_device_flow(scopes=scope)
323+
if "user_code" not in flow:
324+
raise FabricCLIError(
325+
ErrorMessages.Auth.device_code_flow_failed(
326+
flow.get("error_description", "Unknown error")
327+
),
328+
con.ERROR_AUTHENTICATION_FAILED,
329+
)
330+
331+
# Display the device code message to the user
332+
utils_ui.print_grey(flow["message"])
333+
334+
# Wait for the user to authenticate
335+
token = self._get_app().acquire_token_by_device_flow(flow)
336+
return token
337+
273338
def _get_access_token_from_env_vars_if_exist(self, scope):
274339
if "FAB_TOKEN" in os.environ and "FAB_TOKEN_ONELAKE" in os.environ:
275340
match scope:
@@ -494,14 +559,19 @@ def acquire_token(self, scope: list[str], interactive_renew=True) -> dict:
494559
scopes=scope, account=account
495560
)
496561

497-
if token is None and interactive_renew:
498-
token = self._get_app().acquire_token_interactive(
499-
scopes=scope,
500-
prompt="select_account",
501-
parent_window_handle=msal.PublicClientApplication.CONSOLE_WINDOW_HANDLE,
502-
)
562+
if (token is None or token.get("error")) and interactive_renew:
563+
if self._use_device_code:
564+
token = self._acquire_token_by_device_code(scope)
565+
else:
566+
token = self._get_app().acquire_token_interactive(
567+
scopes=scope,
568+
prompt="select_account",
569+
parent_window_handle=msal.PublicClientApplication.CONSOLE_WINDOW_HANDLE,
570+
)
503571
if token is not None and "id_token_claims" in token:
504-
self.set_tenant(token.get("id_token_claims")["tid"])
572+
id_token_claims = token.get("id_token_claims")
573+
if id_token_claims:
574+
self.set_tenant(id_token_claims["tid"])
505575

506576
if token and token.get("error"):
507577
fab_logger.log_debug(

src/fabric_cli/errors/auth.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,7 @@ def cert_read_failed(error: str) -> str:
119119
@staticmethod
120120
def only_supported_with_user_authentication() -> str:
121121
return "This operation is only supported with user authentication"
122+
123+
@staticmethod
124+
def device_code_flow_failed(error_description: str) -> str:
125+
return f"Failed to initiate device code flow: {error_description}"

src/fabric_cli/parsers/fab_auth_parser.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ def register_parser(subparsers: _SubParsersAction) -> None:
3030
"$ auth login\n",
3131
"# command_line mode",
3232
"$ fab auth login\n",
33+
"# command_line mode using device code auth",
34+
"$ fab auth login --use-device-code\n",
3335
"# command_line mode using service principal auth",
3436
"$ fab auth login -u <client_id> -p <client_secret> --tenant <tenant_id>\n",
3537
"# command_line mode using system assigned managed identity auth",
@@ -84,9 +86,15 @@ def register_parser(subparsers: _SubParsersAction) -> None:
8486
required=False,
8587
help="Federated token that can be used for OIDC token exchange. Optional, only for service principal auth",
8688
)
89+
login_parser.add_argument(
90+
"--use-device-code",
91+
required=False,
92+
action="store_true",
93+
help="Use device code authentication flow. Useful when browser access is not available.",
94+
)
8795

8896
login_parser.usage = f"{utils_error_parser.get_usage_prog(login_parser)}"
89-
login_parser.set_defaults(func=lazy_command(_auth_module_path, 'init'))
97+
login_parser.set_defaults(func=lazy_command(_auth_module_path, "init"))
9098

9199
# Subcommand for 'logout'
92100
logout_examples = [
@@ -104,7 +112,7 @@ def register_parser(subparsers: _SubParsersAction) -> None:
104112
)
105113

106114
logout_parser.usage = f"{utils_error_parser.get_usage_prog(logout_parser)}"
107-
logout_parser.set_defaults(func=lazy_command(_auth_module_path, 'logout'))
115+
logout_parser.set_defaults(func=lazy_command(_auth_module_path, "logout"))
108116

109117
# Subcommand for 'status'
110118
status_examples = [
@@ -121,7 +129,7 @@ def register_parser(subparsers: _SubParsersAction) -> None:
121129
)
122130

123131
status_parser.usage = f"{utils_error_parser.get_usage_prog(status_parser)}"
124-
status_parser.set_defaults(func=lazy_command(_auth_module_path, 'status'))
132+
status_parser.set_defaults(func=lazy_command(_auth_module_path, "status"))
125133

126134

127135
def show_help(args: Namespace) -> None:

0 commit comments

Comments
 (0)