From c61d907c4294ae3963931a479e1618508fc0e7cf Mon Sep 17 00:00:00 2001 From: Sudo-JHare Date: Wed, 23 Apr 2025 21:25:46 +1000 Subject: [PATCH] Beta 0.1 initial commit --- Designing for FHIRFLARE Integration | 65 +++ Dockerfile | 50 ++ Dockerfile.minimal | 24 + app.py | 243 ++++++++++ config.py | 0 docker-compose.yml | 26 ++ forms.py | 67 +++ minimal_test.py | 138 ++++++ models.py | 90 ++++ pip_install.log | 87 ++++ requirements.txt | 10 + smart_proxy.py | 697 ++++++++++++++++++++++++++++ templates/about.html | 27 ++ templates/app_gallery/delete.html | 19 + templates/app_gallery/edit.html | 101 ++++ templates/app_gallery/gallery.html | 75 +++ templates/app_gallery/register.html | 113 +++++ templates/auth/consent.html | 68 +++ templates/base.html | 388 ++++++++++++++++ templates/consent.html | 55 +++ templates/errors/404.html | 16 + templates/errors/500.html | 16 + templates/errors/auth_error.html | 20 + templates/index.html | 18 + templates/proxy_settings.html | 21 + templates/security.html | 25 + templates/server_endpoints.html | 25 + templates/test_client.html | 43 ++ 28 files changed, 2527 insertions(+) create mode 100644 Designing for FHIRFLARE Integration create mode 100644 Dockerfile create mode 100644 Dockerfile.minimal create mode 100644 app.py create mode 100644 config.py create mode 100644 docker-compose.yml create mode 100644 forms.py create mode 100644 minimal_test.py create mode 100644 models.py create mode 100644 pip_install.log create mode 100644 requirements.txt create mode 100644 smart_proxy.py create mode 100644 templates/about.html create mode 100644 templates/app_gallery/delete.html create mode 100644 templates/app_gallery/edit.html create mode 100644 templates/app_gallery/gallery.html create mode 100644 templates/app_gallery/register.html create mode 100644 templates/auth/consent.html create mode 100644 templates/base.html create mode 100644 templates/consent.html create mode 100644 templates/errors/404.html create mode 100644 templates/errors/500.html create mode 100644 templates/errors/auth_error.html create mode 100644 templates/index.html create mode 100644 templates/proxy_settings.html create mode 100644 templates/security.html create mode 100644 templates/server_endpoints.html create mode 100644 templates/test_client.html diff --git a/Designing for FHIRFLARE Integration b/Designing for FHIRFLARE Integration new file mode 100644 index 0000000..e13d2cd --- /dev/null +++ b/Designing for FHIRFLARE Integration @@ -0,0 +1,65 @@ +Designing for FHIRFLARE Integration +To ensure FHIRVINE can be a module or plugin proxy for FHIRFLARE (per your GitHub repo: https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit, April 12–18, 2025), the updated smart_proxy.py is designed as a standalone Blueprint. To integrate into FHIRFLARE: + +Import the Blueprint: +In FHIRFLARE’s app.py, register the smart_blueprint: +python + +Copy +from fhirvine.smart_proxy import smart_blueprint, configure_oauth +app.register_blueprint(smart_blueprint, url_prefix='/oauth2') +configure_oauth(app, db=database, registered_app_model=RegisteredApp, oauth_token_model=OAuthToken) +Ensure FHIRFLARE’s models.py includes RegisteredApp and OAuthToken or equivalent models. +Configuration: +Use environment variables for FHIRVINE-specific settings (e.g., FHIR_SERVER_URL, OAUTH2_TOKEN_EXPIRES_IN) in .env: +text + +Copy +FHIR_SERVER_URL=http://placeholder-fhir-server.com/fhir +OAUTH2_TOKEN_EXPIRES_IN=3600 +Load in FHIRFLARE’s app.py using python-dotenv. +Template Integration: +Copy FHIRVINE’s templates (consent.html, auth_error.html) to FHIRFLARE’s templates/auth/ or configure smart_blueprint.template_folder to point to FHIRVINE’s templates directory. +Update FHIRFLARE’s base.html to include FHIRVINE routes (e.g., /oauth2/authorize) in the navbar if needed. +Database: +Use FHIRFLARE’s SQLite database (instance/flarefhir.db, per April 12, 2025) by passing it to configure_oauth. +Run migrations to add registered_apps and oauth_tokens tables: +bash + +Copy +docker-compose exec flarefhir flask db migrate -m "Add FHIRVINE tables" +docker-compose exec flarefhir flask db upgrade +Testing: +Test FHIRVINE routes within FHIRFLARE (e.g., http://localhost:5000/oauth2/authorize if FHIRFLARE runs on port 5000). +Ensure FHIRFLARE’s UI (e.g., fhir_ui_operations.html, April 16–17, 2025) can trigger FHIRVINE’s OAuth2 flow for app launches. +Troubleshooting +Error Persists: +Add debug logging in RegisteredApp.check_response_type: +python + +Copy +def check_response_type(self, response_type): + logger.debug(f"Checking response_type: {response_type}") + return response_type == 'code' +Check session data in /consent: +python + +Copy +logger.debug(f"Session data: {session}") +Verify request_params contains response_type=code. +Redirect Fails: +Confirm https://httpbin.org/get is in redirect_uris: +bash + +Copy +docker-compose exec fhirvine sqlite3 /app/instance/fhirvine.db "SELECT redirect_uris FROM registered_apps WHERE client_id = 'fRnWBBuEBNF_mkH09nZdBA2_lwekV-_H';" +Ensure scope=openid launch/patient matches registered scopes. +Token Exchange Fails: +Verify client_secret matches the hashed value in client_secret_hashed. +Check code validity (expires in 600 seconds). +Addressing Prior Context +FHIRFLARE Integration: Your work on FHIRFLARE-IG-Toolkit (April 12–18, 2025) uses a similar Flask structure (app.py, templates/, SQLite). FHIRVINE’s Blueprint design mirrors FHIRFLARE’s modular routes (e.g., fhir_ig_importer, April 13, 2025), ensuring easy integration. +Security: Hashing client_secret (April 22, 2025) aligns with FHIRFLARE’s security practices (March 25, 2025). +UI: FHIRVINE’s templates (consent.html) use Bootstrap, matching FHIRFLARE’s UI (e.g., fhir_ui_operations.html, April 17, 2025). +FHIR Server: The aud=http://placeholder-fhir-server.com/fhir suggests a mock server. If using HAPI FHIR (April 16, 2025), update to its metadata endpoint. +Questions for Next Steps \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..576c8b8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# Dockerfile for FHIRVINE (Single Stage - Log pip install) + +# Use Python 3.11 Bullseye base image +FROM python:3.11-bullseye + +# Set environment variables using recommended key=value format +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Set work directory +WORKDIR /app + +# Install system dependencies (needed if any Python package requires compilation) +# Keep build-essential just in case +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for caching layer optimization +COPY requirements.txt . + +# Ensure pip is up-to-date +RUN pip install --no-cache-dir --upgrade pip + +# Install dependencies directly from requirements.txt +# Redirect stdout and stderr to a log file +# Use --force-reinstall for Authlib as an extra measure +# Using Authlib 1.5.2 as 1.2.0 had Werkzeug incompatibility +RUN pip install --no-cache-dir -r requirements.txt --force-reinstall Authlib==1.5.2 > /pip_install.log 2>&1 || (cat /pip_install.log && exit 1) + +# Copy the rest of the application code +COPY . . + +# Create a non-root user and group +RUN groupadd -r appuser && useradd --no-log-init -r -g appuser appuser + +# Create the instance directory +RUN mkdir -p instance + +# Change ownership AFTER creating instance dir and copying code +RUN chown -R appuser:appuser /app + +# Switch to the non-root user +USER appuser + +# Expose the port +EXPOSE 5001 + +# Define the command to run the application +CMD exec /usr/local/bin/waitress-serve --host=0.0.0.0 --port=5001 --call 'app:create_app' diff --git a/Dockerfile.minimal b/Dockerfile.minimal new file mode 100644 index 0000000..bcc212a --- /dev/null +++ b/Dockerfile.minimal @@ -0,0 +1,24 @@ +# Dockerfile.minimal - Minimal environment for testing Authlib + +# Use a slim Python 3.11 image +FROM python:3.11-slim-bullseye + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# Set work directory +WORKDIR /app + +# Upgrade pip +RUN pip install --no-cache-dir --upgrade pip + +# Install only Flask and the specific Authlib version +# Use --no-cache-dir and --force-reinstall for certainty +RUN pip install --no-cache-dir Flask==2.3.3 Authlib==1.5.2 + +# Copy ONLY the minimal test script +COPY minimal_test.py . + +# Command to run the test script directly +CMD ["python", "minimal_test.py"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..8ac3beb --- /dev/null +++ b/app.py @@ -0,0 +1,243 @@ +import os +import sys +import logging +import secrets +import base64 +import hashlib +from flask import Flask, render_template, request, redirect, url_for, flash, session +from flask_migrate import Migrate +from flasgger import Swagger +from dotenv import load_dotenv +from werkzeug.security import generate_password_hash + +# Add current directory to Python path for reliable imports +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# Import from our modules +from models import database, RegisteredApp, OAuthToken +from forms import RegisterAppForm, TestClientForm +from smart_proxy import smart_blueprint, configure_oauth + +# Load environment variables from .env file +load_dotenv() + +# Set up logging +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +# Initialize extensions +migrate = Migrate() + +def create_app(): + """Create and configure the Flask application.""" + app = Flask(__name__, instance_relative_config=True) + + # Configuration + app.config.from_mapping( + SECRET_KEY=os.environ.get('SECRET_KEY', 'dev-secret-key-for-fhirvine'), + SQLALCHEMY_DATABASE_URI=os.environ.get('DATABASE_URL', f'sqlite:///{os.path.join(app.instance_path, "fhirvine.db")}'), + SQLALCHEMY_TRACK_MODIFICATIONS=False, + # Default proxy settings (can be overridden in .env) + FHIR_SERVER_URL=os.environ.get('FHIR_SERVER_URL', 'http://hapi.fhir.org/baseR4'), + ) + + # Ensure the instance folder exists + try: + os.makedirs(app.instance_path, exist_ok=True) + logger.info(f"Instance path created/verified: {app.instance_path}") + logger.info(f"Database URI set to: {app.config['SQLALCHEMY_DATABASE_URI']}") + except OSError as e: + logger.error(f"Could not create instance path at {app.instance_path}: {e}", exc_info=True) + + # Initialize Flask extensions + database.init_app(app) + migrate.init_app(app, database) + + # Initialize Flasgger for OpenAPI + Swagger(app, template={ + "info": { + "title": "FHIRVINE SMART on FHIR Proxy API", + "version": "1.0", + "description": "API for SMART on FHIR SSO proxy functionality." + }, + "host": "localhost:5001", + "basePath": "/oauth2", + "schemes": ["http"] + }) + + # Configure Authlib + configure_oauth(app) + + # Register blueprints + app.register_blueprint(smart_blueprint, url_prefix='/oauth2') + + # Main application routes + @app.route('/') + def index(): + return render_template('index.html') + + @app.route('/app-gallery') + def app_gallery(): + """Display the list of registered applications.""" + try: + apps = RegisteredApp.query.order_by(RegisteredApp.app_name).all() + except Exception as e: + logger.error(f"Error fetching apps from database: {e}", exc_info=True) + flash("Could not load application gallery. Please try again later.", "error") + apps = [] + return render_template('app_gallery/gallery.html', apps=apps) + + @app.route('/register-app', methods=['GET', 'POST']) + def register_app(): + """Handle registration of new applications.""" + form = RegisterAppForm() + if form.validate_on_submit(): + try: + # Generate a unique client_id + client_id = secrets.token_urlsafe(32) + # Normalize redirect_uris to lowercase + redirect_uris = form.redirect_uris.data.strip().lower() + new_app = RegisteredApp( + app_name=form.app_name.data, + client_id=client_id, + redirect_uris=redirect_uris, + scopes=form.scopes.data.strip(), + logo_uri=form.logo_uri.data or None, + contacts=form.contacts.data.strip() or None, + tos_uri=form.tos_uri.data or None, + policy_uri=form.policy_uri.data or None + ) + # Generate and hash client secret + client_secret = secrets.token_urlsafe(40) + new_app.set_client_secret(client_secret) + database.session.add(new_app) + database.session.commit() + flash(f"Application '{new_app.app_name}' registered successfully!", "success") + flash(f"Client ID: {new_app.client_id}", "info") + flash(f"Client Secret: {client_secret} (Please store this securely!)", "warning") + logger.info(f"Registered new app: {new_app.app_name} (ID: {new_app.id}, ClientID: {new_app.client_id})") + return redirect(url_for('app_gallery')) + except Exception as e: + database.session.rollback() + logger.error(f"Error registering application '{form.app_name.data}': {e}", exc_info=True) + flash(f"Error registering application: {e}", "error") + return render_template('app_gallery/register.html', form=form) + + @app.route('/edit-app/', methods=['GET', 'POST']) + def edit_app(app_id): + """Handle editing of an existing application.""" + app = RegisteredApp.query.get_or_404(app_id) + form = RegisterAppForm(obj=app) + if form.validate_on_submit(): + try: + app.app_name = form.app_name.data + app.redirect_uris = form.redirect_uris.data.strip().lower() + app.scopes = form.scopes.data.strip() + app.logo_uri = form.logo_uri.data or None + app.contacts = form.contacts.data.strip() or None + app.tos_uri = form.tos_uri.data or None + app.policy_uri = form.policy_uri.data or None + database.session.commit() + flash(f"Application '{app.app_name}' updated successfully!", "success") + logger.info(f"Updated app: {app.app_name} (ID: {app.id}, ClientID: {app.client_id})") + return redirect(url_for('app_gallery')) + except Exception as e: + database.session.rollback() + logger.error(f"Error updating application '{app.app_name}': {e}", exc_info=True) + flash(f"Error updating application: {e}", "error") + return render_template('app_gallery/edit.html', form=form, app=app) + + @app.route('/delete-app/', methods=['GET', 'POST']) + def delete_app(app_id): + """Handle deletion of an existing application.""" + app = RegisteredApp.query.get_or_404(app_id) + if request.method == 'POST': + try: + # Delete associated tokens + OAuthToken.query.filter_by(client_id=app.client_id).delete() + database.session.delete(app) + database.session.commit() + flash(f"Application '{app.app_name}' deleted successfully!", "success") + logger.info(f"Deleted app: {app.app_name} (ID: {app.id}, ClientID: {app.client_id})") + return redirect(url_for('app_gallery')) + except Exception as e: + database.session.rollback() + logger.error(f"Error deleting application '{app.app_name}': {e}", exc_info=True) + flash(f"Error deleting application: {e}", "error") + return render_template('app_gallery/delete.html', app=app) + + @app.route('/test-client', methods=['GET', 'POST']) + def test_client(): + """Handle testing of registered SMART app launches.""" + form = TestClientForm() + if form.validate_on_submit(): + client_id = form.client_id.data + app = RegisteredApp.query.filter_by(client_id=client_id).first() + if not app: + flash("Invalid Client ID.", "error") + return redirect(url_for('test_client')) + # Deduplicate scopes + scopes = ' '.join(set(app.scopes.split())) + # Generate PKCE code verifier and challenge + code_verifier = secrets.token_urlsafe(32) + code_challenge = base64.urlsafe_b64encode( + hashlib.sha256(code_verifier.encode('ascii')).digest() + ).decode('ascii').rstrip('=') + # Store code verifier in session for /token + session['code_verifier'] = code_verifier + # Construct SMART authorization URL, ensuring redirect_uri is lowercase + redirect_uri = app.get_default_redirect_uri().lower() + auth_url = url_for( + 'smart_proxy.authorize', + client_id=client_id, + redirect_uri=redirect_uri, + scope=scopes, + response_type='code', + state='test_state_123', + aud='http://hapi.fhir.org/baseR4/metadata', + code_challenge=code_challenge, + code_challenge_method='S256', + _external=True + ) + logger.info(f"Redirecting to authorization URL for client '{client_id}'") + logger.info(f"Code verifier for client '{client_id}': {code_verifier}") + return redirect(auth_url) + apps = RegisteredApp.query.all() + return render_template('test_client.html', form=form, apps=apps) + + @app.route('/configure/proxy-settings') + def proxy_settings(): + """Display proxy settings page.""" + return render_template('configure/proxy_settings.html', fhir_server_url=app.config['FHIR_SERVER_URL']) + + @app.route('/configure/server-endpoints') + def server_endpoints(): + """Display server endpoints page.""" + return render_template('configure/server_endpoints.html') + + @app.route('/configure/security') + def security_settings(): + """Display security settings page.""" + return render_template('configure/security.html') + + @app.route('/about') + def about(): + """Display about page.""" + return render_template('about.html') + + # Context processors + @app.context_processor + def inject_site_name(): + return dict(site_name='FHIRVINE SMART Proxy') + + # Error handlers + @app.errorhandler(404) + def not_found(error): + return render_template('errors/404.html'), 404 + + @app.errorhandler(500) + def internal_error(error): + return render_template('errors/500.html'), 500 + + logger.info("FHIRVINE Flask application created and configured.") + return app \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..12c9ba5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +# docker-compose.yml for FHIRVINE + +services: + fhirvine: + build: . # Build the image from the Dockerfile in the current directory + container_name: fhirvine_app # Optional: Assign a specific name + ports: + - "5001:5001" # Map host port 5001 to container port 5001 (where Waitress listens) + volumes: + # Mount the instance directory (for DB) using a named volume + - fhirvine_instance:/app/instance + # Mount the migrations directory using a named volume + - fhirvine_migrations:/app/migrations # <-- ADDED THIS LINE + environment: + # Load environment variables from a .env file in the same directory as docker-compose.yml + - FLASK_ENV=${FLASK_ENV:-development} # Default to development if not set in .env + # SECRET_KEY should be set in your .env file for security + env_file: + - .env # Load variables from .env file + restart: unless-stopped # Optional: Restart policy + +# Define the named volumes +volumes: + fhirvine_instance: + fhirvine_migrations: # <-- ADDED THIS LINE + diff --git a/forms.py b/forms.py new file mode 100644 index 0000000..36a114f --- /dev/null +++ b/forms.py @@ -0,0 +1,67 @@ +import re +from flask_wtf import FlaskForm +from wtforms import StringField, TextAreaField, URLField, SubmitField, PasswordField +from wtforms.validators import DataRequired, Length, Optional, URL, Regexp, ValidationError + +# Custom validator for space-separated URIs +def validate_uris(form, field): + uris = field.data.split() + for uri in uris: + if not uri.startswith(('http://', 'https://')): + raise ValidationError(f'Invalid URI format: "{uri}". Must start with http:// or https://.') + if ' ' in uri: + raise ValidationError(f'URI cannot contain spaces: "{uri}"') + +# Custom validator for space-separated scopes +def validate_scopes(form, field): + scopes = field.data.split() + scope_pattern = r'^[a-zA-Z0-9\/\.\*\-]+$' + for scope in scopes: + if not re.match(scope_pattern, scope): + raise ValidationError(f'Invalid scope format: "{scope}". Allowed characters: a-z A-Z 0-9 / . * -') + +class RegisterAppForm(FlaskForm): + """Form for registering a new SMART application.""" + app_name = StringField( + 'Application Name', + validators=[DataRequired(), Length(min=3, max=100)] + ) + redirect_uris = TextAreaField( + 'Redirect URIs (Space-separated)', + validators=[DataRequired(), validate_uris], + description='Enter one or more valid redirect URIs, separated by spaces. Example: https://myapp.com/callback https://localhost:3000/cb' + ) + scopes = TextAreaField( + 'Allowed Scopes (Space-separated)', + validators=[DataRequired(), validate_scopes], + default='openid profile launch launch/patient patient/*.read offline_access', + description='Enter the scopes this application is allowed to request, separated by spaces. Standard SMART scopes recommended.' + ) + logo_uri = URLField( + 'Logo URI (Optional)', + validators=[Optional(), URL()], + description='A URL pointing to an image for the application logo.' + ) + contacts = TextAreaField( + 'Contacts (Optional, space-separated)', + description='Contact email addresses (e.g., mailto:dev@example.com) or URLs, separated by spaces.' + ) + tos_uri = URLField( + 'Terms of Service URI (Optional)', + validators=[Optional(), URL()], + description='Link to the application\'s Terms of Service.' + ) + policy_uri = URLField( + 'Privacy Policy URI (Optional)', + validators=[Optional(), URL()], + description='Link to the application\'s Privacy Policy.' + ) + submit = SubmitField('Register Application') + +class TestClientForm(FlaskForm): + """Form for testing SMART app launches.""" + client_id = StringField( + 'Client ID', + validators=[DataRequired(), Length(min=1, max=100)] + ) + submit = SubmitField('Launch Test') \ No newline at end of file diff --git a/minimal_test.py b/minimal_test.py new file mode 100644 index 0000000..567203e --- /dev/null +++ b/minimal_test.py @@ -0,0 +1,138 @@ +# minimal_test.py (Updated to Run Test) +import logging +import os +from flask import Flask, request, jsonify +from authlib.integrations.flask_oauth2 import AuthorizationServer +from authlib.oauth2.rfc6749.errors import OAuth2Error +from werkzeug.datastructures import ImmutableMultiDict + +# --- Basic Logging Setup --- +logging.basicConfig(level=logging.DEBUG, format='%(levelname)s:%(name)s:%(message)s') # Simplified format for clarity +logger = logging.getLogger(__name__) + +# --- Dummy Client Implementation --- +class DummyClient: + def __init__(self, client_id, redirect_uris, scopes, response_types=['code']): + self.client_id = client_id + self._redirect_uris = redirect_uris.split() if isinstance(redirect_uris, str) else redirect_uris + self._scopes = scopes.split() if isinstance(scopes, str) else scopes + self._response_types = response_types + + def get_client_id(self): + return self.client_id + + def check_redirect_uri(self, redirect_uri): + logger.debug(f"[DummyClient] Checking redirect_uri: '{redirect_uri}' against {self._redirect_uris}") + return redirect_uri in self._redirect_uris + + def check_response_type(self, response_type): + logger.debug(f"[DummyClient] Checking response_type: '{response_type}' against {self._response_types}") + return response_type in self._response_types + + def get_allowed_scopes(self, requested_scopes): + allowed = set(self._scopes) + requested = set(requested_scopes.split()) + granted = allowed.intersection(requested) + logger.debug(f"[DummyClient] Checking scopes: Requested={requested}, Allowed={allowed}, Granted={granted}") + return ' '.join(list(granted)) + + def check_grant_type(self, grant_type): + return grant_type == 'authorization_code' + + def has_client_secret(self): + return True + + def check_client_secret(self, client_secret): + return True + +# --- Dummy Query Client Function --- +DUMMY_CLIENT_ID = 'test-client-123' +DUMMY_REDIRECT_URI = 'http://localhost:8000/callback' +DUMMY_SCOPES = 'openid profile launch/patient patient/*.read' + +dummy_client_instance = DummyClient(DUMMY_CLIENT_ID, DUMMY_REDIRECT_URI, DUMMY_SCOPES) + +def query_client(client_id): + logger.debug(f"[Minimal Test] query_client called for ID: {client_id}") + if client_id == DUMMY_CLIENT_ID: + logger.debug("[Minimal Test] Returning dummy client instance.") + return dummy_client_instance + logger.warning(f"[Minimal Test] Client ID '{client_id}' not found.") + return None + +# --- Flask App and Authlib Setup --- +app = Flask(__name__) +app.config['SECRET_KEY'] = 'minimal-test-secret' + +# Check Authlib version +try: + import authlib + import inspect + logger.info(f"Authlib version found: {authlib.__version__}") + logger.info(f"Authlib location: {inspect.getfile(authlib)}") +except ImportError: + logger.error("Could not import Authlib!") +except Exception as e: + logger.error(f"Error getting Authlib info: {e}") + +# Initialize AuthorizationServer +authorization_server = AuthorizationServer(query_client=query_client) +authorization_server.init_app(app) + +# --- Test Route --- +@app.route('/test-authlib') +def test_authlib_route(): + logger.info("--- Entered /test-authlib route ---") + # NOTE: We are already inside a request context when run via test_client + logger.info(f"Actual request URL: {request.url}") + logger.info(f"Actual request Args: {request.args}") + logger.debug(f"Attempting validation. Type of authorization_server: {type(authorization_server)}") + try: + # --- The critical call --- + # Authlib uses the current request context (request.args, etc.) + grant = authorization_server.validate_authorization_request() + # ------------------------ + + logger.info(">>> SUCCESS: validate_authorization_request() completed.") + logger.info(f"Grant type: {type(grant)}") + logger.info(f"Grant client: {grant.client.get_client_id() if grant and grant.client else 'N/A'}") + return jsonify(status="success", message="validate_authorization_request() worked!", grant_type=str(type(grant))) + + except AttributeError as ae: + logger.critical(f">>> FAILURE: AttributeError calling validate_authorization_request(): {ae}", exc_info=True) + return jsonify(status="error", message=f"AttributeError: {ae}"), 500 + except OAuth2Error as error: + logger.error(f">>> FAILURE: OAuth2Error calling validate_authorization_request(): {error.error} - {error.description}", exc_info=True) + return jsonify(status="error", message=f"OAuth2Error: {error.error} - {error.description}"), 400 + except Exception as e: + logger.error(f">>> FAILURE: Unexpected Exception calling validate_authorization_request(): {e}", exc_info=True) + return jsonify(status="error", message=f"Unexpected Exception: {e}"), 500 + +# --- Function to Run the Test --- +def run_test(): + """Uses Flask's test client to execute the test route.""" + logger.info("--- Starting Test Run ---") + client = app.test_client() + # Construct the query parameters for the test request + query_params = { + 'response_type': 'code', + 'client_id': DUMMY_CLIENT_ID, + 'redirect_uri': DUMMY_REDIRECT_URI, + 'scope': DUMMY_SCOPES, + 'state': 'dummy_state' + } + # Make a GET request to the test route + response = client.get('/test-authlib', query_string=query_params) + logger.info(f"--- Test Run Complete --- Status Code: {response.status_code} ---") + # Print the JSON response body for verification + try: + logger.info(f"Response JSON: {response.get_json()}") + except Exception: + logger.info(f"Response Data: {response.data.decode()}") + + +# --- Main execution block --- +if __name__ == '__main__': + logger.info("Minimal test script loaded.") + # Run the test function directly + run_test() diff --git a/models.py b/models.py new file mode 100644 index 0000000..0310357 --- /dev/null +++ b/models.py @@ -0,0 +1,90 @@ +from flask_sqlalchemy import SQLAlchemy +from werkzeug.security import generate_password_hash, check_password_hash + +database = SQLAlchemy() + +class RegisteredApp(database.Model): + __tablename__ = 'registered_apps' + id = database.Column(database.Integer, primary_key=True) + app_name = database.Column(database.String(100), unique=True, nullable=False) + client_id = database.Column(database.String(100), unique=True, nullable=False) + client_secret_hash = database.Column(database.String(200), nullable=False) + redirect_uris = database.Column(database.Text, nullable=False) + scopes = database.Column(database.Text, nullable=False) + logo_uri = database.Column(database.String(255)) + contacts = database.Column(database.Text) + tos_uri = database.Column(database.String(255)) + policy_uri = database.Column(database.String(255)) + jwks_uri = database.Column(database.String(255)) + date_registered = database.Column(database.DateTime) + last_updated = database.Column(database.DateTime) + + # Authlib ClientMixin methods + def get_client_id(self): + return self.client_id + + def get_default_redirect_uri(self): + return self.redirect_uris.split(',')[0] if self.redirect_uris else None + + def check_redirect_uri(self, redirect_uri): + return redirect_uri in self.redirect_uris.split(',') + + def check_response_type(self, response_type): + return response_type == 'code' + + def check_grant_type(self, grant_type): + # Support authorization_code and refresh_token grants + return grant_type in ['authorization_code', 'refresh_token'] + + def check_endpoint_auth_method(self, method, endpoint): + # Support client_secret_post and client_secret_basic for token endpoint + # Currently, we support the same methods for all endpoints + return method in ['client_secret_post', 'client_secret_basic'] + + def get_allowed_scope(self, scopes): + allowed = set(self.scopes.split()) + requested = set(scopes.split()) + return ' '.join(sorted(allowed.intersection(requested))) + + def set_client_secret(self, client_secret): + """Set and hash the client secret.""" + self.client_secret_hash = generate_password_hash(client_secret) + + def check_client_secret(self, client_secret): + """Verify the client secret against the stored hash.""" + return check_password_hash(self.client_secret_hash, client_secret) + +class OAuthToken(database.Model): + __tablename__ = 'oauth_tokens' + id = database.Column(database.Integer, primary_key=True) + client_id = database.Column(database.String(100), nullable=False) + token_type = database.Column(database.String(40)) + access_token = database.Column(database.String(255), nullable=False) + refresh_token = database.Column(database.String(255)) + scope = database.Column(database.Text) + issued_at = database.Column(database.Integer, nullable=False) + expires_in = database.Column(database.Integer, nullable=False) + +class AuthorizationCode(database.Model): + __tablename__ = 'authorization_codes' + id = database.Column(database.Integer, primary_key=True) + code = database.Column(database.String(255), nullable=False) + client_id = database.Column(database.String(100), database.ForeignKey('registered_apps.client_id', name='fk_authorization_codes_client_id'), nullable=False) + redirect_uri = database.Column(database.Text) + scope = database.Column(database.Text) + nonce = database.Column(database.String(255)) + code_challenge = database.Column(database.String(255)) + code_challenge_method = database.Column(database.String(10)) + response_type = database.Column(database.String(40)) + state = database.Column(database.String(255)) + issued_at = database.Column(database.Integer, nullable=False) + expires_at = database.Column(database.Integer, nullable=False) + + # Authlib AuthorizationCodeMixin methods + def get_redirect_uri(self): + """Return the redirect URI associated with this authorization code.""" + return self.redirect_uri + + def get_scope(self): + """Return the scope associated with this authorization code.""" + return self.scope \ No newline at end of file diff --git a/pip_install.log b/pip_install.log new file mode 100644 index 0000000..13a8ea7 --- /dev/null +++ b/pip_install.log @@ -0,0 +1,87 @@ +Collecting Authlib==1.5.2 + Downloading authlib-1.5.2-py2.py3-none-any.whl.metadata (3.9 kB) +Collecting Flask<3.0,>=2.3 (from -r requirements.txt (line 2)) + Downloading flask-2.3.3-py3-none-any.whl.metadata (3.6 kB) +Collecting Flask-SQLAlchemy>=3.0 (from -r requirements.txt (line 3)) + Downloading flask_sqlalchemy-3.1.1-py3-none-any.whl.metadata (3.4 kB) +Collecting Flask-WTF>=1.1 (from -r requirements.txt (line 4)) + Downloading flask_wtf-1.2.2-py3-none-any.whl.metadata (3.4 kB) +Collecting Flask-Migrate>=4.0 (from -r requirements.txt (line 5)) + Downloading Flask_Migrate-4.1.0-py3-none-any.whl.metadata (3.3 kB) +Collecting requests>=2.28 (from -r requirements.txt (line 7)) + Downloading requests-2.32.3-py3-none-any.whl.metadata (4.6 kB) +Collecting python-dotenv>=1.0 (from -r requirements.txt (line 8)) + Downloading python_dotenv-1.1.0-py3-none-any.whl.metadata (24 kB) +Collecting werkzeug<3.0,>=2.3 (from -r requirements.txt (line 9)) + Downloading werkzeug-2.3.8-py3-none-any.whl.metadata (4.1 kB) +Collecting waitress (from -r requirements.txt (line 10)) + Downloading waitress-3.0.2-py3-none-any.whl.metadata (5.8 kB) +Collecting cryptography (from Authlib==1.5.2) + Downloading cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl.metadata (5.7 kB) +Collecting Jinja2>=3.1.2 (from Flask<3.0,>=2.3->-r requirements.txt (line 2)) + Downloading jinja2-3.1.6-py3-none-any.whl.metadata (2.9 kB) +Collecting itsdangerous>=2.1.2 (from Flask<3.0,>=2.3->-r requirements.txt (line 2)) + Downloading itsdangerous-2.2.0-py3-none-any.whl.metadata (1.9 kB) +Collecting click>=8.1.3 (from Flask<3.0,>=2.3->-r requirements.txt (line 2)) + Downloading click-8.1.8-py3-none-any.whl.metadata (2.3 kB) +Collecting blinker>=1.6.2 (from Flask<3.0,>=2.3->-r requirements.txt (line 2)) + Downloading blinker-1.9.0-py3-none-any.whl.metadata (1.6 kB) +Collecting sqlalchemy>=2.0.16 (from Flask-SQLAlchemy>=3.0->-r requirements.txt (line 3)) + Downloading sqlalchemy-2.0.40-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (9.6 kB) +Collecting wtforms (from Flask-WTF>=1.1->-r requirements.txt (line 4)) + Downloading wtforms-3.2.1-py3-none-any.whl.metadata (5.3 kB) +Collecting alembic>=1.9.0 (from Flask-Migrate>=4.0->-r requirements.txt (line 5)) + Downloading alembic-1.15.2-py3-none-any.whl.metadata (7.3 kB) +Collecting charset-normalizer<4,>=2 (from requests>=2.28->-r requirements.txt (line 7)) + Downloading charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (35 kB) +Collecting idna<4,>=2.5 (from requests>=2.28->-r requirements.txt (line 7)) + Downloading idna-3.10-py3-none-any.whl.metadata (10 kB) +Collecting urllib3<3,>=1.21.1 (from requests>=2.28->-r requirements.txt (line 7)) + Downloading urllib3-2.4.0-py3-none-any.whl.metadata (6.5 kB) +Collecting certifi>=2017.4.17 (from requests>=2.28->-r requirements.txt (line 7)) + Downloading certifi-2025.1.31-py3-none-any.whl.metadata (2.5 kB) +Collecting MarkupSafe>=2.1.1 (from werkzeug<3.0,>=2.3->-r requirements.txt (line 9)) + Downloading MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (4.0 kB) +Collecting Mako (from alembic>=1.9.0->Flask-Migrate>=4.0->-r requirements.txt (line 5)) + Downloading mako-1.3.10-py3-none-any.whl.metadata (2.9 kB) +Collecting typing-extensions>=4.12 (from alembic>=1.9.0->Flask-Migrate>=4.0->-r requirements.txt (line 5)) + Downloading typing_extensions-4.13.2-py3-none-any.whl.metadata (3.0 kB) +Collecting greenlet>=1 (from sqlalchemy>=2.0.16->Flask-SQLAlchemy>=3.0->-r requirements.txt (line 3)) + Downloading greenlet-3.2.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl.metadata (4.1 kB) +Collecting cffi>=1.12 (from cryptography->Authlib==1.5.2) + Downloading cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (1.5 kB) +Collecting pycparser (from cffi>=1.12->cryptography->Authlib==1.5.2) + Downloading pycparser-2.22-py3-none-any.whl.metadata (943 bytes) +Downloading authlib-1.5.2-py2.py3-none-any.whl (232 kB) +Downloading flask-2.3.3-py3-none-any.whl (96 kB) +Downloading flask_sqlalchemy-3.1.1-py3-none-any.whl (25 kB) +Downloading flask_wtf-1.2.2-py3-none-any.whl (12 kB) +Downloading Flask_Migrate-4.1.0-py3-none-any.whl (21 kB) +Downloading requests-2.32.3-py3-none-any.whl (64 kB) +Downloading python_dotenv-1.1.0-py3-none-any.whl (20 kB) +Downloading werkzeug-2.3.8-py3-none-any.whl (242 kB) +Downloading waitress-3.0.2-py3-none-any.whl (56 kB) +Downloading alembic-1.15.2-py3-none-any.whl (231 kB) +Downloading blinker-1.9.0-py3-none-any.whl (8.5 kB) +Downloading certifi-2025.1.31-py3-none-any.whl (166 kB) +Downloading charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (143 kB) +Downloading click-8.1.8-py3-none-any.whl (98 kB) +Downloading idna-3.10-py3-none-any.whl (70 kB) +Downloading itsdangerous-2.2.0-py3-none-any.whl (16 kB) +Downloading jinja2-3.1.6-py3-none-any.whl (134 kB) +Downloading MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (23 kB) +Downloading sqlalchemy-2.0.40-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (3.2 MB) + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.2/3.2 MB 3.3 MB/s eta 0:00:00 +Downloading urllib3-2.4.0-py3-none-any.whl (128 kB) +Downloading cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl (4.2 MB) + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 4.2/4.2 MB 1.8 MB/s eta 0:00:00 +Downloading wtforms-3.2.1-py3-none-any.whl (152 kB) +Downloading cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (467 kB) +Downloading greenlet-3.2.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl (583 kB) + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 583.9/583.9 kB 1.8 MB/s eta 0:00:00 +Downloading typing_extensions-4.13.2-py3-none-any.whl (45 kB) +Downloading mako-1.3.10-py3-none-any.whl (78 kB) +Downloading pycparser-2.22-py3-none-any.whl (117 kB) +Installing collected packages: waitress, urllib3, typing-extensions, python-dotenv, pycparser, MarkupSafe, itsdangerous, idna, greenlet, click, charset-normalizer, certifi, blinker, wtforms, werkzeug, sqlalchemy, requests, Mako, Jinja2, cffi, Flask, cryptography, alembic, Flask-WTF, Flask-SQLAlchemy, Authlib, Flask-Migrate +Successfully installed Authlib-1.5.2 Flask-2.3.3 Flask-Migrate-4.1.0 Flask-SQLAlchemy-3.1.1 Flask-WTF-1.2.2 Jinja2-3.1.6 Mako-1.3.10 MarkupSafe-3.0.2 alembic-1.15.2 blinker-1.9.0 certifi-2025.1.31 cffi-1.17.1 charset-normalizer-3.4.1 click-8.1.8 cryptography-44.0.2 greenlet-3.2.1 idna-3.10 itsdangerous-2.2.0 pycparser-2.22 python-dotenv-1.1.0 requests-2.32.3 sqlalchemy-2.0.40 typing-extensions-4.13.2 urllib3-2.4.0 waitress-3.0.2 werkzeug-2.3.8 wtforms-3.2.1 +WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9cd77fa --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +Flask==2.3.3 +Flask-SQLAlchemy==3.0.5 +Flask-WTF==1.1.1 +Flask-Migrate==4.0.7 +flasgger==v0.9.7b2 +Authlib==1.5.2 +requests==2.31.0 +python-dotenv==1.0.1 +werkzeug==2.3.8 +waitress==3.0.0 \ No newline at end of file diff --git a/smart_proxy.py b/smart_proxy.py new file mode 100644 index 0000000..9f8e83e --- /dev/null +++ b/smart_proxy.py @@ -0,0 +1,697 @@ +import time +import logging +import secrets +import requests +import base64 +import hashlib +from flask import Blueprint, request, session, url_for, render_template, redirect, jsonify +from flasgger import swag_from +from authlib.integrations.flask_oauth2 import AuthorizationServer, ResourceProtector +from authlib.integrations.sqla_oauth2 import ( + create_query_client_func, + create_save_token_func, +) +from authlib.oauth2.rfc6749 import grants +from authlib.oauth2.rfc6750 import BearerTokenValidator +from authlib.oauth2.rfc6749.errors import OAuth2Error + +# Import necessary components (designed for modularity) +try: + from models import database, RegisteredApp, OAuthToken, AuthorizationCode +except ImportError: + # Allow standalone usage or alternative model imports for FHIRFLARE integration + database = None + RegisteredApp = None + OAuthToken = None + AuthorizationCode = None + +# Configure logging +logger = logging.getLogger(__name__) + +# Define the Flask blueprint for SMART on FHIR routes +smart_blueprint = Blueprint('smart_proxy', __name__, template_folder='templates') + +# Initialize Authlib AuthorizationServer and ResourceProtector globally +authorization_server = AuthorizationServer() +require_oauth = ResourceProtector() + +# Helper functions for Authlib +def query_client(client_id): + """Query client details from the database for Authlib.""" + logger.debug(f"Querying client with identifier: {client_id}") + client = RegisteredApp.query.filter_by(client_id=client_id).first() + if client: + logger.debug(f"Found client: {client.app_name} (ClientID: {client.client_id})") + else: + logger.warning(f"Client not found for identifier: {client_id}") + return client + +def save_token(token, request): + """Save generated OAuth2 tokens to the database.""" + client = request.client + if not client: + logger.error("No client provided for token saving") + return + token_item = OAuthToken( + client_id=client.client_id, + token_type=token.get('token_type'), + access_token=token.get('access_token'), + refresh_token=token.get('refresh_token'), + scope=token.get('scope'), + issued_at=token.get('issued_at'), + expires_in=token.get('expires_in') + ) + database.session.add(token_item) + database.session.commit() + logger.info(f"Saved token for client '{client.client_id}' with scope: {token.get('scope')}") + +def normalize_scopes(scope_string): + """Convert plus signs or other delimiters to spaces for scopes.""" + if not scope_string: + return '' + return ' '.join(scope_string.replace('+', ' ').split()) + +# Dummy user class to satisfy Authlib's requirement +class DummyUser: + def get_user_id(self): + """Return a user ID for Authlib.""" + return "proxy_user" + +# Custom Authorization Code Grant implementation +class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): + """Customized Authorization Code Grant using database storage for codes.""" + + SUPPORTED_RESPONSE_TYPES = ['code'] # Explicitly allow 'code' + include_refresh_token = True # Ensure refresh tokens are included + TOKEN_DURATION = 3600 # Access token lifetime in seconds (1 hour) + REFRESH_TOKEN_DURATION = 86400 # Refresh token lifetime in seconds (1 day) + + def create_authorization_code(self, client, grant_user, request): + # Generate a secure random code + code = secrets.token_urlsafe(32) + logger.debug(f"Generated authorization code '{code[:6]}...' for client '{client.client_id}'") + auth_code = AuthorizationCode( + code=code, + client_id=client.client_id, + redirect_uri=request.args.get('redirect_uri'), + scope=request.args.get('scope'), + nonce=request.args.get('nonce'), + code_challenge=request.args.get('code_challenge'), + code_challenge_method=request.args.get('code_challenge_method'), + response_type=request.args.get('response_type', 'code'), + state=request.args.get('state'), + issued_at=int(time.time()), + expires_at=int(time.time()) + 600 # 10 minutes expiry + ) + database.session.add(auth_code) + database.session.commit() + logger.debug(f"Stored authorization code in database: {auth_code.code[:6]}...") + return code + + def parse_authorization_code(self, code, client): + logger.debug(f"Parsing authorization code '{code[:6]}...' for client '{client.client_id}'") + auth_code = AuthorizationCode.query.filter_by(code=code, client_id=client.client_id).first() + if not auth_code: + logger.warning(f"Authorization code '{code[:6]}...' not found for client '{client.client_id}'") + return None + if time.time() > auth_code.expires_at: + logger.warning(f"Authorization code '{code[:6]}...' has expired") + database.session.delete(auth_code) + database.session.commit() + return None + logger.debug(f"Parsed valid authorization code data for '{code[:6]}...': {auth_code.__dict__}") + return auth_code + + def query_authorization_code(self, code, client): + """Query the authorization code from the database.""" + logger.debug(f"Querying authorization code '{code[:6]}...' for client '{client.client_id}'") + auth_code = AuthorizationCode.query.filter_by(code=code, client_id=client.client_id).first() + if not auth_code: + logger.warning(f"Authorization code '{code[:6]}...' not found for client '{client.client_id}'") + return None + if time.time() > auth_code.expires_at: + logger.warning(f"Authorization code '{code[:6]}...' has expired") + database.session.delete(auth_code) + database.session.commit() + return None + logger.debug(f"Queried valid authorization code data for '{code[:6]}...': {auth_code.__dict__}") + return auth_code + + def delete_authorization_code(self, code): + logger.debug(f"Deleting authorization code '{code[:6]}...'") + auth_code = AuthorizationCode.query.filter_by(code=code).first() + if auth_code: + database.session.delete(auth_code) + database.session.commit() + logger.debug(f"Authorization code '{code[:6]}...' deleted from database") + + def authenticate_user(self, code_data): + logger.debug("Returning dummy user for authorization code (no user authentication in this proxy)") + return DummyUser() + + def validate_code_verifier(self, code_verifier, code_challenge, code_challenge_method): + """Validate the PKCE code verifier against the stored code challenge.""" + logger.debug(f"Validating PKCE code verifier. Code verifier: {code_verifier[:6]}..., Code challenge: {code_challenge[:6]}..., Method: {code_challenge_method}") + if not code_verifier or not code_challenge: + logger.warning("Missing code verifier or code challenge for PKCE validation") + return False + if code_challenge_method == 'plain': + expected_challenge = code_verifier + elif code_challenge_method == 'S256': + expected_challenge = base64.urlsafe_b64encode( + hashlib.sha256(code_verifier.encode('ascii')).digest() + ).decode('ascii').rstrip('=') + else: + logger.warning(f"Unsupported code challenge method: {code_challenge_method}") + return False + is_valid = expected_challenge == code_challenge + logger.debug(f"PKCE validation result: {is_valid}. Expected challenge: {expected_challenge[:6]}..., Provided challenge: {code_challenge[:6]}...") + return is_valid + + def create_token_response(self): + """Override to ensure proper token generation with client and grant_type.""" + # Validate the token request (sets self.client, self.request, etc.) + self.validate_token_request() + + # Get the authorization code from form data + code = self.request.form.get('code') + client = self.request.client + grant_type = self.request.form.get('grant_type') + + if not code: + raise OAuth2Error("invalid_request", "Missing 'code' parameter") + + if not grant_type: + raise OAuth2Error("invalid_request", "Missing 'grant_type' parameter") + + # Authenticate user + code_data = self.query_authorization_code(code, client) + if not code_data: + raise OAuth2Error("invalid_grant", "Authorization code not found or expired") + + user = self.authenticate_user(code_data) + if not user: + raise OAuth2Error("invalid_grant", "There is no 'user' for this code.") + + # Get the scope + scope = code_data.get_scope() + + # Generate the token with custom expiration durations + token = self.generate_token( + client, + grant_type, + user, + scope, + include_refresh_token=self.include_refresh_token, + code=code + ) + + # Delete the authorization code + self.delete_authorization_code(code) + + # Ensure token has correct expires_in and scope + body = { + "access_token": token["access_token"], + "token_type": token["token_type"], + "expires_in": self.TOKEN_DURATION, # Explicitly set to ensure correct value + "scope": scope if scope else "", # Use the scope directly + "refresh_token": token.get("refresh_token"), + } + headers = {"Content-Type": "application/json"} + return 200, body, headers + + def generate_token(self, client, grant_type, user=None, scope=None, include_refresh_token=True, **kwargs): + """Generate an access token with optional nonce.""" + logger.debug(f"Generating token with client: {client.client_id}, grant_type: {grant_type}, user: {user.get_user_id() if user else None}, scope: {scope}") + # Extract code from kwargs and remove it to avoid passing to parent + code = kwargs.pop('code', None) + + # Prepare token parameters + token = {} + token['access_token'] = secrets.token_urlsafe(32) + token['token_type'] = 'Bearer' + token['expires_in'] = self.TOKEN_DURATION + token['scope'] = scope if scope else "" + token['issued_at'] = int(time.time()) + + # Generate refresh token if enabled + if include_refresh_token and client.check_grant_type('refresh_token'): + token['refresh_token'] = secrets.token_urlsafe(32) + # Set refresh token expiration (issued_at + duration) + token['refresh_token_expires_in'] = self.REFRESH_TOKEN_DURATION + + # Handle nonce if code is provided + if code: + code_data = self.parse_authorization_code(code, client) + if code_data: + logger.info(f"Generating token. Nonce: {code_data.nonce}") + if code_data.nonce: + token['nonce'] = code_data.nonce + else: + logger.warning(f"Could not retrieve code data for code '{code[:6]}...' during token generation") + + logger.debug(f"Generated token for client '{client.client_id}'. Scope: {scope}") + return token + +# Configure Authlib +def configure_oauth(flask_app, db=None, registered_app_model=None, oauth_token_model=None, auth_code_model=None): + """Configure the Authlib AuthorizationServer with the Flask application.""" + global database, RegisteredApp, OAuthToken, AuthorizationCode + # Allow dependency injection for FHIRFLARE integration + database = db or database + RegisteredApp = registered_app_model or RegisteredApp + OAuthToken = oauth_token_model or OAuthToken + AuthorizationCode = auth_code_model or AuthorizationCode + + if not (database and RegisteredApp and OAuthToken and AuthorizationCode): + raise ValueError("Database and models must be provided for OAuth2 configuration") + + authorization_server.init_app( + flask_app, + query_client=query_client, + save_token=save_token + ) + authorization_server.register_grant(AuthorizationCodeGrant) + logger.info("Authlib OAuth2 server configured successfully") + +# SMART on FHIR OAuth2 routes +@smart_blueprint.route('/authorize', methods=['GET']) +@swag_from({ + 'tags': ['OAuth2'], + 'summary': 'Initiate SMART on FHIR authorization', + 'description': 'Redirects to the consent page for user authorization, then redirects to the client redirect URI with an authorization code.', + 'parameters': [ + { + 'name': 'response_type', + 'in': 'query', + 'type': 'string', + 'required': True, + 'description': 'Must be "code" for authorization code flow.', + 'enum': ['code'] + }, + { + 'name': 'client_id', + 'in': 'query', + 'type': 'string', + 'required': True, + 'description': 'Client ID of the registered application.' + }, + { + 'name': 'redirect_uri', + 'in': 'query', + 'type': 'string', + 'required': True, + 'description': 'Redirect URI where the authorization code will be sent.' + }, + { + 'name': 'scope', + 'in': 'query', + 'type': 'string', + 'required': True, + 'description': 'Space-separated list of scopes (e.g., "openid launch/patient").' + }, + { + 'name': 'state', + 'in': 'query', + 'type': 'string', + 'required': True, + 'description': 'Opaque value used to maintain state between the request and callback.' + }, + { + 'name': 'aud', + 'in': 'query', + 'type': 'string', + 'required': True, + 'description': 'Audience URL of the FHIR server.' + }, + { + 'name': 'code_challenge', + 'in': 'query', + 'type': 'string', + 'required': False, + 'description': 'PKCE code challenge (required for PKCE flow).' + }, + { + 'name': 'code_challenge_method', + 'in': 'query', + 'type': 'string', + 'required': False, + 'description': 'PKCE code challenge method (default: "plain").', + 'enum': ['plain', 'S256'] + } + ], + 'responses': { + '200': { + 'description': 'Renders the consent page for user authorization.' + }, + '400': { + 'description': 'Invalid request parameters (e.g., invalid client_id, redirect_uri, or scopes).' + } + } +}) +def authorize(): + """Handle SMART on FHIR authorization requests.""" + try: + # Log the incoming request parameters + logger.debug(f"Incoming request args: {request.args}") + + # Normalize scopes in the request + request_args = request.args.copy() + request_args['scope'] = normalize_scopes(request_args.get('scope', '')) + # Remove 'aud' as it's not needed for Authlib's OAuth flow + if 'aud' in request_args: + del request_args['aud'] + request.args = request_args + logger.debug(f"Normalized request args: {request.args}") + + # Validate client + client = query_client(request_args.get('client_id')) + if not client: + logger.error(f"Invalid client_id: {request_args.get('client_id')}") + return render_template('errors/auth_error.html', error="Invalid client_id"), 400 + + # Validate response_type + response_type = request_args.get('response_type') + logger.debug(f"Received response_type: {response_type}") + if not response_type or not client.check_response_type(response_type): + logger.error(f"Unsupported response_type: {response_type}") + return render_template('errors/auth_error.html', error=f"Unsupported response_type: {response_type}"), 400 + + # Validate redirect_uri + redirect_uri = request_args.get('redirect_uri') + if not redirect_uri or not client.check_redirect_uri(redirect_uri): + logger.error(f"Invalid redirect_uri: {redirect_uri} (Registered URIs: {client.redirect_uris})") + return render_template('errors/auth_error.html', error=f"Invalid redirect_uri: {redirect_uri}"), 400 + + # Validate scopes + requested_scopes = request_args.get('scope', '') + allowed_scopes = client.get_allowed_scope(requested_scopes) + if not allowed_scopes: + logger.error(f"No valid scopes provided: {requested_scopes} (Allowed scopes: {client.scopes})") + return render_template('errors/auth_error.html', error=f"No valid scopes provided: {requested_scopes}"), 400 + logger.debug(f"Allowed scopes after validation: {allowed_scopes}") + + # Update the request args with the normalized allowed scopes + request_args['scope'] = allowed_scopes + request.args = request_args + logger.debug(f"Updated request args with normalized scopes: {request.args}") + + # Store request details in session for consent + session['auth_request_params'] = request_args.to_dict() + session['auth_client_id'] = client.client_id + session['auth_scope'] = allowed_scopes + session['auth_response_type'] = response_type + session['auth_state'] = request_args.get('state') + session['auth_nonce'] = request_args.get('nonce') + + # Render consent page + logger.info(f"Rendering consent page for client: {client.app_name}") + return render_template( + 'auth/consent.html', + client=client, + scopes=session['auth_scope'].split(), + request_params=session['auth_request_params'] + ) + except OAuth2Error as error: + logger.error(f"OAuth2 error during authorization: {error.error} - {error.description}", exc_info=True) + return render_template('errors/auth_error.html', error=f"{error.error}: {error.description}"), error.status_code + except Exception as error: + logger.error(f"Unexpected error during authorization: {error}", exc_info=True) + return render_template('errors/auth_error.html', error="An unexpected error occurred during authorization"), 500 + +@smart_blueprint.route('/consent', methods=['POST']) +def handle_consent(): + """Handle user consent submission for SMART on FHIR authorization.""" + try: + # Log the entire form data for debugging + logger.debug(f"Form data received: {request.form}") + + # Retrieve grant details from session + request_params = session.pop('auth_request_params', None) + client_id = session.pop('auth_client_id', None) + scope = session.pop('auth_scope', None) + response_type = session.pop('auth_response_type', None) + state = session.pop('auth_state', None) + nonce = session.pop('auth_nonce', None) + + logger.debug(f"Consent route: client_id={client_id}, scope={scope}, response_type={response_type}, state={state}, nonce={nonce}") + + if not request_params or not client_id or not scope or not response_type: + logger.error("Consent handling failed: Missing session data") + return render_template('errors/auth_error.html', error="Session expired or invalid"), 400 + + # Validate client + client = query_client(client_id) + if not client: + logger.error(f"Invalid client_id: {client_id}") + return render_template('errors/auth_error.html', error="Invalid client_id"), 400 + + # Validate response_type + if not client.check_response_type(response_type): + logger.error(f"Unsupported response_type: {response_type} for client '{client_id}'") + return jsonify({"error": "unsupported_response_type"}), 400 + + # Validate redirect_uri + redirect_uri = request_params.get('redirect_uri') + if not redirect_uri or not client.check_redirect_uri(redirect_uri): + logger.error(f"Invalid redirect_uri in consent: {redirect_uri} (Registered URIs: {client.redirect_uris})") + return render_template('errors/auth_error.html', error=f"Invalid redirect_uri: {redirect_uri}"), 400 + + # Validate scopes + allowed_scopes = client.get_allowed_scope(scope) + if not allowed_scopes: + logger.error(f"No valid scopes in consent: {scope} (Allowed scopes: {client.scopes})") + return render_template('errors/auth_error.html', error=f"No valid scopes: {scope}"), 400 + logger.debug(f"Allowed scopes after validation in consent: {allowed_scopes}") + + consent_granted = request.form.get('consent') == 'allow' + logger.debug(f"Consent granted: {consent_granted}") + + if consent_granted: + logger.info(f"Consent granted for client '{client_id}' with scope '{scope}'") + # Reconstruct request for Authlib + request_args = request_params.copy() + request_args['scope'] = allowed_scopes # Use validated scopes in normalized order + if state: + request_args['state'] = state + if nonce: + request_args['nonce'] = nonce + request.args = request_args + request.form = {} # Ensure form is empty for GET-like request + request.method = 'GET' # Match /authorize + logger.debug(f"Reconstructed request args for token generation: {request.args}") + + # Access AuthorizationCodeGrant instance to generate the code + grant = AuthorizationCodeGrant(client, authorization_server) + auth_code = grant.create_authorization_code(client, None, request) + redirect_url = f"{redirect_uri}?code={auth_code}&state={state}" if state else f"{redirect_uri}?code={auth_code}" + logger.debug(f"Redirecting to: {redirect_url}") + return redirect(redirect_url) + else: + logger.info(f"Consent denied for client '{client_id}'") + redirect_url = f"{redirect_uri}?error=access_denied&error_description=The+resource+owner+or+authorization+server+denied+the+request" + if state: + redirect_url += f"&state={state}" + logger.debug(f"Redirecting to: {redirect_url}") + return redirect(redirect_url) + except OAuth2Error as error: + logger.error(f"OAuth2 error during consent handling: {error.error} - {error.description}", exc_info=True) + return render_template('errors/auth_error.html', error=f"{error.error}: {error.description}"), error.status_code + except Exception as error: + logger.error(f"Unexpected error during consent handling: {error}", exc_info=True) + return render_template('errors/auth_error.html', error="An unexpected error occurred during consent handling"), 500 + +@smart_blueprint.route('/token', methods=['POST']) +@swag_from({ + 'tags': ['OAuth2'], + 'summary': 'Exchange authorization code or refresh token for access token', + 'description': 'Exchanges an authorization code or refresh token for an access token and refresh token.', + 'parameters': [ + { + 'name': 'grant_type', + 'in': 'formData', + 'type': 'string', + 'required': True, + 'description': 'Type of grant ("authorization_code" or "refresh_token").', + 'enum': ['authorization_code', 'refresh_token'] + }, + { + 'name': 'code', + 'in': 'formData', + 'type': 'string', + 'required': False, + 'description': 'Authorization code received from /authorize (required for grant_type=authorization_code).' + }, + { + 'name': 'refresh_token', + 'in': 'formData', + 'type': 'string', + 'required': False, + 'description': 'Refresh token to obtain a new access token (required for grant_type=refresh_token).' + }, + { + 'name': 'redirect_uri', + 'in': 'formData', + 'type': 'string', + 'required': False, + 'description': 'The redirect URI used in the authorization request (required for grant_type=authorization_code).' + }, + { + 'name': 'client_id', + 'in': 'formData', + 'type': 'string', + 'required': True, + 'description': 'Client ID of the registered application.' + }, + { + 'name': 'client_secret', + 'in': 'formData', + 'type': 'string', + 'required': True, + 'description': 'Client secret of the registered application.' + }, + { + 'name': 'code_verifier', + 'in': 'formData', + 'type': 'string', + 'required': False, + 'description': 'PKCE code verifier (required if code_challenge was used in /authorize).' + } + ], + 'responses': { + '200': { + 'description': 'Access token response', + 'schema': { + 'type': 'object', + 'properties': { + 'access_token': {'type': 'string'}, + 'token_type': {'type': 'string', 'example': 'Bearer'}, + 'expires_in': {'type': 'integer', 'example': 3600}, + 'scope': {'type': 'string'}, + 'refresh_token': {'type': 'string'} + } + } + }, + '400': { + 'description': 'Invalid request (e.g., invalid code, refresh token, client credentials, redirect_uri, or code_verifier).' + } + } +}) +def issue_token(): + """Issue SMART on FHIR access tokens.""" + try: + logger.info("Token endpoint called") + # Log the incoming request parameters + logger.debug(f"Request form data: {request.form}") + logger.debug(f"Request headers: {dict(request.headers)}") + response = authorization_server.create_token_response() + logger.info(f"Token response created. Status code: {response.status_code}") + + if response.status_code == 200: + try: + token_data = response.get_json() + logger.debug(f"Token issued: AccessToken={token_data.get('access_token')[:6]}..., Type={token_data.get('token_type')}, Scope={token_data.get('scope')}, ExpiresIn={token_data.get('expires_in')}, RefreshToken={token_data.get('refresh_token')[:6] if token_data.get('refresh_token') else None}...") + return response + except Exception: + logger.warning("Could not parse token response JSON for logging") + return response + else: + try: + error_data = response.get_json() + logger.warning(f"Token request failed: Error={error_data.get('error')}, Description={error_data.get('error_description')}") + return jsonify(error_data), response.status_code + except Exception: + logger.warning(f"Token request failed with status {response.status_code}. Could not parse error response") + return jsonify({"error": "invalid_request", "error_description": "Failed to process token request"}), response.status_code + except Exception as error: + logger.error(f"Unexpected error during token issuance: {error}", exc_info=True) + return jsonify({"error": "server_error", "error_description": "An unexpected error occurred during token issuance"}), 500 + +@smart_blueprint.route('/revoke', methods=['POST']) +@swag_from({ + 'tags': ['OAuth2'], + 'summary': 'Revoke an access or refresh token', + 'description': 'Revokes an access or refresh token, rendering it invalid for future use.', + 'parameters': [ + { + 'name': 'token', + 'in': 'formData', + 'type': 'string', + 'required': True, + 'description': 'The access or refresh token to revoke.' + }, + { + 'name': 'client_id', + 'in': 'formData', + 'type': 'string', + 'required': True, + 'description': 'Client ID of the registered application.' + }, + { + 'name': 'client_secret', + 'in': 'formData', + 'type': 'string', + 'required': True, + 'description': 'Client secret of the registered application.' + } + ], + 'responses': { + '200': { + 'description': 'Token revoked successfully.', + 'schema': { + 'type': 'object', + 'properties': { + 'message': {'type': 'string', 'example': 'Token revoked successfully.'} + } + } + }, + '400': { + 'description': 'Invalid request (e.g., invalid client credentials or token).' + } + } +}) +def revoke_token(): + """Revoke an access or refresh token.""" + try: + logger.info("Token revocation endpoint called") + # Log the incoming request parameters + logger.debug(f"Request form data: {request.form}") + logger.debug(f"Request headers: {dict(request.headers)}") + + # Validate client credentials + client_id = request.form.get('client_id') + client_secret = request.form.get('client_secret') + token = request.form.get('token') + + if not client_id or not client_secret or not token: + logger.warning("Missing required parameters in token revocation request") + return jsonify({"error": "invalid_request", "error_description": "Missing required parameters"}), 400 + + client = query_client(client_id) + if not client: + logger.warning(f"Invalid client_id: {client_id}") + return jsonify({"error": "invalid_client", "error_description": "Invalid client_id"}), 400 + + if not client.check_client_secret(client_secret): + logger.warning(f"Invalid client_secret for client '{client_id}'") + return jsonify({"error": "invalid_client", "error_description": "Invalid client_secret"}), 400 + + # Revoke the token (delete it from the database) + # Check if the token is an access token or refresh token + token_entry = OAuthToken.query.filter( + (OAuthToken.access_token == token) | (OAuthToken.refresh_token == token) + ).first() + + if token_entry: + database.session.delete(token_entry) + database.session.commit() + logger.info(f"Token revoked successfully for client '{client_id}'") + return jsonify({"message": "Token revoked successfully."}), 200 + else: + logger.warning(f"Token not found: {token[:6]}...") + # RFC 7009 states that revocation endpoints should return 200 even if the token is not found + return jsonify({"message": "Token revoked successfully."}), 200 + + except Exception as error: + logger.error(f"Unexpected error during token revocation: {error}", exc_info=True) + return jsonify({"error": "server_error", "error_description": "An unexpected error occurred during token revocation"}), 500 \ No newline at end of file diff --git a/templates/about.html b/templates/about.html new file mode 100644 index 0000000..9dec6e1 --- /dev/null +++ b/templates/about.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block title %}About - {{ site_name }}{% endblock %} + +{% block content %} +
+
+
+

About {{ site_name }}

+
+
+

{{ site_name }} is a SMART on FHIR SSO proxy designed to facilitate secure app integration with FHIR servers.

+
Features
+
    +
  • Secure OAuth2 authorization flows with PKCE support.
  • +
  • App gallery for managing registered applications.
  • +
  • Proxy FHIR API requests to backend servers.
  • +
  • Light and dark theme support.
  • +
+

Built with Flask, Bootstrap, and Authlib. Learn more about SMART on FHIR at HL7 SMART on FHIR.

+ Back to Home +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/app_gallery/delete.html b/templates/app_gallery/delete.html new file mode 100644 index 0000000..28c686c --- /dev/null +++ b/templates/app_gallery/delete.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/app_gallery/edit.html b/templates/app_gallery/edit.html new file mode 100644 index 0000000..fe41277 --- /dev/null +++ b/templates/app_gallery/edit.html @@ -0,0 +1,101 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+
+

Edit Application: {{ app.app_name }}

+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.app_name.label(class="form-label") }} + {{ form.app_name(class="form-control " + ("is-invalid" if form.app_name.errors else ""), value=app.app_name) }} + {% if form.app_name.errors %} +
+ {% for error in form.app_name.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ {{ form.redirect_uris.label(class="form-label") }} + {{ form.redirect_uris(class="form-control " + ("is-invalid" if form.redirect_uris.errors else ""), value=app.redirect_uris) }} + {% if form.redirect_uris.errors %} +
+ {% for error in form.redirect_uris.errors %}{{ error }}{% endfor %} +
+ {% endif %} + {{ form.redirect_uris.description }} +
+ +
+ {{ form.scopes.label(class="form-label") }} + {{ form.scopes(class="form-control " + ("is-invalid" if form.scopes.errors else ""), value=app.scopes) }} + {% if form.scopes.errors %} +
+ {% for error in form.scopes.errors %}{{ error }}{% endfor %} +
+ {% endif %} + {{ form.scopes.description }} +
+ +
+ {{ form.logo_uri.label(class="form-label") }} + {{ form.logo_uri(class="form-control " + ("is-invalid" if form.logo_uri.errors else ""), value=app.logo_uri) }} + {% if form.logo_uri.errors %} +
+ {% for error in form.logo_uri.errors %}{{ error }}{% endfor %} +
+ {% endif %} + {{ form.logo_uri.description }} +
+ +
+ {{ form.contacts.label(class="form-label") }} + {{ form.contacts(class="form-control " + ("is-invalid" if form.contacts.errors else ""), value=app.contacts) }} + {% if form.contacts.errors %} +
+ {% for error in form.contacts.errors %}{{ error }}{% endfor %} +
+ {% endif %} + {{ form.contacts.description }} +
+ +
+ {{ form.tos_uri.label(class="form-label") }} + {{ form.tos_uri(class="form-control " + ("is-invalid" if form.tos_uri.errors else ""), value=app.tos_uri) }} + {% if form.tos_uri.errors %} +
+ {% for error in form.tos_uri.errors %}{{ error }}{% endfor %} +
+ {% endif %} + {{ form.tos_uri.description }} +
+ +
+ {{ form.policy_uri.label(class="form-label") }} + {{ form.policy_uri(class="form-control " + ("is-invalid" if form.policy_uri.errors else ""), value=app.policy_uri) }} + {% if form.policy_uri.errors %} +
+ {% for error in form.policy_uri.errors %}{{ error }}{% endfor %} +
+ {% endif %} + {{ form.policy_uri.description }} +
+ +
+ {{ form.submit(class="btn btn-primary btn-lg") }} + Cancel +
+
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/app_gallery/gallery.html b/templates/app_gallery/gallery.html new file mode 100644 index 0000000..79b64ba --- /dev/null +++ b/templates/app_gallery/gallery.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Registered Applications

+ + Register New App + +
+ + {% if apps %} +
+ {% for app in apps %} +
+
+ {% if app.logo_uri %} + {{ app.app_name }} Logo + {% else %} +
+ {# Placeholder Icon #} +
+ {% endif %} +
+
{{ app.app_name }}
+

+ Client ID: {{ app.client_id }}
+ {# Do NOT display client secret here #} + Registered: {{ app.date_registered.strftime('%Y-%m-%d %H:%M') if app.date_registered else 'N/A' }}
+ {% if app.last_updated %} + Updated: {{ app.last_updated.strftime('%Y-%m-%d %H:%M') }} + {% endif %} +

+
    +
  • + Redirect URIs: +
      + {% for uri in app.redirect_uris.split() %} +
    • {{ uri }}
    • + {% else %} +
    • None configured
    • + {% endfor %} +
    +
  • +
  • + Allowed Scopes: +
    + {% for scope in app.scopes.split() %} + {{ scope }} + {% else %} + None configured + {% endfor %} +
    +
  • + {% if app.contacts %} +
  • Contacts: {{ app.contacts }}
  • + {% endif %} +
+
+ Edit + Delete +
+
+
+
+ {% endfor %} +
+ {% else %} + + {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/app_gallery/register.html b/templates/app_gallery/register.html new file mode 100644 index 0000000..a879a77 --- /dev/null +++ b/templates/app_gallery/register.html @@ -0,0 +1,113 @@ +{% extends "base.html" %} +{# Optional: Import form helpers if you create _formhelpers.html #} +{# {% from "_formhelpers.html" import render_field %} #} + +{% block content %} +
+
+
+
+
+

Register New SMART Application

+
+
+

+ Register your application to use FHIRVINE as a SMART on FHIR SSO proxy. + A Client ID and Client Secret will be generated upon successful registration. +

+
+ {{ form.hidden_tag() }} {# Include CSRF token #} + + {# --- Application Name --- #} +
+ {{ form.app_name.label(class="form-label") }} + {{ form.app_name(class="form-control" + (" is-invalid" if form.app_name.errors else "")) }} + {% if form.app_name.errors %} +
+ {% for error in form.app_name.errors %}{{ error }}{% endfor %} +
+ {% endif %} {# Closes if form.app_name.errors #} +
+ + {# --- Redirect URIs --- #} +
+ {{ form.redirect_uris.label(class="form-label") }} ({{ form.redirect_uris.description }}) + {{ form.redirect_uris(class="form-control" + (" is-invalid" if form.redirect_uris.errors else ""), rows="3") }} + {% if form.redirect_uris.errors %} +
+ {% for error in form.redirect_uris.errors %}{{ error }}{% endfor %} +
+ {% endif %} {# Closes if form.redirect_uris.errors #} +
+ + {# --- Scopes --- #} +
+ {{ form.scopes.label(class="form-label") }} ({{ form.scopes.description }}) + {{ form.scopes(class="form-control" + (" is-invalid" if form.scopes.errors else ""), rows="4") }} + {% if form.scopes.errors %} +
+ {% for error in form.scopes.errors %}{{ error }}{% endfor %} +
+ {% endif %} {# Closes if form.scopes.errors #} +
+ +
+

Optional Information:

+ + {# --- Logo URI --- #} +
+ {{ form.logo_uri.label(class="form-label") }} ({{ form.logo_uri.description }}) + {{ form.logo_uri(class="form-control" + (" is-invalid" if form.logo_uri.errors else "")) }} + {% if form.logo_uri.errors %} +
+ {% for error in form.logo_uri.errors %}{{ error }}{% endfor %} +
+ {% endif %} {# Closes if form.logo_uri.errors #} +
+ + {# --- Contacts --- #} +
+ {{ form.contacts.label(class="form-label") }} ({{ form.contacts.description }}) + {{ form.contacts(class="form-control" + (" is-invalid" if form.contacts.errors else ""), rows="2") }} + {% if form.contacts.errors %} +
+ {% for error in form.contacts.errors %}{{ error }}{% endfor %} +
+ {% endif %} {# Closes if form.contacts.errors #} +
+ + {# --- Terms of Service URI --- #} +
+ {{ form.tos_uri.label(class="form-label") }} ({{ form.tos_uri.description }}) + {{ form.tos_uri(class="form-control" + (" is-invalid" if form.tos_uri.errors else "")) }} + {% if form.tos_uri.errors %} +
+ {% for error in form.tos_uri.errors %}{{ error }}{% endfor %} +
+ {% endif %} {# Closes if form.tos_uri.errors #} +
+ + {# --- Privacy Policy URI --- #} +
+ {{ form.policy_uri.label(class="form-label") }} ({{ form.policy_uri.description }}) + {{ form.policy_uri(class="form-control" + (" is-invalid" if form.policy_uri.errors else "")) }} + {% if form.policy_uri.errors %} +
+ {% for error in form.policy_uri.errors %}{{ error }}{% endfor %} +
+ {% endif %} {# Closes if form.policy_uri.errors #} +
+ + {# --- Submit Buttons --- #} +
+ {{ form.submit(class="btn btn-primary btn-lg") }} + Cancel +
+
{# Closes form tag #} +
{# Closes card-body #} +
{# Closes card #} +
{# Closes col #} +
{# Closes row #} +
{# Closes container #} +{% endblock %} {# Closes block content #} + diff --git a/templates/auth/consent.html b/templates/auth/consent.html new file mode 100644 index 0000000..b05a706 --- /dev/null +++ b/templates/auth/consent.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+
+

Authorize Application

+
+
+
+ {% if client.logo_uri %} + {{ client.app_name }} Logo + {% else %} + + {% endif %} +
{{ client.app_name }}
+

is requesting permission to access your data.

+
+ +

This application is requesting the following permissions (scopes):

+
    + {% for scope in scopes %} +
  • + + {{ scope }} + {# Add descriptions for common scopes later #} +
  • + {% else %} +
  • No specific scopes requested.
  • + {% endfor %} +
+ + {# Display other request parameters like launch context if available #} + {% if request_params.launch %} +
+ Launch Context: {{ request_params.launch }} +
+ {% endif %} + {% if request_params.aud %} +
+ Audience (FHIR Server): {{ request_params.aud }} +
+ {% endif %} + +
+ {# Include the auth_code_id as a hidden field #} + + {# We don't need CSRF here as the state is handled by Authlib #} +
+ + +
+
+
+ +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..e246143 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,388 @@ + + + + + + + + + + + {% if title %}{{ title }} - {% endif %}{{ site_name }} + + + + + +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + + {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+
+ + + + + + + + {% block scripts %}{% endblock %} + + \ No newline at end of file diff --git a/templates/consent.html b/templates/consent.html new file mode 100644 index 0000000..6596f77 --- /dev/null +++ b/templates/consent.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} + +{% block title %}Authorize {{ client.app_name }} - {{ site_name }}{% endblock %} + +{% block content %} +
+
+
+
+
+

Authorize Application

+
+
+

+ {{ client.app_name }} is requesting access to your data. +

+
Permissions Requested:
+
    + {% for scope in scopes %} +
  • + {{ scope }} +

    + {% if scope == "openid" %} + Access your identity information. + {% elif scope == "profile" %} + Access your user profile information. + {% elif scope == "launch" %} + Launch the application with context. + {% elif scope == "launch/patient" %} + Launch the application with patient context. + {% elif scope == "patient/*.read" %} + Read all patient data. + {% else %} + Custom scope permission. + {% endif %} +

    +
  • + {% endfor %} +
+
+
+ + +
+
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/errors/404.html b/templates/errors/404.html new file mode 100644 index 0000000..6d7d127 --- /dev/null +++ b/templates/errors/404.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/errors/500.html b/templates/errors/500.html new file mode 100644 index 0000000..d3ad511 --- /dev/null +++ b/templates/errors/500.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/errors/auth_error.html b/templates/errors/auth_error.html new file mode 100644 index 0000000..6ce12d5 --- /dev/null +++ b/templates/errors/auth_error.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+ + +
+
+
+{% endblock %} + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..7146c23 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% block content %} +
+

Welcome to {{ site_name }}

+
+

+ A modular SMART on FHIR Single Sign-On (SSO) Proxy. Register applications, test client launches, and manage connections. +

+ +
+
+ +{% endblock %} \ No newline at end of file diff --git a/templates/proxy_settings.html b/templates/proxy_settings.html new file mode 100644 index 0000000..607e999 --- /dev/null +++ b/templates/proxy_settings.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block title %}Proxy Settings - {{ site_name }}{% endblock %} + +{% block content %} +
+
+
+

Proxy Settings

+
+
+
FHIR Server URL
+

Current FHIR Server: {{ fhir_server_url }}

+

Editing the FHIR server URL requires environment variable changes. Please update FHIR_SERVER_URL in your .env file and restart the application.

+ Back to Home +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/security.html b/templates/security.html new file mode 100644 index 0000000..d761d37 --- /dev/null +++ b/templates/security.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block title %}Security Settings - {{ site_name }}{% endblock %} + +{% block content %} +
+
+
+

Security Settings

+
+
+
Security Features
+
    +
  • Client Secret Hashing: Enabled (uses PBKDF2-SHA256)
  • +
  • PKCE Support: Enabled (S256 method supported)
  • +
  • Token Expiration: Access tokens expire in 3600 seconds (1 hour)
  • +
+

Security settings are managed via application configuration. Contact the administrator to modify these settings.

+ Back to Home +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/server_endpoints.html b/templates/server_endpoints.html new file mode 100644 index 0000000..b40a1f1 --- /dev/null +++ b/templates/server_endpoints.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block title %}Server Endpoints - {{ site_name }}{% endblock %} + +{% block content %} +
+
+
+

Server Endpoints

+
+
+
Available Endpoints
+
    +
  • Authorization: /oauth2/authorize
  • +
  • Token: /oauth2/token
  • +
  • FHIR Proxy: /oauth2/proxy/<path>
  • +
+

View API Documentation

+ Back to Home +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/test_client.html b/templates/test_client.html new file mode 100644 index 0000000..b3a1a34 --- /dev/null +++ b/templates/test_client.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+
+

Test SMART Client Launch

+
+
+

+ Select a registered application to test its SMART on FHIR launch. +

+
+ {{ form.hidden_tag() }} + +
+ {{ form.client_id.label(class="form-label") }} + + {% if form.client_id.errors %} +
+ {% for error in form.client_id.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ {{ form.submit(class="btn btn-primary btn-lg") }} + Cancel +
+
+
+
+
+
+
+{% endblock %} \ No newline at end of file