diff --git a/tools/paconn-cli/README.md b/tools/paconn-cli/README.md index 4ebc39707f..47ec77e5b9 100644 --- a/tools/paconn-cli/README.md +++ b/tools/paconn-cli/README.md @@ -141,7 +141,15 @@ Log in to Power Platform by running: `paconn login` -This command will ask you to log in using the device code login process. Follow the prompt for the log in. Service Principle authentication is not supported at this point. Please review [a customer workaround posted in the issues page](https://github.com/microsoft/PowerPlatformConnectors/issues/287). +This command will ask you to log in using the device code login process. Follow the prompt for the log in. + +For interactive browser authentication, use: + +`paconn login --interactive` + +This will open your default browser and prompt you to log in interactively instead of using device code flow. + +Service Principle authentication is not supported at this point. Please review [a customer workaround posted in the issues page](https://github.com/microsoft/PowerPlatformConnectors/issues/287). ### Logout diff --git a/tools/paconn-cli/paconn/authentication/auth.py b/tools/paconn-cli/paconn/authentication/auth.py index 7b4254fc16..a732ce9fe6 100644 --- a/tools/paconn-cli/paconn/authentication/auth.py +++ b/tools/paconn-cli/paconn/authentication/auth.py @@ -14,9 +14,14 @@ from paconn.authentication.tokenmanager import TokenManager -def get_authentication(settings, force_authenticate): +def get_authentication(settings, force_authenticate, interactive=False): """ Logs the user in and saves the token in a file. + + Args: + settings: Configuration settings containing authentication parameters + force_authenticate: Force re-authentication even if token exists + interactive: Use interactive browser authentication instead of device code """ tokenmanager = TokenManager() credentials = tokenmanager.read() @@ -31,7 +36,10 @@ def get_authentication(settings, force_authenticate): resource=settings.resource, authority_url=settings.authority_url) - credentials = profile.authenticate_device_code() + if interactive: + credentials = profile.authenticate_interactive() + else: + credentials = profile.authenticate_device_code() tokenmanager.write(credentials) @@ -42,6 +50,13 @@ def get_authentication(settings, force_authenticate): raise CLIError('Couldn\'t get authentication') +def get_authentication_interactive(settings, force_authenticate=False): + """ + Logs the user in using interactive authentication and saves the token in a file. + """ + return get_authentication(settings, force_authenticate, interactive=True) + + def remove_authentication(): tokenmanager = TokenManager() tokenmanager.delete_token_file() diff --git a/tools/paconn-cli/paconn/authentication/profile.py b/tools/paconn-cli/paconn/authentication/profile.py index 44f2faa2be..25fdba90fd 100644 --- a/tools/paconn-cli/paconn/authentication/profile.py +++ b/tools/paconn-cli/paconn/authentication/profile.py @@ -7,10 +7,9 @@ """ User profile management class.` """ -import adal -from urllib.parse import urljoin -# AADTokenCredentials for multi-factor authentication -from msrestazure.azure_active_directory import AADTokenCredentials +import json +import msal +import time class Profile: @@ -24,32 +23,96 @@ def __init__(self, client_id, tenant, resource, authority_url): self.resource = resource self.authority_url = authority_url - def _get_authentication_context(self): - auth_url = urljoin(self.authority_url, self.tenant) - - return adal.AuthenticationContext( - authority=auth_url, - api_version=None) + def _get_msal_app(self): + """ + Creates and returns an MSAL PublicClientApplication instance. + """ + authority = f"{self.authority_url.rstrip('/')}/{self.tenant}" + + return msal.PublicClientApplication( + client_id=self.client_id, + authority=authority + ) def authenticate_device_code(self): """ Authenticate the end-user using device auth. """ - context = self._get_authentication_context() - - code = context.acquire_user_code( - resource=self.resource, - client_id=self.client_id) - - print(code['message']) + app = self._get_msal_app() + + # Start device flow + flow = app.initiate_device_flow(scopes=[f"{self.resource}/.default"]) + + if "user_code" not in flow: + raise ValueError( + "Fail to create device flow. Error: %s" % json.dumps(flow, indent=2)) + + print(flow["message"]) + + # Block until the user has entered the device code + result = app.acquire_token_by_device_flow(flow) + + if "access_token" in result: + # Convert MSAL result to the expected format + token_data = { + 'access_token': result['access_token'], + 'token_type': result.get('token_type', 'Bearer'), + 'expires_on': int(time.time()) + result.get('expires_in', 3600), + 'resource': self.resource + } + + if 'id_token_claims' in result and 'oid' in result['id_token_claims']: + token_data['oid'] = result['id_token_claims']['oid'] + + return token_data + else: + raise Exception(f"Failed to acquire token: {result.get('error_description', 'Unknown error')}") - mgmt_token = context.acquire_token_with_device_code( - resource=self.resource, - user_code_info=code, - client_id=self.client_id) - - credentials = AADTokenCredentials( - token=mgmt_token, - client_id=self.client_id) - - return credentials.token + def authenticate_interactive(self): + """ + Authenticate the end-user using interactive authentication (browser). + """ + app = self._get_msal_app() + + # Try to get token silently first + accounts = app.get_accounts() + if accounts: + result = app.acquire_token_silent( + scopes=[f"{self.resource}/.default"], + account=accounts[0] + ) + if result and "access_token" in result: + # Convert MSAL result to the expected format + token_data = { + 'access_token': result['access_token'], + 'token_type': result.get('token_type', 'Bearer'), + 'expires_on': int(time.time()) + result.get('expires_in', 3600), + 'resource': self.resource + } + + if 'id_token_claims' in result and 'oid' in result['id_token_claims']: + token_data['oid'] = result['id_token_claims']['oid'] + + return token_data + + # If silent acquisition fails, perform interactive authentication + result = app.acquire_token_interactive( + scopes=[f"{self.resource}/.default"], + prompt="select_account" # Force account selection + ) + + if "access_token" in result: + # Convert MSAL result to the expected format + token_data = { + 'access_token': result['access_token'], + 'token_type': result.get('token_type', 'Bearer'), + 'expires_on': int(time.time()) + result.get('expires_in', 3600), + 'resource': self.resource + } + + if 'id_token_claims' in result and 'oid' in result['id_token_claims']: + token_data['oid'] = result['id_token_claims']['oid'] + + return token_data + else: + raise Exception(f"Failed to acquire token: {result.get('error_description', 'Unknown error')}") diff --git a/tools/paconn-cli/paconn/commands/login.py b/tools/paconn-cli/paconn/commands/login.py index bfc3940b68..f957bb9304 100644 --- a/tools/paconn-cli/paconn/commands/login.py +++ b/tools/paconn-cli/paconn/commands/login.py @@ -7,14 +7,23 @@ Login command. """ -from paconn.authentication.auth import get_authentication +from paconn.authentication.auth import get_authentication, get_authentication_interactive from paconn.common.util import display from paconn.settings.settingsbuilder import SettingsBuilder -def login(client_id, tenant, authority_url, resource, settings_file, force): +def login(client_id, tenant, authority_url, resource, settings_file, force, interactive=False): """ Login command. + + Args: + client_id: OAuth2 client ID + tenant: Azure AD tenant + authority_url: Authentication authority URL + resource: Resource URL for the token + settings_file: Path to settings file + force: Force re-authentication + interactive: Use interactive browser authentication instead of device code """ # Get settings settings = SettingsBuilder.get_authentication_settings( @@ -24,7 +33,13 @@ def login(client_id, tenant, authority_url, resource, settings_file, force): authority_url=authority_url, resource=resource) - get_authentication( - settings=settings, - force_authenticate=force) + if interactive: + get_authentication_interactive( + settings=settings, + force_authenticate=force) + else: + get_authentication( + settings=settings, + force_authenticate=force) + display('Login successful.') diff --git a/tools/paconn-cli/paconn/commands/params.py b/tools/paconn-cli/paconn/commands/params.py index e93df34d46..b282022cd5 100644 --- a/tools/paconn-cli/paconn/commands/params.py +++ b/tools/paconn-cli/paconn/commands/params.py @@ -105,6 +105,11 @@ def load_arguments(self, command): default=False, const=True, help='Override a previous login, if exists.') + arg_context.argument( + 'interactive', + options_list=['--interactive', '-int'], + action='store_true', + help='Use interactive browser authentication instead of device code.') with ArgumentsContext(self, _DOWNLOAD) as arg_context: arg_context.argument( diff --git a/tools/paconn-cli/setup.py b/tools/paconn-cli/setup.py index 289f51a277..8577903836 100644 --- a/tools/paconn-cli/setup.py +++ b/tools/paconn-cli/setup.py @@ -57,8 +57,7 @@ def read(fname): 'pytest-xdist', 'virtualenv', 'requests', - 'adal', - 'msrestazure', + 'msal>=1.20.0', 'azure-storage-blob>=2.1,<12.0' ], extras_require={