diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index b2412f6..b347335 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -2,7 +2,7 @@ name: JupyterHealth SMART on FHIR test suite on: push: - branches: [ main ] + branches: [main] pull_request: jobs: @@ -13,33 +13,31 @@ jobs: python-version: [3.12] # extend if needed steps: - - uses: actions/checkout@v4 - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: '18.x' - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: "pip" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e .[testing] - - name: Install SMART sandbox - run: | - git clone https://github.com/smart-on-fhir/smart-launcher-v2.git - cd smart-launcher-v2 - git switch -c aa0f3b1 # Fix the version we use for the sandbox - npm ci - npm run build - env: - PORT: 5555 - - name: Run tests - run: | - pytest tests/ - env: - SANDBOX_DIR: ${{ github.workspace }}/smart-launcher-v2 - - \ No newline at end of file + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: "18.x" + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[testing] + - name: Install SMART sandbox + run: | + git clone https://github.com/smart-on-fhir/smart-launcher-v2.git + cd smart-launcher-v2 + git switch -c aa0f3b1 # Fix the version we use for the sandbox + npm ci + npm run build + env: + PORT: 5555 + - name: Run tests + run: | + pytest tests/ + env: + SANDBOX_DIR: ${{ github.workspace }}/smart-launcher-v2 diff --git a/.gitignore b/.gitignore index 56232b6..e32023e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,35 @@ -jupyterhub-proxy.pid -jupyterhub_cookie_secret *.key *.key.pub *.egg-info + +node_modules +*.py[co] +*~ +.cache +.DS_Store +/build +dist +docs/_build +docs/build + +.ipynb_checkpoints +.virtual_documents + + +jupyterhub_cookie_secret +jupyterhub.sqlite +jupyterhub.sqlite* + +*.egg-info +MANIFEST +.coverage +.coverage.* +htmlcov +.idea/ +.vscode/ +.pytest_cache +pip-wheel-metadata +oldest-requirements.txt +jupyterhub-proxy.pid + +*.hot-update* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f0fc235 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,49 @@ +# pre-commit is a tool to perform a predefined set of tasks manually and/or +# automatically before git commits are made. +# +# Config reference: https://pre-commit.com/#pre-commit-configyaml---top-level +# +# Common tasks +# +# - Run on all files: pre-commit run --all-files +# - Register git hooks: pre-commit install --install-hooks +# + +ci: + # pre-commit.ci will open PRs updating our hooks once a month + autoupdate_schedule: monthly + +repos: + # autoformat and lint Python code + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.3 + hooks: + - id: ruff + types_or: + - python + - jupyter + args: ["--fix", "--show-fixes"] + - id: ruff-format + types_or: + - python + - jupyter + + # Autoformat: markdown, yaml, javascript (see the file .prettierignore) + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v4.0.0-alpha.8 + hooks: + - id: prettier + + # Format toml + - repo: https://github.com/ComPWA/taplo-pre-commit + rev: v0.9.3 + hooks: + - id: taplo-format + - id: taplo-lint + + # Autoformat and linting, misc. details + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: end-of-file-fixer + - id: requirements-txt-fixer diff --git a/README.md b/README.md index 03e711f..1ba8ff3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # SMART on FHIR flow in Jupyter + This repo contains two demos: - - `server_extension`: a Jupyter server extension that acts as public client for a SMART server. - - `hub_service`: a JupyterHub service that acts as confidential client for a SMART server and performs asymmetric authentication. + +- `server_extension`: a Jupyter server extension that acts as public client for a SMART server. +- `hub_service`: a JupyterHub service that acts as confidential client for a SMART server and performs asymmetric authentication. Check the READMEs in the respective folders for more information. Work in progress. diff --git a/examples/hub_service/README.md b/examples/hub_service/README.md index 35d31ce..dbf05b7 100644 --- a/examples/hub_service/README.md +++ b/examples/hub_service/README.md @@ -5,18 +5,21 @@ This project contains a demo of a JupyterHub service that acts as a confidential ## Installation 1. Clone this repository: + ``` git clone https://github.com/jupyterhealth/smart-fhir-jupyter-demo.git cd smart-fhir-jupyter-demo/hub_service ``` 2. Create and activate a virtual environment: + ``` python -m venv .venv - source .venv/bin/activate + source .venv/bin/activate ``` 3. Install the required dependencies: + ``` pip install -e . ``` @@ -30,6 +33,7 @@ This project contains a demo of a JupyterHub service that acts as a confidential ## Configuration 1. Update the `jupyterhub_config.py` file with your SMART on FHIR server details: + - Set the `fhir_base_url` to the sandbox server's URL 2. As a confidential client, this needs a public/private key pair. For demo purposes, the script takes care of generating this keypair. @@ -39,4 +43,5 @@ This project contains a demo of a JupyterHub service that acts as a confidential Once authenticated, users can make requests to the FHIR server through the service. The service handles token management and SMART on FHIR authentication flow. Example: -- Accessing `http://localhost:8000/hub/fhir` will fetch Condition resources from the FHIR server. \ No newline at end of file + +- Accessing `http://localhost:8000/hub/fhir` will fetch Condition resources from the FHIR server. diff --git a/examples/hub_service/jupyterhub_config.py b/examples/hub_service/jupyterhub_config.py index d811dda..8c56a0e 100644 --- a/examples/hub_service/jupyterhub_config.py +++ b/examples/hub_service/jupyterhub_config.py @@ -1,5 +1,4 @@ import requests - from oauthenticator.generic import GenericOAuthenticator c = get_config() # noqa diff --git a/examples/server_extension/README.md b/examples/server_extension/README.md index 8251ee0..6fe473f 100644 --- a/examples/server_extension/README.md +++ b/examples/server_extension/README.md @@ -1,6 +1,6 @@ # SMART App demo with Jupyter -This is a Jupyter server extension that fetches data from a SMART on FHIR endpoint. +This is a Jupyter server extension that fetches data from a SMART on FHIR endpoint. _work in progress_ @@ -9,15 +9,17 @@ _work in progress_ ## Local Installation with pip 1. Clone the repository: + ``` git clone https://github.com/jupyterhealth/smart-fhir-jupyter-demo.git cd smart-fhir-jupyter-demo/server_extension ``` 2. Create and activate a virtual environment: + ``` python -m venv .venv - source .venv/bin/activate + source .venv/bin/activate ``` 3. Install the Jupyter server extension: @@ -28,6 +30,7 @@ _work in progress_ ## Installation with Docker 1. Clone the repository: + ``` git clone https://github.com/jupyterhealth/smart-fhir-jupyter-demo.git cd smart-fhir-jupyter-demo/server_extension @@ -45,20 +48,23 @@ _work in progress_ 1. Ensure you're in the project directory and your virtual environment is activated. 2. Enable the server extension: + ``` jupyter server extension enable jupyter_smart_on_fhir.server_extension ``` -2. Start the Jupyter server: +3. Start the Jupyter server: + ``` jupyter server ``` -3. Open a web browser and navigate to `http://localhost:8888` to access the Jupyter interface. +4. Open a web browser and navigate to `http://localhost:8888` to access the Jupyter interface. ## Running with Docker 1. Run the Docker container: + ``` docker run -p 8888:8888 smart-server-extension ``` @@ -71,7 +77,3 @@ _work in progress_ ``` http://localhost:8888/extension/fhir ``` - - - - diff --git a/jupyter_smart_on_fhir/auth.py b/jupyter_smart_on_fhir/auth.py index e756442..d3da06e 100644 --- a/jupyter_smart_on_fhir/auth.py +++ b/jupyter_smart_on_fhir/auth.py @@ -1,11 +1,12 @@ -from dataclasses import dataclass, asdict -import requests -import secrets -import jwt import json import os +import secrets +from dataclasses import asdict, dataclass from pathlib import Path +import jwt +import requests + @dataclass class SMARTConfig: @@ -47,7 +48,7 @@ def generate_state(next_url=None) -> dict: def get_jwks_from_key(key_file: Path, key_id: str = "1") -> str: """Generate a JWKS from a public key file. Not required for end users, but useful for development""" try: - with open(key_file + ".pub", "r") as f: + with open(key_file + ".pub") as f: public_key = f.read() except FileNotFoundError as e: raise FileNotFoundError( diff --git a/jupyter_smart_on_fhir/hub_service.py b/jupyter_smart_on_fhir/hub_service.py index 21a7196..40839fe 100755 --- a/jupyter_smart_on_fhir/hub_service.py +++ b/jupyter_smart_on_fhir/hub_service.py @@ -4,25 +4,27 @@ - Asymmetric authentication """ +import base64 import os -import time import secrets +import time from functools import wraps +from urllib.parse import urlencode + +import jwt +import requests +from cryptography.fernet import Fernet, InvalidToken from flask import ( Flask, Response, + current_app, make_response, redirect, request, session, - current_app, ) -import requests -from urllib.parse import urlencode -import jwt -import base64 + from jupyter_smart_on_fhir.auth import SMARTConfig, generate_state, validate_keys -from cryptography.fernet import Fernet, InvalidToken prefix = os.environ.get("JUPYTERHUB_SERVICE_PREFIX", "/") diff --git a/jupyter_smart_on_fhir/server_extension.py b/jupyter_smart_on_fhir/server_extension.py index 7143555..5e30e8e 100644 --- a/jupyter_smart_on_fhir/server_extension.py +++ b/jupyter_smart_on_fhir/server_extension.py @@ -1,14 +1,16 @@ -from jupyter_server.extension.application import ExtensionApp -from jupyter_server.base.handlers import JupyterHandler -import tornado -import requests +import base64 +import hashlib import secrets from urllib.parse import urlencode, urljoin -import hashlib -import base64 -from jupyter_smart_on_fhir.auth import SMARTConfig, generate_state + +import requests +import tornado +from jupyter_server.base.handlers import JupyterHandler +from jupyter_server.extension.application import ExtensionApp from traitlets import List, Unicode +from jupyter_smart_on_fhir.auth import SMARTConfig, generate_state + smart_path = "/smart" login_path = "/smart/login" callback_path = "/smart/oauth_callback" diff --git a/pyproject.toml b/pyproject.toml index 8127a07..896b337 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,13 +6,13 @@ build-backend = "setuptools.build_meta" name = "jupyter_smart_on_fhir" version = "0.1.0" dependencies = [ - "flask", - "tornado", - "pyJWT", - "requests", - "jupyter_server", - "jupyterhub", - "oauthenticator", + "flask", + "tornado", + "pyJWT", + "requests", + "jupyter_server", + "jupyterhub", + "oauthenticator", ] [project.optional-dependencies] @@ -21,3 +21,14 @@ testing = ["pytest"] [tool.setuptools] packages = ["jupyter_smart_on_fhir"] + +[tool.ruff.lint] +ignore = [ + "F841", # unused variable +] +select = [ + "E9", # syntax + "I", # isort + "UP", # pyupgrade + "F", # flake8 +] diff --git a/tests/conftest.py b/tests/conftest.py index c40c22b..84ccb29 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,14 @@ -import pytest +import base64 +import json import os import subprocess -import requests import time -from dataclasses import asdict, field, dataclass -import base64 -import json +from dataclasses import asdict, dataclass, field from urllib import parse +import pytest +import requests + @pytest.fixture(scope="function") # module? def sandbox(): diff --git a/tests/test_hub_service.py b/tests/test_hub_service.py index 72edeaf..ffa4393 100644 --- a/tests/test_hub_service.py +++ b/tests/test_hub_service.py @@ -1,19 +1,20 @@ +import os +from urllib import parse + import pytest +import requests +from conftest import SandboxConfig +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa from flask import session + from jupyter_smart_on_fhir.auth import get_jwks_from_key from jupyter_smart_on_fhir.hub_service import ( create_app, - set_encrypted_cookie, get_encrypted_cookie, prefix, - token_for_code, + set_encrypted_cookie, ) -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import rsa -import os -import requests -from conftest import SandboxConfig -from urllib import parse @pytest.fixture(scope="module") diff --git a/tests/test_server_extension.py b/tests/test_server_extension.py index a86dd78..7f36d98 100644 --- a/tests/test_server_extension.py +++ b/tests/test_server_extension.py @@ -1,9 +1,11 @@ import os import subprocess -import requests + import pytest -from conftest import wait_for_server, SandboxConfig -from jupyter_smart_on_fhir.server_extension import smart_path, login_path, callback_path +import requests +from conftest import SandboxConfig, wait_for_server + +from jupyter_smart_on_fhir.server_extension import callback_path, login_path, smart_path PORT = os.getenv("TEST_PORT", 18888) ext_url = f"http://localhost:{PORT}" @@ -35,8 +37,8 @@ def jupyter_server(tmpdir, jupyterdir): command = [ "jupyter-server", "--ServerApp.token=secret", - "--SMARTExtensionApp.client_id={}".format(client_id), - "--port={}".format(PORT), + f"--SMARTExtensionApp.client_id={client_id}", + f"--port={PORT}", ] subprocess.check_call( extension_command + ["enable", "jupyter_smart_on_fhir.server_extension"],