From 0081ae6b57764e88903eabfb2dc3dae1fc1240bb Mon Sep 17 00:00:00 2001 From: "steven.granados" Date: Thu, 22 May 2025 13:11:51 +0000 Subject: [PATCH 1/6] Add browser authentication --- .devcontainer/devcontainer.json | 10 +++- canopy/__init__.py | 2 +- canopy/authentication.py | 87 ++++++++++++++++++++++++++++- canopy/prompt_for_authentication.py | 11 ++++ 4 files changed, 106 insertions(+), 4 deletions(-) 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/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..e87c835 100644 --- a/canopy/authentication.py +++ b/canopy/authentication.py @@ -1,9 +1,14 @@ +from authlib.integrations.requests_client import OAuth2Session +from http.server import HTTPServer, BaseHTTPRequestHandler +from secrets import token_urlsafe from typing import Optional import canopy -import getpass import datetime - +import requests +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() + + 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" + + config = requests.get(DISCOVERY_URL, verify=False).json() + AUTHORIZATION_ENDPOINT = config['authorization_endpoint'] + TOKEN_ENDPOINT = config['token_endpoint'] + USERINFO_ENDPOINT = config['userinfo_endpoint'] + + # === Local server to capture callback === + class CallbackHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.server.path = self.path + self.send_response(200) + self.end_headers() + self.wfile.write(b'Login complete. You can close this window.') + + authorization_response = None + port = None + def start_callback_server(): + server = HTTPServer(('0.0.0.0', 0), CallbackHandler) + global port + port = server.server_port + server.handle_request() + global authorization_response + authorization_response = server.path + + # === Main OIDC flow with discovery === + server_thread = threading.Thread(target=start_callback_server, daemon=True) + server_thread.start() + + while(port == None): + time.sleep(0.1) + + client = OAuth2Session(client_id=client_id, client_secret=client_secret, redirect_uri=REDIRECT_URI_HOST + ':' + str(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) + + server_thread.join() + + # Exchange code for token + token_result = client.fetch_token( + TOKEN_ENDPOINT, + authorization_response=authorization_response, + code_verifier=code_verifier, + verify=False, + ) + + 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] From acf4c3a680798b8c19ee7a171b656545cacf6733 Mon Sep 17 00:00:00 2001 From: "steven.granados" Date: Thu, 22 May 2025 13:11:51 +0000 Subject: [PATCH 2/6] Add authlib dependency --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 66f822c..ed4e1ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ urllib3 >= 1.15.1 pandas >= 0.25.1 aiohttp munch +authlib pytest-asyncio ipykernel \ No newline at end of file From 69788b488242093fbbee9e2c6f72e22d543c36e4 Mon Sep 17 00:00:00 2001 From: "steven.granados" Date: Thu, 22 May 2025 13:11:51 +0000 Subject: [PATCH 3/6] Use existing http library --- canopy/authentication.py | 64 ++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/canopy/authentication.py b/canopy/authentication.py index e87c835..f00f7ff 100644 --- a/canopy/authentication.py +++ b/canopy/authentication.py @@ -1,11 +1,11 @@ +import asyncio +from aiohttp import web, ClientSession from authlib.integrations.requests_client import OAuth2Session -from http.server import HTTPServer, BaseHTTPRequestHandler from secrets import token_urlsafe from typing import Optional import canopy import datetime -import requests import threading import time import webbrowser @@ -43,44 +43,47 @@ def authenticate_with_browser(self): elif datetime.datetime.now() >= self._expires: self.refresh_access_token() - def sign_in_with_browser(self): + 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" - config = requests.get(DISCOVERY_URL, verify=False).json() + 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'] - # === Local server to capture callback === - class CallbackHandler(BaseHTTPRequestHandler): - def do_GET(self): - self.server.path = self.path - self.send_response(200) - self.end_headers() - self.wfile.write(b'Login complete. You can close this window.') - - authorization_response = None - port = None - def start_callback_server(): - server = HTTPServer(('0.0.0.0', 0), CallbackHandler) - global port - port = server.server_port - server.handle_request() - global authorization_response - authorization_response = server.path + 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 === - server_thread = threading.Thread(target=start_callback_server, daemon=True) - server_thread.start() + asyncio.create_task(run_server()) - while(port == None): - time.sleep(0.1) - - client = OAuth2Session(client_id=client_id, client_secret=client_secret, redirect_uri=REDIRECT_URI_HOST + ':' + str(port), scope=SCOPE, code_challenge_method='S256') + 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 @@ -89,14 +92,11 @@ def start_callback_server(): # Open browser webbrowser.open(auth_url) - server_thread.join() - # Exchange code for token token_result = client.fetch_token( TOKEN_ENDPOINT, - authorization_response=authorization_response, - code_verifier=code_verifier, - verify=False, + authorization_response = str(await authorization_response_future), + code_verifier=code_verifier ) userinfo = client.get(USERINFO_ENDPOINT).json() From 372f9cd4e460ef630dffc832accab3a2626ea0b8 Mon Sep 17 00:00:00 2001 From: "steven.granados" Date: Thu, 22 May 2025 13:11:51 +0000 Subject: [PATCH 4/6] Rename Authlib --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ed4e1ea..074729e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ urllib3 >= 1.15.1 pandas >= 0.25.1 aiohttp munch -authlib +Authlib pytest-asyncio ipykernel \ No newline at end of file From 5d9c9e454efe0cb1c07feb4e28998992c81e9457 Mon Sep 17 00:00:00 2001 From: "steven.granados" Date: Thu, 22 May 2025 13:11:51 +0000 Subject: [PATCH 5/6] Add requests --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 074729e..526bba0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ pandas >= 0.25.1 aiohttp munch Authlib +requests pytest-asyncio ipykernel \ No newline at end of file From 6e384d951497aac1c8c3bd91818a61d638e9d999 Mon Sep 17 00:00:00 2001 From: "steven.granados" Date: Thu, 22 May 2025 13:12:19 +0000 Subject: [PATCH 6/6] Update to python 3.9 --- azure-pipelines.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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: