diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4170360..0a82a8f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,6 +2,12 @@ // README at: https://github.com/devcontainers/templates/tree/main/src/anaconda { "name": "Anaconda (Python 3)", + "runArgs": [ + "--network=host" + ], + + //"forwardPorts": [4201], + "build": { "context": "..", "dockerfile": "Dockerfile" @@ -10,7 +16,9 @@ "customizations": { "vscode": { "extensions": [ - "ms-python.python" + "ms-python.python", + "ms-toolsai.jupyter-keymap", + "ms-python.debugpy" ] } }, diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 0d32bc2..c2447a2 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -10,7 +10,7 @@ pool: vmImage: 'ubuntu-latest' variables: - python.buildVersion: '3.8' + python.buildVersion: '3.9' major: 8 minor: $[counter(variables.major, 0)] @@ -24,8 +24,6 @@ jobs: - job: Test strategy: matrix: - Python38: - python.version: '3.8' Python39: python.version: '3.9' Python310: diff --git a/canopy/__init__.py b/canopy/__init__.py index 83d997f..3b9125c 100644 --- a/canopy/__init__.py +++ b/canopy/__init__.py @@ -24,7 +24,7 @@ from canopy.config_result import ConfigResult from canopy.loaded_channel import LoadedChannel from canopy.local_config import LocalConfig -from canopy.prompt_for_authentication import prompt_for_authentication +from canopy.prompt_for_authentication import prompt_for_authentication, prompt_for_authentication_browser from canopy.process_data_frame import process_data_frame from canopy.try_load_csv_from_url import try_load_csv_from_url diff --git a/canopy/authentication.py b/canopy/authentication.py index 608ce3d..f00f7ff 100644 --- a/canopy/authentication.py +++ b/canopy/authentication.py @@ -1,9 +1,14 @@ +import asyncio +from aiohttp import web, ClientSession +from authlib.integrations.requests_client import OAuth2Session +from secrets import token_urlsafe from typing import Optional import canopy -import getpass import datetime - +import threading +import time +import webbrowser class Authentication(object): _client: canopy.openapi.ApiClient @@ -32,6 +37,84 @@ def authenticate(self): elif datetime.datetime.now() >= self._expires: self.refresh_access_token() + def authenticate_with_browser(self): + if self._identity is None or self._expires is None: + self.sign_in_with_browser() + elif datetime.datetime.now() >= self._expires: + self.refresh_access_token() + + async def sign_in_with_browser(self): + client_id, client_secret = canopy.prompt_for_authentication_browser() + # === Configuration === + DISCOVERY_URL = 'https://identity.canopysimulations.com' + '/.well-known/openid-configuration' + REDIRECT_URI_HOST = 'http://localhost' + SCOPE = "openid profile canopy_api IdentityServerApi offline_access" + + async with ClientSession() as session: + async with session.get(DISCOVERY_URL) as response: + config = await response.json() + + AUTHORIZATION_ENDPOINT = config['authorization_endpoint'] + TOKEN_ENDPOINT = config['token_endpoint'] + USERINFO_ENDPOINT = config['userinfo_endpoint'] + + authorization_response_future = asyncio.Future() + async def callback_handler(request): + authorization_response_future.set_result(request.rel_url) + response = web.Response(text="Login complete. You can close this tab.") + asyncio.create_task(shutdown_server()) + return response + + async def shutdown_server(): + await runner.result().cleanup() + + port = asyncio.Future() + runner = asyncio.Future() + # === aiohttp app runner === + async def run_server(): + app = web.Application() + app.router.add_get('/', callback_handler) + runner.set_result(web.AppRunner(app)) + await runner.result().setup() + site = web.TCPSite(runner.result(), '0.0.0.0') + port.set_result(site._port) + await site.start() + + # === Main OIDC flow with discovery === + asyncio.create_task(run_server()) + + client = OAuth2Session(client_id=client_id, client_secret=client_secret, redirect_uri=REDIRECT_URI_HOST + ':' + str(await port), scope=SCOPE, code_challenge_method='S256') + + code_verifier = token_urlsafe(48) + # Generate auth URL + auth_url, _ = client.create_authorization_url(AUTHORIZATION_ENDPOINT, code_verifier=code_verifier) + + # Open browser + webbrowser.open(auth_url) + + # Exchange code for token + token_result = client.fetch_token( + TOKEN_ENDPOINT, + authorization_response = str(await authorization_response_future), + code_verifier=code_verifier + ) + + userinfo = client.get(USERINFO_ENDPOINT).json() + + #Map results to legacy object + self._identity.expires_in = token_result['expires_in'] + self._identity.access_token = token_result['access_token'] + self._identity.refresh_token = token_result['refresh_token'] + self._identity.expires = token_result['expires_at'] + self._identity.token_type = token_result['token_type'] + self._identity.tenant_id = userinfo['tenant'] + self._identity.user_id = userinfo['sub'] + self._identity.username = userinfo['name'] + self._identity.roles = ', '.join(userinfo['roles']) + + self.__update_from_identity() + + def sign_in(self): authentication_data = canopy.prompt_for_authentication( client_id=self._client_id, diff --git a/canopy/prompt_for_authentication.py b/canopy/prompt_for_authentication.py index 7bd7bce..f6377cf 100644 --- a/canopy/prompt_for_authentication.py +++ b/canopy/prompt_for_authentication.py @@ -30,3 +30,14 @@ def prompt_for_authentication( username=username, tenant_name=tenant_name, password=password) + +def prompt_for_authentication_browser( + client_id: Optional[str] = None, + client_secret: Optional[str] = None) -> tuple[str, str]: + + if client_id is None: + client_id = input('Client ID:') + if client_secret is None: + client_secret = getpass.getpass(prompt='Client Secret:') + + return [client_id, client_secret] diff --git a/requirements.txt b/requirements.txt index 66f822c..526bba0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,8 @@ urllib3 >= 1.15.1 pandas >= 0.25.1 aiohttp munch +Authlib +requests pytest-asyncio ipykernel \ No newline at end of file