Skip to content

Add browser authentication #10

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
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 .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -10,7 +16,9 @@
"customizations": {
"vscode": {
"extensions": [
"ms-python.python"
"ms-python.python",
"ms-toolsai.jupyter-keymap",
"ms-python.debugpy"
]
}
},
Expand Down
4 changes: 1 addition & 3 deletions azure-pipelines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ pool:
vmImage: 'ubuntu-latest'

variables:
python.buildVersion: '3.8'
python.buildVersion: '3.9'

major: 8
minor: $[counter(variables.major, 0)]
Expand All @@ -24,8 +24,6 @@ jobs:
- job: Test
strategy:
matrix:
Python38:
python.version: '3.8'
Python39:
python.version: '3.9'
Python310:
Expand Down
2 changes: 1 addition & 1 deletion canopy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
87 changes: 85 additions & 2 deletions canopy/authentication.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions canopy/prompt_for_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ urllib3 >= 1.15.1
pandas >= 0.25.1
aiohttp
munch
Authlib
requests

pytest-asyncio
ipykernel