diff --git a/.env b/.env new file mode 100644 index 0000000..e62ba57 --- /dev/null +++ b/.env @@ -0,0 +1,18 @@ +# .env file for FHIRVINE environment variables + +# Set the environment for Flask (development or production) +# Use 'production' for deployment, 'development' for local testing +FLASK_ENV=development + +# Generate a strong, random secret key for Flask sessions and security +# You can generate one using: python -c 'import secrets; print(secrets.token_hex(24))' +SECRET_KEY=replace_this_with_a_strong_random_secret_key + +# Optional: Override the default database URL if needed. +# The default in app.py points to /app/instance/fhirvine.db within the container. +# DATABASE_URL=sqlite:////app/instance/fhirvine.db + +# Optional: Add other environment-specific configurations here +# e.g., API_KEYS, external service URLs etc. + +FHIR_SERVER_URL=http://hapi.fhir.org/baseR4 \ No newline at end of file diff --git a/Dockerfile.minimal b/Dockerfile.minimal deleted file mode 100644 index bcc212a..0000000 --- a/Dockerfile.minimal +++ /dev/null @@ -1,24 +0,0 @@ -# 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/README.md b/README.md index 6386de0..8c0bd66 100644 --- a/README.md +++ b/README.md @@ -1 +1,119 @@ -# FHIREVINE-Smart-Proxy \ No newline at end of file +# FHIRVINE SMART Proxy + +## Overview + +FHIRVINE is a lightweight, performant SMART on FHIR proxy built with Flask, designed to facilitate seamless integration and testing of SMART on FHIR applications. It acts as an intermediary between SMART apps and FHIR servers, providing OAuth2 authentication, app registration, testing capabilities, and configuration options. Key features include: + +- **OAuth2 Authentication**: Supports SMART on FHIR OAuth2 flows for secure app authentication. +- **App Gallery**: Register, manage, and test SMART applications. +- **Testing Support**: Test client and server modes for simulating OAuth2 flows. +- **Configuration Menu**: Customize security settings, proxy settings, and server endpoints. +- **Modular Integration**: Designed to integrate with FHIRFLARE for extended functionality. +- **API Documentation**: Swagger UI for exploring available endpoints. + +FHIRVINE is ideal for developers building and testing SMART on FHIR applications, ensuring compliance with FHIR standards while providing a user-friendly interface for configuration and testing. + +## Prerequisites + +- **Docker**: Required for containerized deployment. +- **Python 3.11**: If running locally without Docker. +- **Dependencies**: Listed in `requirements.txt` (e.g., Flask, Flask-SQLAlchemy, Authlib, Flasgger). + +## Installation + +### Using Docker (Recommended) + +1. **Clone the Repository**: + ```bash + git clone + cd FHIRVINE + ``` + +2. **Set Up Environment Variables**: + - Copy the `.env.example` to `.env`: + ```bash + cp .env.example .env + ``` + - Edit `.env` with your settings: + ``` + FLASK_ENV=development + SECRET_KEY=your-secure-random-key + DATABASE_URL=sqlite:////app/instance/fhirvine.db + FHIR_SERVER_URL=http://hapi.fhir.org/baseR4 + ``` + +3. **Build and Run with Docker Compose**: + ```bash + docker-compose up -d --build + ``` + - The application will be available at `http://localhost:5001`. + +4. **Access the Application**: + - Open your browser and navigate to `http://localhost:5001`. + +### Local Installation + +1. **Clone the Repository**: + ```bash + git clone + cd FHIRVINE + ``` + +2. **Set Up a Virtual Environment**: + ```bash + python3 -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +3. **Install Dependencies**: + ```bash + pip install -r requirements.txt + ``` + +4. **Set Up Environment Variables**: + - Follow the same steps as above to create and edit the `.env` file. + +5. **Run Database Migrations**: + ```bash + flask db upgrade + ``` + +6. **Start the Application**: + ```bash + flask run --host=0.0.0.0 --port=5001 + ``` + - Access the application at `http://localhost:5001`. + +## Usage + +FHIRVINE provides a web interface and API endpoints for managing SMART on FHIR applications. Key functionalities include: + +- **App Gallery**: Register and manage SMART apps. +- **Test Client/Server**: Simulate OAuth2 flows for testing. +- **Configuration**: Adjust security, proxy, and server settings. +- **API Access**: Use the Swagger UI at `/apidocs` to explore endpoints. + +For detailed usage instructions, including all features, API endpoints, and use cases, refer to the [User Guide](USERGUIDE.md). + +## Project Structure + +- `app.py`: Main Flask application file. +- `models.py`: SQLAlchemy models for database tables. +- `smart_proxy.py`: SMART on FHIR proxy logic and OAuth2 handling. +- `forms.py`: WTForms for handling form data. +- `templates/`: HTML templates for the web UI. +- `static/`: Static files (CSS, JavaScript, images like `FHIRVINE.png`). +- `docker-compose.yml`: Docker Compose configuration. +- `Dockerfile`: Docker container configuration. + +## Contributing + +Contributions are welcome! Please fork the repository, create a new branch, and submit a pull request with your changes. Ensure your code follows PEP 8 standards and includes appropriate tests. + +## License + +This project is licensed under the MIT License. See the `LICENSE` file for details. + +## Contact + +For support or inquiries, contact the development team at `support@fhirvine.example.com`. \ No newline at end of file diff --git a/README_INTEGRATION.md b/README_INTEGRATION.md new file mode 100644 index 0000000..0fca0d0 --- /dev/null +++ b/README_INTEGRATION.md @@ -0,0 +1,72 @@ +# Integrating FHIRVINE with FHIRFLARE + +## Overview + +FHIRVINE can be integrated with FHIRFLARE (a Flask-based FHIR Implementation Guide toolkit) as a SMART on FHIR proxy module. This guide explains how to set up and integrate FHIRVINE with FHIRFLARE for seamless FHIR data access and validation. + +## Prerequisites + +- FHIRVINE and FHIRFLARE repositories cloned. +- Docker and Docker Compose installed. +- Both applications configured and running. + +## Setup + +### 1. Deploy FHIRVINE + +Follow the FHIRVINE proxy setup guide (see `README_PROXY.md`). Ensure it’s running at `http://localhost:5001`. + +### 2. Configure FHIRFLARE + +- Clone FHIRFLARE: + + ```bash + git clone https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit + cd FHIRFLARE-IG-Toolkit + ``` + +- Update `appsettings.json` or environment variables to point to FHIRVINE’s proxy: + + ```json + { + "FhirServerUrl": "http://localhost:5001/oauth2/proxy", + "AuthServerUrl": "http://localhost:5001/oauth2" + } + ``` + +### 3. Register FHIRFLARE as a SMART App in FHIRVINE + +- In FHIRVINE, go to `http://localhost:5001/app-gallery`. +- Register FHIRFLARE as a new app: + - Redirect URI: `http://localhost:8080/callback` (or FHIRFLARE’s callback URL). + - Scopes: `patient/*.read offline_access`. +- Note the Client ID and Secret. + +### 4. Update FHIRVINE Configuration + +- In FHIRVINE, go to `http://localhost:5001/configure/proxy-settings`. +- Set the upstream FHIR server to FHIRFLARE’s target FHIR server (e.g., `http://hapi.fhir.org/baseR4`). + +## Integration Steps + +### 1. Authenticate FHIRFLARE via FHIRVINE + +- In FHIRFLARE, initiate an OAuth2 flow using FHIRVINE’s `/oauth2/authorize` endpoint. +- Use the Client ID and Secret from FHIRVINE. +- After authorization, FHIRFLARE will receive an access token. + +### 2. Access FHIR Data + +- FHIRFLARE can now make FHIR requests through FHIRVINE’s proxy (e.g., `http://localhost:5001/oauth2/proxy/Patient`). +- The proxy will handle authentication and forward requests to the upstream FHIR server. + +### 3. Validate FHIR Resources + +- Use FHIRFLARE’s validation module to validate resources fetched via FHIRVINE’s proxy. +- Example: Fetch a Patient resource and validate it against an IG in FHIRFLARE. + +## Troubleshooting + +- **Auth Errors**: Ensure FHIRFLARE’s redirect URI matches the registered URI in FHIRVINE. +- **Proxy Errors**: Check FHIRVINE logs (`docker logs fhirvine_app`) and verify the upstream FHIR server URL. +- **Validation Issues**: Ensure FHIRFLARE’s IG package is correctly loaded. \ No newline at end of file diff --git a/README_PROXY.md b/README_PROXY.md new file mode 100644 index 0000000..5b98f2b --- /dev/null +++ b/README_PROXY.md @@ -0,0 +1,84 @@ +# Using and Setting Up the SMART on FHIR Proxy in FHIRVINE + +## Overview + +FHIRVINE provides a SMART on FHIR proxy to securely connect SMART apps to FHIR servers, supporting OAuth2 authentication and proxying requests. This guide explains how to set up and use the proxy function. + +## Prerequisites + +- Docker and Docker Compose installed. +- A FHIR server (e.g., `http://hapi.fhir.org/baseR4`). +- Python 3.11 (if running locally). + +## Setup + +### 1. Clone the Repository + +```bash +git clone +cd FHIRVINE +``` + +### 2. Configure Environment Variables + +Copy the `.env.example` to `.env` and update the values: + +```env +FLASK_ENV=development +SECRET_KEY=your-secure-random-key +DATABASE_URL=sqlite:////app/instance/fhirvine.db +FHIR_SERVER_URL=http://hapi.fhir.org/baseR4 +``` + +### 3. Run with Docker + +Build and start the application: + +```bash +docker-compose up -d --build +``` + +The proxy will be available at `http://localhost:5001`. + +### 4. (Optional) Run Locally + +If not using Docker: + +```bash +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +flask db upgrade +flask run --host=0.0.0.0 --port=5001 +``` + +## Using the Proxy + +### 1. Register a SMART App + +- Navigate to `http://localhost:5001/app-gallery`. +- Click "Register New App". +- Fill in details (e.g., Redirect URIs, Scopes) and submit to get a Client ID and Secret. + +### 2. Test the Proxy + +- Go to `http://localhost:5001/test-client`. +- Select your app’s Client ID, choose a response mode, and launch the test. +- Grant consent at `/oauth2/authorize`. +- The proxy will handle the OAuth2 flow and proxy FHIR requests to the upstream server (e.g., `/oauth2/proxy/Patient`). + +### 3. Configure the Proxy + +- Visit `http://localhost:5001/configure/proxy-settings` to set the FHIR server URL and proxy timeout. +- Save your settings to update the proxy configuration. + +## Key Endpoints + +- **Authorization**: `/oauth2/authorize` - Initiates the OAuth2 flow. +- **Token**: `/oauth2/token` - Exchanges code for an access token. +- **Proxy**: `/oauth2/proxy/` - Proxies FHIR requests with the access token. + +## Troubleshooting + +- **Logs**: Check logs with `docker logs fhirvine_app` for errors. +- **Database**: Ensure values are saved in `/app/instance/fhirvine.db`. \ No newline at end of file diff --git a/USERGUIDE.markdown b/USERGUIDE.markdown new file mode 100644 index 0000000..88b1af0 --- /dev/null +++ b/USERGUIDE.markdown @@ -0,0 +1,247 @@ +# FHIRVINE SMART Proxy User Guide + +## Introduction + +FHIRVINE is a SMART on FHIR proxy that simplifies the integration, testing, and configuration of SMART applications with FHIR servers. This guide provides a comprehensive overview of all features, API endpoints, and use cases to help developers and administrators effectively use FHIRVINE. + +## Features Overview + +### App Gallery +- **Purpose**: Register, view, edit, and delete SMART applications. +- **Access**: Navigate to `/app-gallery` from the homepage. +- **Functionality**: + - Register new apps with details like name, redirect URIs, scopes, and logo. + - Edit existing app details. + - Delete apps and associated tokens. + - Test apps using temporary registrations (24-hour expiry). + +### Test Client Mode +- **Purpose**: Simulate a SMART app client to test OAuth2 authentication flows. +- **Access**: Navigate to `/test-client`. +- **Functionality**: + - Select a registered app by Client ID. + - Choose response mode: inline (display response on page) or redirect (send to app’s redirect URI). + - Initiate an OAuth2 authorization flow, grant consent, and view the resulting authorization code or token. + +### Test Server Mode +- **Purpose**: Simulate a SMART on FHIR server to test app interactions. +- **Access**: Navigate to `/test-server`. +- **Functionality**: + - Input Client ID, Client Secret, Redirect URI, and scopes. + - Initiate an OAuth2 flow, grant consent, and receive an access token and refresh token. + - View token details and test token exchange. + +### Configuration Menu +- **Purpose**: Customize FHIRVINE’s behavior through a user-friendly interface. +- **Access**: Navigate to `/configure/security`, `/configure/proxy-settings`, or `/configure/server-endpoints`. +- **Submenus**: + - **Security Settings**: + - Configure access token duration (seconds). + - Configure refresh token duration (seconds). + - Set allowed OAuth2 scopes. + - **Proxy Settings**: + - Set the upstream FHIR server URL. + - Adjust the proxy request timeout (seconds). + - **Server Endpoints**: + - Define the metadata endpoint path. + - Define the capability statement endpoint path. + - Define the base resource endpoint path. + +### API Documentation +- **Purpose**: Explore and test FHIRVINE’s API endpoints. +- **Access**: Navigate to `/apidocs`. +- **Functionality**: + - View all available API endpoints with descriptions. + - Test endpoints directly from the Swagger UI. + +## Web Interface Usage + +### Homepage +- **URL**: `/` +- **Description**: The landing page provides an overview of FHIRVINE and navigation links to key features. +- **Actions**: + - Click "Explore Apps" to access the App Gallery. + - Click "Test Client" to test as a client. + - Click "Test Server" to test as a server. + - Click "Configure" to access the configuration menu. + +### Registering a New App +1. Go to `/app-gallery`. +2. Click "Register New App". +3. Fill in the form: + - **Application Name**: e.g., "My SMART App". + - **Redirect URIs**: Space-separated list (e.g., `https://myapp.com/callback`). + - **Allowed Scopes**: Space-separated (e.g., `patient/*.read openid`). + - **Logo URI**, **Contacts**, **Terms of Service URI**, **Privacy Policy URI**: Optional fields. +4. Submit the form to receive a Client ID and Client Secret. + +### Testing an App as a Client +1. Go to `/test-client`. +2. Select a Client ID from the dropdown (populated from registered apps). +3. Choose a response mode: + - **Inline**: View the response on the page. + - **Redirect**: Redirect to the app’s URI with the authorization code. +4. Click "Launch Test" to initiate the OAuth2 flow. +5. On the consent page (`/oauth2/authorize`), click "Allow" to proceed. +6. View the authorization code or token response. + +### Testing as a Server +1. Go to `/test-server`. +2. Enter: + - **Client ID**: From a registered app. + - **Client Secret**: From registration. + - **Redirect URI**: App’s callback URL. + - **Scopes**: Space-separated scopes to request. +3. Click "Initiate Authorization". +4. Grant consent on the `/oauth2/authorize` page. +5. Receive and view the access token, refresh token, and token request details. + +### Configuring Settings +- **Security Settings** (`/configure/security`): + - Adjust token durations and scopes, then save. +- **Proxy Settings** (`/configure/proxy-settings`): + - Set the FHIR server URL and timeout, then save. +- **Server Endpoints** (`/configure/server-endpoints`): + - Define endpoint paths, then save. + +## API Endpoints + +### Overview +FHIRVINE exposes several API endpoints under the `/oauth2` prefix, documented via Swagger UI at `/apidocs`. Below are the key endpoints and their use cases. + +### 1. Well-Known SMART Configuration +- **Endpoint**: `GET /.well-known/smart-configuration` +- **Description**: Provides the SMART on FHIR configuration for discovery. +- **Use Case**: Apps use this to discover FHIRVINE’s OAuth2 endpoints. +- **Response**: + ```json + { + "issuer": "http://localhost:5001/oauth2", + "authorization_endpoint": "http://localhost:5001/oauth2/authorize", + "token_endpoint": "http://localhost:5001/oauth2/token", + "revocation_endpoint": "http://localhost:5001/oauth2/revoke", + "introspection_endpoint": "http://localhost:5001/oauth2/introspect", + "scopes_supported": ["openid", "profile", "launch", "launch/patient", "patient/*.read", "offline_access"], + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"] + } + ``` + +### 2. Authorize +- **Endpoint**: `GET /oauth2/authorize` +- **Description**: Initiates the OAuth2 authorization flow. +- **Parameters**: + - `client_id`: Client ID of the app. + - `redirect_uri`: App’s callback URL. + - `scope`: Space-separated scopes (e.g., `patient/*.read openid`). + - `response_type`: Must be `code`. + - `state`: State parameter for CSRF protection. + - `aud`: Audience (FHIR server URL). + - `code_challenge`: PKCE code challenge. + - `code_challenge_method`: Must be `S256`. +- **Use Case**: Redirect users to authenticate and authorize the app. +- **Response**: Redirects to the `redirect_uri` with an authorization code (e.g., `?code=&state=`). + +### 3. Token +- **Endpoint**: `POST /oauth2/token` +- **Description**: Exchanges an authorization code for an access token. +- **Parameters**: + - `grant_type`: `authorization_code` or `refresh_token`. + - `code`: Authorization code (for `authorization_code` grant). + - `redirect_uri`: Must match the original redirect URI. + - `client_id`: Client ID. + - `client_secret`: Client Secret. + - `code_verifier`: PKCE code verifier. + - `refresh_token`: Refresh token (for `refresh_token` grant). +- **Use Case**: Obtain access and refresh tokens to interact with the FHIR server. +- **Response**: + ```json + { + "access_token": "", + "refresh_token": "", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "patient/*.read openid" + } + ``` + +### 4. Revoke +- **Endpoint**: `POST /oauth2/revoke` +- **Description**: Revokes an access or refresh token. +- **Parameters**: + - `token`: The token to revoke. + - `token_type_hint`: `access_token` or `refresh_token` (optional). +- **Use Case**: Invalidate tokens when they are no longer needed. +- **Response**: + ```json + {} + ``` + +### 5. Introspect +- **Endpoint**: `POST /oauth2/introspect` +- **Description**: Verifies the status of a token. +- **Parameters**: + - `token`: The token to introspect. +- **Use Case**: Check if a token is active or expired. +- **Response**: + ```json + { + "active": true, + "client_id": "", + "scope": "patient/*.read openid", + "exp": 1698777600 + } + ``` + +### 6. FHIR Proxy +- **Endpoint**: `GET|POST /oauth2/proxy/` +- **Description**: Proxies requests to the upstream FHIR server, adding the access token in the Authorization header. +- **Parameters**: + - `path`: The FHIR resource path (e.g., `Patient/123`). + - Headers: `Authorization: Bearer `. +- **Use Case**: Access FHIR resources securely through the proxy. +- **Response**: The FHIR server’s response (e.g., a Patient resource in JSON). + +## Use Cases + +### Use Case 1: Registering and Testing a SMART App +1. Register a new app in the App Gallery with redirect URI `https://myapp.com/callback` and scopes `patient/*.read openid`. +2. Use the Client ID and Secret in `/test-client` to simulate an OAuth2 flow. +3. Grant consent and receive an authorization code. +4. Exchange the code for an access token using the `/oauth2/token` endpoint. +5. Use the access token to query FHIR resources via `/oauth2/proxy/Patient`. + +### Use Case 2: Configuring Proxy Settings +1. Go to `/configure/proxy-settings`. +2. Change the FHIR server URL to a custom server (e.g., `https://myfhirserver.com`). +3. Set the proxy timeout to 30 seconds. +4. Save the settings and test the proxy by querying a resource. + +### Use Case 3: Revoking a Token +1. After testing an app, obtain a refresh token. +2. Use the `/oauth2/revoke` endpoint to invalidate the refresh token. +3. Verify the token’s status using `/oauth2/introspect`. + +## Troubleshooting + +### Common Issues +- **Configuration Values Not Displaying**: + - Check the logs for `get_config_value` debug messages to confirm database retrieval. + - Ensure the database (`/app/instance/fhirvine.db`) contains the correct values. +- **OAuth2 Flow Fails**: + - Verify the Client ID, Secret, and Redirect URI match the registered app. + - Check the FHIR server URL in `/configure/proxy-settings`. +- **Images Not Displaying**: + - Ensure `FHIRVINE.png` is in `/app/static/`. + - Check volume mounts in `docker-compose.yml` (e.g., `./static:/app/static`). + +### Logs +- Logs are available via Docker: + ```bash + docker logs fhirvine_app + ``` +- Look for `DEBUG` and `ERROR` messages to diagnose issues. + +## Support + +For additional help, contact `support@fhirvine.example.com` or open an issue on the project’s GitHub repository. \ No newline at end of file diff --git a/app.py b/app.py index 8ac3beb..f9d1fb2 100644 --- a/app.py +++ b/app.py @@ -4,44 +4,55 @@ import logging import secrets import base64 import hashlib -from flask import Flask, render_template, request, redirect, url_for, flash, session +import requests +import json +from flask import Flask, render_template, request, redirect, url_for, flash, session, current_app from flask_migrate import Migrate -from flasgger import Swagger +from flasgger import Swagger, swag_from from dotenv import load_dotenv from werkzeug.security import generate_password_hash +from datetime import datetime, timedelta +from forms import FlaskForm +from sqlalchemy.exc import OperationalError +from sqlalchemy import text +from wtforms import StringField, URLField, SubmitField +from wtforms.validators import DataRequired, URL -# 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 models import database, RegisteredApp, OAuthToken, AuthorizationCode, Configuration +from forms import RegisterAppForm, TestClientForm, SecurityConfigForm, ProxyConfigForm, EndpointConfigForm, ConsentForm 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'), + PROXY_TIMEOUT=10, + METADATA_ENDPOINT='/metadata', + CAPABILITY_ENDPOINT='/metadata', + RESOURCE_BASE_ENDPOINT='', + TOKEN_DURATION=3600, + REFRESH_TOKEN_DURATION=86400, + ALLOWED_SCOPES='openid profile launch launch/patient patient/*.read offline_access', + WTF_CSRF_ENABLED=True ) - # Ensure the instance folder exists + if not app.config['SECRET_KEY']: + logger.error("SECRET_KEY is not set. CSRF protection may fail.") + else: + logger.debug(f"SECRET_KEY is set: {app.config['SECRET_KEY'][:8]}...") + try: os.makedirs(app.instance_path, exist_ok=True) logger.info(f"Instance path created/verified: {app.instance_path}") @@ -49,12 +60,10 @@ def create_app(): 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={ + swagger_template = { "info": { "title": "FHIRVINE SMART on FHIR Proxy API", "version": "1.0", @@ -63,39 +72,94 @@ def create_app(): "host": "localhost:5001", "basePath": "/oauth2", "schemes": ["http"] - }) + } + swagger_config = { + "specs": [ + { + "endpoint": "apispec_1", + "route": "/apispec/1", + "rule_filter": lambda rule: True, + "model_filter": lambda tag: True, + } + ], + "uimode": "view", + "specs_route": "/apispec/", + "headers": [] + } + swagger = Swagger(app, template=swagger_template, config=swagger_config) - # Configure Authlib - configure_oauth(app) + configure_oauth(app, db=database, registered_app_model=RegisteredApp, oauth_token_model=OAuthToken, auth_code_model=AuthorizationCode) - # Register blueprints app.register_blueprint(smart_blueprint, url_prefix='/oauth2') - # Main application routes + def load_config_from_db(): + try: + configs = Configuration.query.all() + logger.debug(f"Loaded configs from database: {[{'key': c.key, 'value': c.value} for c in configs]}") + for config in configs: + try: + app.config[config.key] = int(config.value) if config.key in ['TOKEN_DURATION', 'PROXY_TIMEOUT', 'REFRESH_TOKEN_DURATION'] else config.value + except ValueError: + app.config[config.key] = config.value + except OperationalError as e: + logger.warning(f"Could not load configurations from database: {e}. Using default config values.") + + def get_config_value(key, default): + try: + # Ensure the session is fresh + database.session.expire_all() + config = Configuration.query.filter_by(key=key).first() + if config: + logger.debug(f"Retrieved {key} from database (exact match): {config.value}") + return int(config.value) if key in ['TOKEN_DURATION', 'PROXY_TIMEOUT', 'REFRESH_TOKEN_DURATION'] else config.value + # Try case-insensitive match + config = Configuration.query.filter(Configuration.key.ilike(key)).first() + if config: + logger.debug(f"Retrieved {key} from database (case-insensitive match): {config.value}") + return int(config.value) if key in ['TOKEN_DURATION', 'PROXY_TIMEOUT', 'REFRESH_TOKEN_DURATION'] else config.value + # Fallback: Direct database query + result = database.session.execute( + text("SELECT value FROM configurations WHERE key = :key"), + {"key": key} + ).fetchone() + if result: + value = result[0] + logger.debug(f"Retrieved {key} from database (direct query): {value}") + return int(value) if key in ['TOKEN_DURATION', 'PROXY_TIMEOUT', 'REFRESH_TOKEN_DURATION'] else value + logger.debug(f"No value found in database for {key}, using default: {default}") + except OperationalError as e: + logger.error(f"Database error while retrieving {key}: {e}") + except Exception as e: + logger.error(f"Unexpected error while retrieving {key}: {e}") + return default + + with app.app_context(): + load_config_from_db() + @app.route('/') def index(): return render_template('index.html') @app.route('/app-gallery') def app_gallery(): - """Display the list of registered applications.""" + form = FlaskForm() try: apps = RegisteredApp.query.order_by(RegisteredApp.app_name).all() + apps = [app for app in apps if app.is_test_app is None or app.is_test_app == False or app.test_app_expires_at > datetime.utcnow()] + logger.debug(f"App gallery apps retrieved: {[{'id': app.id, 'app_name': app.app_name, 'client_id': app.client_id, 'is_test_app': app.is_test_app, 'test_app_expires_at': app.test_app_expires_at} for app in apps]}") 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) + return render_template('app_gallery/gallery.html', apps=apps, form=form) @app.route('/register-app', methods=['GET', 'POST']) def register_app(): - """Handle registration of new applications.""" form = RegisterAppForm() + is_test = request.args.get('test', '0') == '1' 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, @@ -105,9 +169,10 @@ def create_app(): 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 + policy_uri=form.policy_uri.data or None, + is_test_app=is_test, + test_app_expires_at=datetime.utcnow() + timedelta(hours=24) if is_test else None ) - # Generate and hash client secret client_secret = secrets.token_urlsafe(40) new_app.set_client_secret(client_secret) database.session.add(new_app) @@ -115,17 +180,16 @@ def create_app(): 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})") + logger.info(f"Registered new app: {new_app.app_name} (ID: {new_app.id}, ClientID: {new_app.client_id}, IsTest: {new_app.is_test_app}, ExpiresAt: {new_app.test_app_expires_at}, LogoURI: {new_app.logo_uri}, TosURI: {new_app.tos_uri}, PolicyURI: {new_app.policy_uri})") 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) + return render_template('app_gallery/register.html', form=form, is_test=is_test) @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(): @@ -149,11 +213,10 @@ def create_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': + form = FlaskForm() + if form.validate_on_submit(): try: - # Delete associated tokens OAuthToken.query.filter_by(client_id=app.client_id).delete() database.session.delete(app) database.session.commit() @@ -164,28 +227,28 @@ def create_app(): 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) + return render_template('app_gallery/delete.html', app=app, form=form) @app.route('/test-client', methods=['GET', 'POST']) def test_client(): - """Handle testing of registered SMART app launches.""" form = TestClientForm() + response_data = None + response_mode = session.get('response_mode', 'inline') + logger.debug(f"Session contents before form submission: {session}") 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 + response_mode = form.response_mode.data if hasattr(form, 'response_mode') and form.response_mode.data else 'inline' + session['response_mode'] = response_mode 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', @@ -194,43 +257,279 @@ def create_app(): scope=scopes, response_type='code', state='test_state_123', - aud='http://hapi.fhir.org/baseR4/metadata', + aud=current_app.config['FHIR_SERVER_URL'] + current_app.config['METADATA_ENDPOINT'], 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}") + session['post_auth_redirect'] = redirect_uri return redirect(auth_url) - apps = RegisteredApp.query.all() - return render_template('test_client.html', form=form, apps=apps) + if 'code' in request.args and 'state' in request.args: + code = request.args.get('code') + state = request.args.get('state') + redirect_uri = session.get('post_auth_redirect') + if redirect_uri: + if response_mode == 'redirect': + redirect_url = f"{redirect_uri}?code={code}&state={state}" + session.pop('post_auth_redirect', None) + session.pop('response_mode', None) + return redirect(redirect_url) + else: + try: + response = requests.get(redirect_uri, params={'code': code, 'state': state}) + response.raise_for_status() + response_data = response.json() + logger.debug(f"Received response from redirect_uri: {response_data}") + except requests.RequestException as e: + logger.error(f"Error fetching response from redirect_uri: {e}") + flash(f"Error fetching response from redirect_uri: {e}", "error") + finally: + session.pop('post_auth_redirect', None) + session.pop('response_mode', None) + try: + apps = RegisteredApp.query.filter( + (RegisteredApp.is_test_app == False) | + (RegisteredApp.is_test_app.is_(None)) | + (RegisteredApp.test_app_expires_at > datetime.utcnow()) + ).all() + logger.debug(f"Test client apps retrieved: {[{'id': app.id, 'app_name': app.app_name, 'client_id': app.client_id, 'is_test_app': app.is_test_app, 'test_app_expires_at': app.test_app_expires_at} for app in apps]}") + except Exception as e: + logger.error(f"Error fetching apps for test client: {e}", exc_info=True) + flash("Could not load applications. Please try again later.", "error") + apps = [] + return render_template('test_client.html', form=form, apps=apps, response_data=response_data, response_mode=response_mode) - @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('/test-server', methods=['GET', 'POST']) + def test_server(): + class ServerTestForm(FlaskForm): + client_id = StringField('Client ID', validators=[DataRequired()]) + client_secret = StringField('Client Secret', validators=[DataRequired()]) + redirect_uri = URLField('Redirect URI', validators=[DataRequired(), URL()]) + scopes = StringField('Scopes (Space-separated)', validators=[DataRequired()], default='openid profile launch launch/patient patient/*.read offline_access') + submit = SubmitField('Initiate Authorization') - @app.route('/configure/server-endpoints') - def server_endpoints(): - """Display server endpoints page.""" - return render_template('configure/server_endpoints.html') + form = ServerTestForm() + response_data = None + if form.validate_on_submit(): + try: + client_id = form.client_id.data + client_secret = form.client_secret.data + redirect_uri = form.redirect_uri.data + scopes = ' '.join(set(form.scopes.data.split())) + code_verifier = secrets.token_urlsafe(32) + code_challenge = base64.urlsafe_b64encode( + hashlib.sha256(code_verifier.encode('ascii')).digest() + ).decode('ascii').rstrip('=') + session['server_test_code_verifier'] = code_verifier + session['server_test_client_secret'] = client_secret + auth_url = url_for( + 'smart_proxy.authorize', + client_id=client_id, + redirect_uri=redirect_uri, + scope=scopes, + response_type='code', + state='server_test_state', + aud=current_app.config['FHIR_SERVER_URL'] + current_app.config['METADATA_ENDPOINT'], + code_challenge=code_challenge, + code_challenge_method='S256', + _external=True + ) + session['server_test_redirect_uri'] = redirect_uri + return redirect(auth_url) + except Exception as e: + flash(f"Error initiating authorization: {e}", "error") + logger.error(f"Error in test-server: {e}", exc_info=True) + if 'code' in request.args and 'state' in request.args and request.args.get('state') == 'server_test_state': + code = request.args.get('code') + redirect_uri = session.get('server_test_redirect_uri') + client_secret = session.get('server_test_client_secret') + code_verifier = session.get('server_test_code_verifier') + if redirect_uri and client_secret and code_verifier: + token_url = url_for('smart_proxy.issue_token', _external=True) + token_data = { + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': redirect_uri, + 'client_id': form.client_id.data, + 'client_secret': client_secret, + 'code_verifier': code_verifier + } + try: + response = requests.post(token_url, data=token_data) + response.raise_for_status() + response_data = response.json() + response_data['token_request'] = token_data + logger.debug(f"Token response: {response_data}") + except requests.RequestException as e: + flash(f"Error exchanging code for token: {e}", "error") + logger.error(f"Error exchanging code for token: {e}", exc_info=True) + finally: + session.pop('server_test_redirect_uri', None) + session.pop('server_test_client_secret', None) + session.pop('server_test_code_verifier', None) + return render_template('test_server.html', form=form, response_data=response_data) - @app.route('/configure/security') + @app.route('/configure/security', methods=['GET', 'POST']) def security_settings(): - """Display security settings page.""" - return render_template('configure/security.html') + token_duration = get_config_value('TOKEN_DURATION', 3600) + refresh_token_duration = get_config_value('REFRESH_TOKEN_DURATION', 86400) + allowed_scopes = get_config_value('ALLOWED_SCOPES', 'openid profile launch launch/patient patient/*.read offline_access') + form = SecurityConfigForm() + form_data = { + 'token_duration': token_duration, + 'refresh_token_duration': refresh_token_duration, + 'allowed_scopes': allowed_scopes + } + logger.debug(f"Security form data: {form_data}") + form.process(data=form_data) + # Fallback: Directly set form field values + form.token_duration.data = token_duration + form.refresh_token_duration.data = refresh_token_duration + form.allowed_scopes.data = allowed_scopes + logger.debug(f"Security form fields after process: {list(form._fields.keys())}") + logger.debug(f"Security form values before render: token_duration={form.token_duration.data}, refresh_token_duration={form.refresh_token_duration.data}, allowed_scopes={form.allowed_scopes.data}") + if form.validate_on_submit(): + try: + configs = [ + Configuration(key='TOKEN_DURATION', value=str(form.token_duration.data), description='Access token duration in seconds'), + Configuration(key='REFRESH_TOKEN_DURATION', value=str(form.refresh_token_duration.data), description='Refresh token duration in seconds'), + Configuration(key='ALLOWED_SCOPES', value=form.allowed_scopes.data.strip(), description='Allowed OAuth2 scopes') + ] + for config in configs: + existing = Configuration.query.filter_by(key=config.key).first() + if existing: + existing.value = config.value + existing.description = config.description + else: + database.session.add(config) + database.session.commit() + load_config_from_db() + flash("Security settings updated successfully!", "success") + return redirect(url_for('security_settings')) + except Exception as e: + database.session.rollback() + logger.error(f"Error updating security settings: {e}", exc_info=True) + flash(f"Error updating security settings: {e}", "error") + return render_template('configure/security.html', form=form) + + @app.route('/configure/proxy-settings', methods=['GET', 'POST']) + def proxy_settings(): + fhir_server_url = get_config_value('FHIR_SERVER_URL', 'http://hapi.fhir.org/baseR4') + proxy_timeout = get_config_value('PROXY_TIMEOUT', 10) + form = ProxyConfigForm() + form_data = { + 'fhir_server_url': fhir_server_url, + 'proxy_timeout': proxy_timeout + } + logger.debug(f"Proxy settings form data: {form_data}") + form.process(data=form_data) + # Fallback: Directly set form field values + form.fhir_server_url.data = fhir_server_url + form.proxy_timeout.data = proxy_timeout + logger.debug(f"Proxy settings form fields after process: {list(form._fields.keys())}") + logger.debug(f"Proxy settings form values before render: fhir_server_url={form.fhir_server_url.data}, proxy_timeout={form.proxy_timeout.data}") + if form.validate_on_submit(): + try: + configs = [ + Configuration(key='FHIR_SERVER_URL', value=form.fhir_server_url.data.strip(), description='Upstream FHIR server URL'), + Configuration(key='PROXY_TIMEOUT', value=str(form.proxy_timeout.data), description='Proxy request timeout in seconds') + ] + for config in configs: + existing = Configuration.query.filter_by(key=config.key).first() + if existing: + existing.value = config.value + existing.description = config.description + else: + database.session.add(config) + database.session.commit() + load_config_from_db() + flash("Proxy settings updated successfully!", "success") + return redirect(url_for('proxy_settings')) + except Exception as e: + database.session.rollback() + logger.error(f"Error updating proxy settings: {e}", exc_info=True) + flash(f"Error updating proxy settings: {e}", "error") + return render_template('configure/proxy_settings.html', form=form, fhir_server_url=app.config['FHIR_SERVER_URL']) + + @app.route('/configure/server-endpoints', methods=['GET', 'POST']) + def server_endpoints(): + metadata_endpoint = get_config_value('METADATA_ENDPOINT', '/metadata') + capability_endpoint = get_config_value('CAPABILITY_ENDPOINT', '/metadata') + resource_base_endpoint = get_config_value('RESOURCE_BASE_ENDPOINT', '') + form = EndpointConfigForm() + form_data = { + 'metadata_endpoint': metadata_endpoint, + 'capability_endpoint': capability_endpoint, + 'resource_base_endpoint': resource_base_endpoint + } + logger.debug(f"Server endpoints form data: {form_data}") + form.process(data=form_data) + # Fallback: Directly set form field values + form.metadata_endpoint.data = metadata_endpoint + form.capability_endpoint.data = capability_endpoint + form.resource_base_endpoint.data = resource_base_endpoint + logger.debug(f"Server endpoints form fields after process: {list(form._fields.keys())}") + logger.debug(f"Server endpoints form values before render: metadata_endpoint={form.metadata_endpoint.data}, capability_endpoint={form.capability_endpoint.data}, resource_base_endpoint={form.resource_base_endpoint.data}") + if form.validate_on_submit(): + try: + configs = [ + Configuration( + key='METADATA_ENDPOINT', + value=form.metadata_endpoint.data.strip(), + description='FHIR server metadata endpoint' + ), + Configuration( + key='CAPABILITY_ENDPOINT', + value=form.capability_endpoint.data.strip(), + description='FHIR server capability statement endpoint' + ), + Configuration( + key='RESOURCE_BASE_ENDPOINT', + value=form.resource_base_endpoint.data.strip(), + description='Base path for FHIR resources' + ) + ] + for config in configs: + existing = Configuration.query.filter_by(key=config.key).first() + if existing: + existing.value = config.value + existing.description = config.description + else: + database.session.add(config) + database.session.commit() + load_config_from_db() + flash("Endpoint settings updated successfully!", "success") + return redirect(url_for('server_endpoints')) + except Exception as e: + database.session.rollback() + logger.error(f"Error updating endpoint settings: {e}", exc_info=True) + flash(f"Error updating endpoint settings: {e}", "error") + return render_template('configure/server_endpoints.html', form=form) + + @app.route('/api-docs') + @swag_from({ + 'tags': ['Documentation'], + 'summary': 'API Documentation', + 'description': 'Swagger UI for FHIRVINE API documentation.', + 'responses': { + '200': { + 'description': 'Renders the Swagger UI documentation page.' + } + } + }) + def custom_apidocs(): + return render_template('swagger-ui.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 diff --git a/docker-compose.yml b/docker-compose.yml index 12c9ba5..72fe468 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,25 +2,21 @@ services: fhirvine: - build: . # Build the image from the Dockerfile in the current directory - container_name: fhirvine_app # Optional: Assign a specific name + build: . + container_name: fhirvine_app ports: - - "5001:5001" # Map host port 5001 to container port 5001 (where Waitress listens) + - "5001:5001" 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 + - fhirvine_migrations:/app/migrations 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 + - FLASK_ENV=${FLASK_ENV:-development} env_file: - - .env # Load variables from .env file - restart: unless-stopped # Optional: Restart policy + - .env + command: > + sh -c "flask db upgrade && waitress-serve --host=0.0.0.0 --port=5001 --call 'app:create_app'" + restart: unless-stopped -# Define the named volumes volumes: fhirvine_instance: - fhirvine_migrations: # <-- ADDED THIS LINE - + fhirvine_migrations: \ No newline at end of file diff --git a/forms.py b/forms.py index 36a114f..d8fee6e 100644 --- a/forms.py +++ b/forms.py @@ -1,9 +1,8 @@ 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 +from wtforms import StringField, TextAreaField, URLField, SubmitField, HiddenField, IntegerField, RadioField +from wtforms.validators import DataRequired, Length, Optional, URL, Regexp, ValidationError, NumberRange -# Custom validator for space-separated URIs def validate_uris(form, field): uris = field.data.split() for uri in uris: @@ -12,16 +11,19 @@ def validate_uris(form, field): 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\/\.\*\-]+$' + 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 / . * -') + raise ValidationError(f'Invalid scope format: "{scope}". Allowed characters: a-z A-Z 0-9 / . * _ -') + +def validate_optional_url(form, field): + if field.data and field.data.strip(): + url_validator = URL(require_tld=False) + url_validator(form, field) class RegisterAppForm(FlaskForm): - """Form for registering a new SMART application.""" app_name = StringField( 'Application Name', validators=[DataRequired(), Length(min=3, max=100)] @@ -29,39 +31,108 @@ class RegisterAppForm(FlaskForm): 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' + description='Enter one or more valid redirect URIs, separated by spaces.' ) 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.' + description='Enter the scopes this application is allowed to request, separated by spaces.' ) - logo_uri = URLField( + logo_uri = StringField( 'Logo URI (Optional)', - validators=[Optional(), URL()], + validators=[Optional(strip_whitespace=True), validate_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.' + validators=[Optional(strip_whitespace=True)], + description='Contact email addresses or URLs, separated by spaces.' ) - tos_uri = URLField( + tos_uri = StringField( 'Terms of Service URI (Optional)', - validators=[Optional(), URL()], + validators=[Optional(strip_whitespace=True), validate_optional_url], description='Link to the application\'s Terms of Service.' ) - policy_uri = URLField( + policy_uri = StringField( 'Privacy Policy URI (Optional)', - validators=[Optional(), URL()], + validators=[Optional(strip_whitespace=True), validate_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 + response_mode = RadioField( + 'Response Mode', + choices=[ + ('inline', 'Display Response Inline'), + ('redirect', 'Redirect to URL') + ], + default='inline' + ) + submit = SubmitField('Launch Test') + +class ConsentForm(FlaskForm): + consent = HiddenField('Consent', validators=[DataRequired()]) + submit_allow = SubmitField('Allow') + submit_deny = SubmitField('Deny') + +class SecurityConfigForm(FlaskForm): + token_duration = IntegerField( + 'Access Token Duration (seconds)', + validators=[DataRequired(), NumberRange(min=300, max=86400)], + default=3600, + description='Duration for access tokens (300 to 86400 seconds).' + ) + refresh_token_duration = IntegerField( + 'Refresh Token Duration (seconds)', + validators=[DataRequired(), NumberRange(min=3600, max=604800)], + default=86400, + description='Duration for refresh tokens (3600 to 604800 seconds).' + ) + allowed_scopes = TextAreaField( + 'Allowed Scopes (Space-separated)', + validators=[DataRequired(), validate_scopes], + default='openid profile launch launch/patient patient/*.read offline_access', + description='Scopes allowed for all applications.' + ) + submit = SubmitField('Save Security Settings') + +class ProxyConfigForm(FlaskForm): + fhir_server_url = URLField( + 'FHIR Server URL', + validators=[DataRequired(), URL()], + description='Base URL of the upstream FHIR server.' + ) + proxy_timeout = IntegerField( + 'Proxy Timeout (seconds)', + validators=[DataRequired(), NumberRange(min=5, max=60)], + default=10, + description='Timeout for proxy requests (5 to 60 seconds).' + ) + submit = SubmitField('Save Proxy Settings') + +class EndpointConfigForm(FlaskForm): + metadata_endpoint = StringField( + 'Metadata Endpoint', + validators=[DataRequired(), Length(min=1, max=255)], + default='/metadata', + description='Relative path to the FHIR server metadata endpoint (e.g., /metadata).' + ) + capability_endpoint = StringField( + 'Capability Statement Endpoint', + validators=[DataRequired(), Length(min=1, max=255)], + default='/metadata', + description='Relative path to the FHIR server capability statement (e.g., /metadata).' + ) + resource_base_endpoint = StringField( + 'Resource Base Endpoint', + validators=[DataRequired(), Length(min=1, max=255)], + default='', + description='Base path for FHIR resources (e.g., /baseR4).' + ) + submit = SubmitField('Save Endpoint Settings') \ No newline at end of file diff --git a/minimal_test.py b/minimal_test.py deleted file mode 100644 index 567203e..0000000 --- a/minimal_test.py +++ /dev/null @@ -1,138 +0,0 @@ -# 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 index 0310357..d9979ae 100644 --- a/models.py +++ b/models.py @@ -1,5 +1,6 @@ from flask_sqlalchemy import SQLAlchemy from werkzeug.security import generate_password_hash, check_password_hash +from datetime import datetime database = SQLAlchemy() @@ -16,10 +17,11 @@ class RegisteredApp(database.Model): 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) + date_registered = database.Column(database.DateTime, default=datetime.utcnow) + last_updated = database.Column(database.DateTime, onupdate=datetime.utcnow) + is_test_app = database.Column(database.Boolean, default=False) + test_app_expires_at = database.Column(database.DateTime) - # Authlib ClientMixin methods def get_client_id(self): return self.client_id @@ -33,12 +35,9 @@ class RegisteredApp(database.Model): 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): @@ -47,11 +46,9 @@ class RegisteredApp(database.Model): 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): @@ -80,11 +77,16 @@ class AuthorizationCode(database.Model): 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 + return self.scope + +class Configuration(database.Model): + __tablename__ = 'configurations' + id = database.Column(database.Integer, primary_key=True) + key = database.Column(database.String(100), unique=True, nullable=False) + value = database.Column(database.Text, nullable=False) + description = database.Column(database.Text) + last_updated = database.Column(database.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) \ No newline at end of file diff --git a/smart_proxy.py b/smart_proxy.py index 9f8e83e..c09a05d 100644 --- a/smart_proxy.py +++ b/smart_proxy.py @@ -4,40 +4,29 @@ import secrets import requests import base64 import hashlib -from flask import Blueprint, request, session, url_for, render_template, redirect, jsonify +from flask import Blueprint, request, session, url_for, render_template, redirect, jsonify, current_app 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.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 +from werkzeug.http import is_hop_by_hop_header +from forms import ConsentForm -# 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 +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: @@ -47,7 +36,6 @@ def query_client(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") @@ -66,28 +54,19 @@ def save_token(token, request): 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) + SUPPORTED_RESPONSE_TYPES = ['code'] + include_refresh_token = True 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( @@ -101,7 +80,7 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): 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 + expires_at=int(time.time()) + 600 ) database.session.add(auth_code) database.session.commit() @@ -123,11 +102,10 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): 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}'") + logger.warning(f"Authorization code '{code[:6]}...' not found for client '{client_id}'") return None if time.time() > auth_code.expires_at: logger.warning(f"Authorization code '{code[:6]}...' has expired") @@ -146,14 +124,13 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): 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)") + logger.debug("Returning dummy user for authorization code") 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") + logger.warning("Missing code verifier or code challenge") return False if code_challenge_method == 'plain': expected_challenge = code_verifier @@ -165,38 +142,25 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): 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]}...") + logger.debug(f"PKCE validation result: {is_valid}") 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 + raise OAuth2Error("invalid_grant", "No user for this code") scope = code_data.get_scope() - - # Generate the token with custom expiration durations token = self.generate_token( client, grant_type, @@ -205,493 +169,344 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): 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 + "expires_in": current_app.config['TOKEN_DURATION'], + "scope": scope if scope else "", "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 + logger.debug(f"Generating token for client: {client.client_id}, grant_type: {grant_type}") 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['expires_in'] = current_app.config['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 + token['refresh_token_expires_in'] = current_app.config['REFRESH_TOKEN_DURATION'] 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") - + if code_data and code_data.nonce: + token['nonce'] = code_data.nonce 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") - + raise ValueError("Database and models must be provided") 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") + logger.info("Authlib OAuth2 server configured") + +@smart_blueprint.route('/.well-known/smart-configuration', methods=['GET']) +@swag_from({ + 'tags': ['OAuth2'], + 'summary': 'SMART on FHIR Configuration', + 'description': 'Returns the SMART on FHIR configuration.', + 'responses': { + '200': { + 'description': 'SMART configuration', + 'schema': { + 'type': 'object', + 'properties': { + 'issuer': {'type': 'string'}, + 'authorization_endpoint': {'type': 'string'}, + 'token_endpoint': {'type': 'string'}, + 'revocation_endpoint': {'type': 'string'}, + 'introspection_endpoint': {'type': 'string'}, + 'scopes_supported': {'type': 'array', 'items': {'type': 'string'}}, + 'response_types_supported': {'type': 'array', 'items': {'type': 'string'}}, + 'grant_types_supported': {'type': 'array', 'items': {'type': 'string'}}, + 'code_challenge_methods_supported': {'type': 'array', 'items': {'type': 'string'}} + } + } + } + } +}) +def smart_configuration(): + config = { + "issuer": request.url_root.rstrip('/'), + "authorization_endpoint": url_for('smart_proxy.authorize', _external=True), + "token_endpoint": url_for('smart_proxy.issue_token', _external=True), + "revocation_endpoint": url_for('smart_proxy.revoke_token', _external=True), + "introspection_endpoint": url_for('smart_proxy.introspect_token', _external=True), + "scopes_supported": current_app.config['ALLOWED_SCOPES'].split(), + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "code_challenge_methods_supported": ["S256"] + } + return jsonify(config), 200 -# 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.', + 'description': 'Redirects to consent page, then to client redirect URI with 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'] - } + {'name': 'response_type', 'in': 'query', 'type': 'string', 'required': True, 'description': 'Must be "code".', 'enum': ['code']}, + {'name': 'client_id', 'in': 'query', 'type': 'string', 'required': True, 'description': 'Client ID.'}, + {'name': 'redirect_uri', 'in': 'query', 'type': 'string', 'required': True, 'description': 'Redirect URI.'}, + {'name': 'scope', 'in': 'query', 'type': 'string', 'required': True, 'description': 'Scopes.'}, + {'name': 'state', 'in': 'query', 'type': 'string', 'required': True, 'description': 'State.'}, + {'name': 'aud', 'in': 'query', 'type': 'string', 'required': True, 'description': 'FHIR server URL.'}, + {'name': 'code_challenge', 'in': 'query', 'type': 'string', 'required': False, 'description': 'PKCE challenge.'}, + {'name': 'code_challenge_method', 'in': 'query', 'type': 'string', 'required': False, 'description': 'PKCE method.', '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).' - } + '200': {'description': 'Consent page.'}, + '400': {'description': 'Invalid parameters.'} } }) 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 + return render_template('errors/auth_error.html', error=f"No valid scopes: {requested_scopes}"), 400 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'] + request_params=session['auth_request_params'], + form=ConsentForm() ) 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 + return render_template('errors/auth_error.html', error="Unexpected error"), 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 + form = ConsentForm() + # Manually validate form fields, bypassing CSRF + if not form.is_submitted(): + return render_template('errors/auth_error.html', error="Form not submitted"), 400 + if not form.consent.data: + return render_template('errors/auth_error.html', error="Consent value missing"), 400 + consent_granted = form.consent.data == 'allow' 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 + request_args['scope'] = allowed_scopes 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 + request.form = {} + request.method = 'GET' 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 + return render_template('errors/auth_error.html', error="Unexpected error"), 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.', + 'summary': 'Exchange code or refresh token for access 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).' - } + {'name': 'grant_type', 'in': 'formData', 'type': 'string', 'required': True, 'enum': ['authorization_code', 'refresh_token']}, + {'name': 'code', 'in': 'formData', 'type': 'string', 'required': False}, + {'name': 'refresh_token', 'in': 'formData', 'type': 'string', 'required': False}, + {'name': 'redirect_uri', 'in': 'formData', 'type': 'string', 'required': False}, + {'name': 'client_id', 'in': 'formData', 'type': 'string', 'required': True}, + {'name': 'client_secret', 'in': 'formData', 'type': 'string', 'required': True}, + {'name': 'code_verifier', 'in': 'formData', 'type': 'string', 'required': False} ], '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).' - } + '200': {'description': 'Access token response'}, + '400': {'description': 'Invalid request'} } }) 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 + token_data = response.get_json() + logger.debug(f"Token issued: AccessToken={token_data.get('access_token')[:6]}...") + 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 + error_data = response.get_json() + logger.warning(f"Token request failed: {error_data.get('error')}") + return jsonify(error_data), 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 + return jsonify({"error": "server_error", "error_description": "Unexpected error"}), 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.' - } + {'name': 'token', 'in': 'formData', 'type': 'string', 'required': True}, + {'name': 'client_id', 'in': 'formData', 'type': 'string', 'required': True}, + {'name': 'client_secret', 'in': 'formData', 'type': 'string', 'required': True} ], '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).' - } + '200': {'description': 'Token revoked'}, + '400': {'description': 'Invalid request'} } }) 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 - + return jsonify({"error": "invalid_request", "error_description": "Missing 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 + if not client or not client.check_client_secret(client_secret): + return jsonify({"error": "invalid_client", "error_description": "Invalid client credentials"}), 400 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 - + 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 + return jsonify({"error": "server_error", "error_description": "Unexpected error"}), 500 + +@smart_blueprint.route('/introspect', methods=['POST']) +@swag_from({ + 'tags': ['OAuth2'], + 'summary': 'Introspect a token', + 'parameters': [ + {'name': 'token', 'in': 'formData', 'type': 'string', 'required': True}, + {'name': 'client_id', 'in': 'formData', 'type': 'string', 'required': True}, + {'name': 'client_secret', 'in': 'formData', 'type': 'string', 'required': True} + ], + 'responses': { + '200': {'description': 'Token introspection response'}, + '400': {'description': 'Invalid request'} + } +}) +def introspect_token(): + try: + 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: + return jsonify({"error": "invalid_request", "error_description": "Missing parameters"}), 400 + client = query_client(client_id) + if not client or not client.check_client_secret(client_secret): + return jsonify({"error": "invalid_client", "error_description": "Invalid client credentials"}), 400 + token_entry = OAuthToken.query.filter( + (OAuthToken.access_token == token) | (OAuthToken.refresh_token == token) + ).first() + if not token_entry: + return jsonify({"active": False}), 200 + expires_at = token_entry.issued_at + token_entry.expires_in + response = { + "active": time.time() < expires_at, + "client_id": token_entry.client_id, + "scope": token_entry.scope or "", + "exp": expires_at + } + return jsonify(response), 200 + except Exception as error: + logger.error(f"Unexpected error during token introspection: {error}", exc_info=True) + return jsonify({"error": "server_error", "error_description": "Unexpected error"}), 500 + +@smart_blueprint.route('/proxy/', methods=['GET', 'POST', 'PUT', 'DELETE']) +@require_oauth('launch launch/patient patient/*.read') +def proxy_fhir(path): + try: + token = request.headers.get('Authorization', '').replace('Bearer ', '') + if not token: + return jsonify({"error": "unauthorized", "error_description": "Missing Authorization header"}), 401 + token_entry = OAuthToken.query.filter_by(access_token=token).first() + if not token_entry or time.time() > (token_entry.issued_at + token_entry.expires_in): + return jsonify({"error": "unauthorized", "error_description": "Invalid or expired token"}), 401 + fhir_server_url = current_app.config['FHIR_SERVER_URL'] + target_url = f"{fhir_server_url.rstrip('/')}/{path}" + headers = {k: v for k, v in request.headers.items() if not is_hop_by_hop_header(k)} + headers['Authorization'] = f"Bearer {token}" + response = requests.request( + method=request.method, + url=target_url, + headers=headers, + data=request.get_data(), + params=request.args, + timeout=int(current_app.config['PROXY_TIMEOUT']) + ) + return jsonify(response.json()), response.status_code, { + k: v for k, v in response.headers.items() if not is_hop_by_hop_header(k) + } + except requests.RequestException as error: + logger.error(f"Error proxying FHIR request: {error}", exc_info=True) + return jsonify({"error": "proxy_error", "error_description": str(error)}), 502 + except Exception as error: + logger.error(f"Unexpected error during FHIR proxy: {error}", exc_info=True) + return jsonify({"error": "server_error", "error_description": "Unexpected error"}), 500 \ No newline at end of file diff --git a/static/FHIRVINE_LOGO.png b/static/FHIRVINE_LOGO.png new file mode 100644 index 0000000..28082cf Binary files /dev/null and b/static/FHIRVINE_LOGO.png differ diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..1ee23a7 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/swagger-ui.css b/static/swagger-ui.css new file mode 100644 index 0000000..3aebf87 --- /dev/null +++ b/static/swagger-ui.css @@ -0,0 +1,78 @@ +/* FHIRVINE Swagger UI Custom Styles */ + +/* Override Swagger UI background and text colors */ +body { + background-color: #f8f9fa !important; /* Light gray background */ + font-family: 'Roboto', sans-serif !important; /* Modern font */ + color: #333 !important; /* Dark text for readability */ +} + +/* Customize the top bar */ +.topbar { + background-color: #343a40 !important; /* Dark gray top bar */ + border-bottom: 2px solid #007bff !important; /* Blue accent */ +} + +.topbar-wrapper .link { + color: #ffffff !important; /* White text for logo/title */ +} + +.topbar-wrapper img { + display: none; /* Hide default Swagger logo */ +} + +.topbar-wrapper .link::before { + content: "FHIRVINE API Docs"; + font-size: 1.5rem; + font-weight: bold; +} + +/* Customize the info section */ +.info .title { + color: #007bff !important; /* Blue title */ + font-weight: bold; +} + +.info .description, .info .version { + color: #555 !important; /* Darker gray for description */ +} + +/* Customize operation blocks */ +.opblock-tag { + color: #007bff !important; /* Blue for tags */ + border-bottom: 1px solid #dee2e6 !important; +} + +.opblock-summary-method { + background-color: #28a745 !important; /* Green for methods (e.g., GET) */ + color: #fff !important; +} + +.opblock-summary-path { + color: #333 !important; +} + +.opblock.is-open .opblock-summary { + background-color: #e9ecef !important; /* Light gray for expanded blocks */ +} + +/* Customize buttons and inputs */ +.btn, .btn:hover { + background-color: #007bff !important; + color: #fff !important; + border: none !important; +} + +.input__field, .textarea { + border: 1px solid #ced4da !important; + border-radius: 0.25rem !important; +} + +/* Customize the response section */ +.response-col_status { + color: #28a745 !important; /* Green for status codes */ +} + +.response-col_description { + color: #555 !important; +} \ No newline at end of file diff --git a/templates/_formhelpers.html b/templates/_formhelpers.html new file mode 100644 index 0000000..ed20b92 --- /dev/null +++ b/templates/_formhelpers.html @@ -0,0 +1,16 @@ +{% macro render_field(field, value=None, rows=None, autocomplete=None) %} +
+ {{ field.label(class="form-label") }} + {% if field.type == "TextAreaField" %} + {{ field(class="form-control " + ("is-invalid" if field.errors else ""), value=value or '', rows=rows or 3, autocomplete=autocomplete) }} + {% else %} + {{ field(class="form-control " + ("is-invalid" if field.errors else ""), value=value or '', autocomplete=autocomplete) }} + {% endif %} + {% if field.errors %} +
+ {% for error in field.errors %}{{ error }}{% endfor %} +
+ {% endif %} + {{ field.description }} +
+{% endmacro %} \ No newline at end of file diff --git a/templates/app_gallery/delete.html b/templates/app_gallery/delete.html index 28c686c..fafe660 100644 --- a/templates/app_gallery/delete.html +++ b/templates/app_gallery/delete.html @@ -9,6 +9,7 @@

Are you sure you want to delete this application? This action cannot be undone.


+ {{ form.hidden_tag() }} Cancel
diff --git a/templates/app_gallery/edit.html b/templates/app_gallery/edit.html index fe41277..aa5482b 100644 --- a/templates/app_gallery/edit.html +++ b/templates/app_gallery/edit.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% from "_formhelpers.html" import render_field %} {% block content %}
@@ -11,83 +12,13 @@
{{ 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 }} -
- + {{ render_field(form.app_name, value=app.app_name) }} + {{ render_field(form.redirect_uris, value=app.redirect_uris, rows=3) }} + {{ render_field(form.scopes, value=app.scopes, rows=4) }} + {{ render_field(form.logo_uri, value=app.logo_uri, autocomplete="off") }} + {{ render_field(form.contacts, value=app.contacts, rows=2, autocomplete="off") }} + {{ render_field(form.tos_uri, value=app.tos_uri, autocomplete="off") }} + {{ render_field(form.policy_uri, value=app.policy_uri, autocomplete="off") }}
{{ form.submit(class="btn btn-primary btn-lg") }} Cancel diff --git a/templates/app_gallery/gallery.html b/templates/app_gallery/gallery.html index 79b64ba..1c702be 100644 --- a/templates/app_gallery/gallery.html +++ b/templates/app_gallery/gallery.html @@ -18,20 +18,22 @@ {{ 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 %} + 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 %} + {% if app.is_test_app %} +
Test App: Expires {{ app.test_app_expires_at.strftime('%Y-%m-%d %H:%M') }} + {% endif %}

-
    +
    • Redirect URIs:
        @@ -42,23 +44,26 @@ {% endfor %}
    • -
    • - Allowed Scopes: -
      +
    • + Allowed Scopes: +
      {% for scope in app.scopes.split() %} {{ scope }} {% else %} - None configured + None configured {% endfor %} -
      -
    • - {% if app.contacts %} -
    • Contacts: {{ app.contacts }}
    • - {% endif %} +
+ + {% if app.contacts %} +
  • Contacts: {{ app.contacts }}
  • + {% endif %}
    - Edit - Delete + Edit + + {{ form.hidden_tag() }} + +
    diff --git a/templates/app_gallery/register.html b/templates/app_gallery/register.html index a879a77..8f4cd4d 100644 --- a/templates/app_gallery/register.html +++ b/templates/app_gallery/register.html @@ -1,6 +1,5 @@ {% extends "base.html" %} -{# Optional: Import form helpers if you create _formhelpers.html #} -{# {% from "_formhelpers.html" import render_field %} #} +{% from "_formhelpers.html" import render_field %} {% block content %}
    @@ -13,101 +12,27 @@

    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. + {% if is_test %}This is a test app and will expire in 24 hours.{% endif %}

    -
    - {{ 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.hidden_tag() }} + {{ render_field(form.app_name) }} + {{ render_field(form.redirect_uris, rows=3) }} + {{ render_field(form.scopes, rows=4) }} +
    +

    Optional Information:

    + {{ render_field(form.logo_uri, autocomplete="off") }} + {{ render_field(form.contacts, rows=2, autocomplete="off") }} + {{ render_field(form.tos_uri, autocomplete="off") }} + {{ render_field(form.policy_uri, autocomplete="off") }}
    - {{ form.submit(class="btn btn-primary btn-lg") }} - Cancel + {{ 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 #} - + + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index e246143..e780b4f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -6,12 +6,9 @@ - {% if title %}{{ title }} - {% endif %}{{ site_name }} @@ -233,25 +183,28 @@ - - - + - - - - - - {% block scripts %}{% endblock %} diff --git a/templates/configure/proxy_settings.html b/templates/configure/proxy_settings.html new file mode 100644 index 0000000..cca0c6e --- /dev/null +++ b/templates/configure/proxy_settings.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% from "_formhelpers.html" import render_field %} + +{% block title %}Proxy Settings - {{ site_name }}{% endblock %} + +{% block content %} +
    +
    +
    +
    +
    +

    Proxy Settings

    +
    +
    +

    + Configure the upstream FHIR server and proxy settings. +

    +
    + {{ form.hidden_tag() }} + {{ render_field(form.fhir_server_url, value=form.fhir_server_url.data) }} + {{ render_field(form.proxy_timeout, value=form.proxy_timeout.data) }} +
    + {{ form.submit(class="btn btn-primary btn-lg") }} + Cancel +
    +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/configure/security.html b/templates/configure/security.html new file mode 100644 index 0000000..a73793b --- /dev/null +++ b/templates/configure/security.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% from "_formhelpers.html" import render_field %} + +{% block title %}Security Settings - {{ site_name }}{% endblock %} + +{% block content %} +
    +
    +
    +
    +
    +

    Security Settings

    +
    +
    +

    + Configure security settings for OAuth2 authentication. +

    +
    + {{ form.hidden_tag() }} + {{ render_field(form.token_duration, value=form.token_duration.data) }} + {{ render_field(form.refresh_token_duration, value=form.refresh_token_duration.data) }} + {{ render_field(form.allowed_scopes, value=form.allowed_scopes.data) }} +
    + {{ form.submit(class="btn btn-primary btn-lg") }} + Cancel +
    +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/configure/server_endpoints.html b/templates/configure/server_endpoints.html new file mode 100644 index 0000000..f686b8f --- /dev/null +++ b/templates/configure/server_endpoints.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% from "_formhelpers.html" import render_field %} + +{% block title %}Server Endpoints - {{ site_name }}{% endblock %} + +{% block content %} +
    +
    +
    +
    +
    +

    Server Endpoints

    +
    +
    +

    + Configure endpoints for the upstream FHIR server and view available SMART endpoints. +

    +
    + {{ form.hidden_tag() }} + {{ render_field(form.metadata_endpoint) }} + {{ render_field(form.capability_endpoint) }} + {{ render_field(form.resource_base_endpoint) }} +
    + {{ form.submit(class="btn btn-primary btn-lg") }} + Cancel +
    +
    +
    +
    Available SMART Endpoints
    +
      +
    • Configuration: /.well-known/smart-configuration - SMART on FHIR discovery
    • +
    • Authorization: /oauth2/authorize - Initiate OAuth2 flow
    • +
    • Token: /oauth2/token - Exchange code for token
    • +
    • Revoke: /oauth2/revoke - Revoke tokens
    • +
    • Introspect: /oauth2/introspect - Verify token status
    • +
    • FHIR Proxy: /oauth2/proxy/ - Proxy FHIR requests
    • +
    + +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/consent.html b/templates/consent.html index 6596f77..6ad3158 100644 --- a/templates/consent.html +++ b/templates/consent.html @@ -1,48 +1,31 @@ {% extends "base.html" %} -{% block title %}Authorize {{ client.app_name }} - {{ site_name }}{% endblock %} - {% block content %} -
    +
    -
    -

    Authorize Application

    +
    +

    Authorize {{ client.app_name }}

    -

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

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

    -
    Permissions Requested:
    +
    Scopes 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 %} -

      -
    • +
    • {{ scope }}
    • {% endfor %}
    -
    -
    - -
    diff --git a/templates/index.html b/templates/index.html index 7146c23..6813d62 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2,7 +2,8 @@ {% block content %}
    -

    Welcome to {{ site_name }}

    + FHIRVINE SMART PROXY +

    Welcome to {{ site_name }}

    A modular SMART on FHIR Single Sign-On (SSO) Proxy. Register applications, test client launches, and manage connections. @@ -11,6 +12,7 @@ View App Gallery Register New App Test Client Launch + Test Server

    diff --git a/templates/swagger-ui.html b/templates/swagger-ui.html new file mode 100644 index 0000000..f2f91c0 --- /dev/null +++ b/templates/swagger-ui.html @@ -0,0 +1,87 @@ + + + + + FHIRVINE API Documentation + + + + + + + + +
    +
    + + + +
    + + + \ No newline at end of file diff --git a/templates/swagger-ui.html.old.html b/templates/swagger-ui.html.old.html new file mode 100644 index 0000000..f2f91c0 --- /dev/null +++ b/templates/swagger-ui.html.old.html @@ -0,0 +1,87 @@ + + + + + FHIRVINE API Documentation + + + + + + + + +
    +
    + + + +
    + + + \ No newline at end of file diff --git a/templates/test_client.html b/templates/test_client.html index b3a1a34..b5fae2b 100644 --- a/templates/test_client.html +++ b/templates/test_client.html @@ -10,31 +10,53 @@

    - Select a registered application to test its SMART on FHIR launch. + Select a registered application to test its SMART on FHIR launch or register a test app.

    {{ form.hidden_tag() }} -
    {{ form.client_id.label(class="form-label") }} - + + {% if apps %} + {% for app in apps %} + + {% endfor %} + {% else %} + + {% endif %} + Choose an app to test its OAuth2 flow. {% if form.client_id.errors %}
    {% for error in form.client_id.errors %}{{ error }}{% endfor %}
    {% endif %}
    - +
    + {{ form.response_mode.label(class="form-label") }} +
    + {% for subfield in form.response_mode %} +
    + {{ subfield(class="form-check-input") }} + {{ subfield.label(class="form-check-label") }} +
    + {% endfor %} +
    +
    {{ form.submit(class="btn btn-primary btn-lg") }} + Register Test App Cancel
    + {% if response_data %} +
    +
    Test Response:
    +
    +{{ response_data | tojson(indent=2) }}
    +                        
    + {% endif %}
    diff --git a/templates/test_server.html b/templates/test_server.html new file mode 100644 index 0000000..e98de8e --- /dev/null +++ b/templates/test_server.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% from "_formhelpers.html" import render_field %} + +{% block content %} +
    +
    +
    +
    +
    +

    Test SMART Server

    +
    +
    +

    + Test FHIRVINE as an OAuth2 server by simulating an external client. +

    +
    + {{ form.hidden_tag() }} + {{ render_field(form.client_id) }} + {{ render_field(form.client_secret) }} + {{ render_field(form.redirect_uri) }} + {{ render_field(form.scopes) }} +
    + {{ form.submit(class="btn btn-primary btn-lg") }} + Cancel +
    +
    + {% if response_data %} +
    +
    Server Test Response:
    +
    +{{ response_data | tojson(indent=2) }}
    +                        
    + {% endif %} +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file