Skip to content

Commit 5d6a1d2

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 5d6a1d2

7 files changed

Lines changed: 749 additions & 14 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: 75 additions & 6 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,52 @@ def _get_app(self) -> msal.ClientApplication:
270290
)
271291
return self.app
272292

293+
def _acquire_token_by_device_code(self, scope: list[str]) -> 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+
309+
Raises:
310+
FabricCLIError: When device code flow initiation or token acquisition fails
311+
"""
312+
# If we already have an account from a prior device code auth in this
313+
# session, try silent acquisition with force_refresh to use the refresh
314+
# token for a different resource scope.
315+
accounts = self._get_app().get_accounts()
316+
if accounts:
317+
token = self._get_app().acquire_token_silent(
318+
scopes=scope, account=accounts[0], force_refresh=True
319+
)
320+
if token and token.get("access_token"):
321+
return token
322+
323+
flow = self._get_app().initiate_device_flow(scopes=scope)
324+
if "user_code" not in flow:
325+
raise FabricCLIError(
326+
ErrorMessages.Auth.device_code_flow_failed(
327+
flow.get("error_description", "Unknown error")
328+
),
329+
con.ERROR_AUTHENTICATION_FAILED,
330+
)
331+
332+
# Display the device code message to the user
333+
utils_ui.print_grey(flow["message"])
334+
335+
# Wait for the user to authenticate
336+
token = self._get_app().acquire_token_by_device_flow(flow)
337+
return token
338+
273339
def _get_access_token_from_env_vars_if_exist(self, scope):
274340
if "FAB_TOKEN" in os.environ and "FAB_TOKEN_ONELAKE" in os.environ:
275341
match scope:
@@ -494,12 +560,15 @@ def acquire_token(self, scope: list[str], interactive_renew=True) -> dict:
494560
scopes=scope, account=account
495561
)
496562

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-
)
563+
if (token is None or token.get("error")) and interactive_renew:
564+
if self._use_device_code:
565+
token = self._acquire_token_by_device_code(scope)
566+
else:
567+
token = self._get_app().acquire_token_interactive(
568+
scopes=scope,
569+
prompt="select_account",
570+
parent_window_handle=msal.PublicClientApplication.CONSOLE_WINDOW_HANDLE,
571+
)
503572
if token is not None and "id_token_claims" in token:
504573
self.set_tenant(token.get("id_token_claims")["tid"])
505574

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:

tests/test_commands/test_auth.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,155 @@ def test_init_with_tenant_and_cert_args_auth(
811811
name="Unknown", id="mocked_tenant_id"
812812
)
813813

814+
def test_init_with_device_code_auth_command_line(
815+
self, mock_fab_auth, mock_fab_context
816+
):
817+
# Arrange
818+
args = prepare_auth_args({"use_device_code": True})
819+
820+
# Act
821+
result = fab_auth.init(args)
822+
823+
# Assert
824+
mock_fab_auth_instance = mock_fab_auth.get("instance")
825+
mock_fab_auth_instance.set_access_mode.assert_called_with("user", None)
826+
assert_get_access_token(mock_fab_auth_instance)
827+
assert result is True
828+
829+
assert_fab_context(mock_fab_context)
830+
831+
def test_init_with_device_code_auth_command_line_with_tenant(
832+
self, mock_fab_auth, mock_fab_context
833+
):
834+
# Arrange
835+
args = prepare_auth_args({"use_device_code": True, "tenant": "mock_tenant"})
836+
837+
# Act
838+
result = fab_auth.init(args)
839+
840+
# Assert
841+
mock_fab_auth_instance = mock_fab_auth.get("instance")
842+
mock_fab_auth_instance.set_access_mode.assert_called_with("user", "mock_tenant")
843+
assert_get_access_token(mock_fab_auth_instance)
844+
assert result is True
845+
846+
assert_fab_context(mock_fab_context)
847+
848+
def test_init_with_device_code_auth_interactive(
849+
self, mock_fab_auth, mock_fab_context
850+
):
851+
# Arrange
852+
with patch(
853+
"fabric_cli.utils.fab_ui.prompt_select_item",
854+
return_value="Device code",
855+
):
856+
args = prepare_auth_args()
857+
858+
# Act
859+
result = fab_auth.init(args)
860+
861+
# Assert
862+
mock_fab_auth_instance = mock_fab_auth.get("instance")
863+
mock_fab_auth_instance.set_access_mode.assert_called_with("user", None)
864+
assert_get_access_token(mock_fab_auth_instance)
865+
assert result is True
866+
867+
assert_fab_context(mock_fab_context)
868+
869+
def test_init_with_device_code_takes_precedence_over_identity(
870+
self, mock_fab_auth, mock_fab_context
871+
):
872+
"""When both --use-device-code and --identity are passed, device code wins."""
873+
# Arrange
874+
args = prepare_auth_args({"use_device_code": True, "identity": True})
875+
876+
# Act
877+
result = fab_auth.init(args)
878+
879+
# Assert: device code path was taken (set_access_mode("user")), not managed identity
880+
mock_fab_auth_instance = mock_fab_auth.get("instance")
881+
mock_fab_auth_instance.set_access_mode.assert_called_with("user", None)
882+
mock_fab_auth_instance.set_managed_identity.assert_not_called()
883+
assert_get_access_token(mock_fab_auth_instance)
884+
assert result is True
885+
886+
assert_fab_context(mock_fab_context)
887+
888+
def test_init_with_device_code_takes_precedence_over_spn_args(
889+
self, mock_fab_auth, mock_fab_context
890+
):
891+
"""When --use-device-code is passed alongside -u/-p, device code wins."""
892+
# Arrange
893+
args = prepare_auth_args(
894+
{
895+
"use_device_code": True,
896+
"username": "some_client_id",
897+
"password": "some_secret",
898+
"tenant": "some_tenant",
899+
}
900+
)
901+
902+
# Act
903+
result = fab_auth.init(args)
904+
905+
# Assert: device code path was taken, not SPN
906+
mock_fab_auth_instance = mock_fab_auth.get("instance")
907+
mock_fab_auth_instance.set_access_mode.assert_called_with("user", "some_tenant")
908+
mock_fab_auth_instance.set_spn.assert_not_called()
909+
assert_get_access_token(mock_fab_auth_instance)
910+
assert result is True
911+
912+
assert_fab_context(mock_fab_context)
913+
914+
def test_init_with_device_code_resets_flag_on_success(
915+
self, mock_fab_auth, mock_fab_context
916+
):
917+
"""Verify _use_device_code flag is reset to False after successful login."""
918+
# Arrange
919+
args = prepare_auth_args({"use_device_code": True})
920+
921+
# Act
922+
fab_auth.init(args)
923+
924+
# Assert: flag should be reset
925+
mock_fab_auth_instance = mock_fab_auth.get("instance")
926+
assert mock_fab_auth_instance._use_device_code is False
927+
928+
def test_init_with_device_code_resets_flag_on_exception(self, mock_fab_auth):
929+
"""Verify _use_device_code flag is reset even when get_access_token raises."""
930+
# Arrange
931+
mock_fab_auth_instance = mock_fab_auth.get("instance")
932+
mock_fab_auth_instance.get_access_token.side_effect = FabricCLIError(
933+
"Token acquisition failed",
934+
fab_constant.ERROR_AUTHENTICATION_FAILED,
935+
)
936+
args = prepare_auth_args({"use_device_code": True})
937+
938+
# Act
939+
with pytest.raises(FabricCLIError):
940+
fab_auth.init(args)
941+
942+
# Assert: flag should be reset thanks to try/finally
943+
assert mock_fab_auth_instance._use_device_code is False
944+
945+
def test_init_with_device_code_interactive_resets_flag_on_success(
946+
self, mock_fab_auth, mock_fab_context
947+
):
948+
"""Verify _use_device_code flag is reset after interactive device code login."""
949+
# Arrange
950+
with patch(
951+
"fabric_cli.utils.fab_ui.prompt_select_item",
952+
return_value="Device code",
953+
):
954+
args = prepare_auth_args()
955+
956+
# Act
957+
fab_auth.init(args)
958+
959+
# Assert: flag should be reset
960+
mock_fab_auth_instance = mock_fab_auth.get("instance")
961+
assert mock_fab_auth_instance._use_device_code is False
962+
814963
def test_init_with_args_auth_missing_arg_raise_exception(self):
815964
# Test without tenant
816965
# Arrange
@@ -821,6 +970,7 @@ def test_init_with_args_auth_missing_arg_raise_exception(self):
821970
identity=None,
822971
certificate=None,
823972
federated_token=None,
973+
use_device_code=None,
824974
)
825975

826976
# Act
@@ -837,6 +987,7 @@ def test_init_with_args_auth_missing_arg_raise_exception(self):
837987
identity=None,
838988
certificate=None,
839989
federated_token=None,
990+
use_device_code=None,
840991
)
841992

842993
# Act
@@ -853,6 +1004,7 @@ def test_init_with_args_auth_missing_arg_raise_exception(self):
8531004
identity=None,
8541005
certificate=None,
8551006
federated_token=None,
1007+
use_device_code=None,
8561008
)
8571009

8581010
# Act
@@ -971,6 +1123,7 @@ def prepare_auth_args(args=None):
9711123
"identity",
9721124
"certificate",
9731125
"federated_token",
1126+
"use_device_code",
9741127
]
9751128
}
9761129
)

0 commit comments

Comments
 (0)