Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion tools/paconn-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 17 additions & 2 deletions tools/paconn-cli/paconn/authentication/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)

Expand All @@ -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()
117 changes: 90 additions & 27 deletions tools/paconn-cli/paconn/authentication/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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')}")
25 changes: 20 additions & 5 deletions tools/paconn-cli/paconn/commands/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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.')
5 changes: 5 additions & 0 deletions tools/paconn-cli/paconn/commands/params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 1 addition & 2 deletions tools/paconn-cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down