V0.5 - incremental

App should be mostly functional now
This commit is contained in:
Joshua Hare 2025-04-24 09:27:48 +10:00
parent c61d907c42
commit 897e875e29
30 changed files with 1767 additions and 990 deletions

18
.env Normal file
View File

@ -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

View File

@ -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"]

120
README.md
View File

@ -1 +1,119 @@
# FHIREVINE-Smart-Proxy
# 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 <repository-url>
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 <repository-url>
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`.

72
README_INTEGRATION.md Normal file
View File

@ -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 its 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 FHIRVINEs 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 FHIRFLAREs 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 FHIRFLAREs target FHIR server (e.g., `http://hapi.fhir.org/baseR4`).
## Integration Steps
### 1. Authenticate FHIRFLARE via FHIRVINE
- In FHIRFLARE, initiate an OAuth2 flow using FHIRVINEs `/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 FHIRVINEs 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 FHIRFLAREs validation module to validate resources fetched via FHIRVINEs proxy.
- Example: Fetch a Patient resource and validate it against an IG in FHIRFLARE.
## Troubleshooting
- **Auth Errors**: Ensure FHIRFLAREs 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 FHIRFLAREs IG package is correctly loaded.

84
README_PROXY.md Normal file
View File

@ -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 <repository-url>
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 apps 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/<path>` - 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`.

247
USERGUIDE.markdown Normal file
View File

@ -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 apps 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 FHIRVINEs 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 FHIRVINEs 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 apps 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**: Apps 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 FHIRVINEs 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`: Apps 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=<code>&state=<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": "<access-token>",
"refresh_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": "<client-id>",
"scope": "patient/*.read openid",
"exp": 1698777600
}
```
### 6. FHIR Proxy
- **Endpoint**: `GET|POST /oauth2/proxy/<path:path>`
- **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 <access-token>`.
- **Use Case**: Access FHIR resources securely through the proxy.
- **Response**: The FHIR servers 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 tokens 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 projects GitHub repository.

415
app.py
View File

@ -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/<int:app_id>', 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/<int:app_id>', 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

View File

@ -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:

107
forms.py
View File

@ -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')
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')

View File

@ -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()

View File

@ -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
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)

View File

@ -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
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/<path:path>', 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

BIN
static/FHIRVINE_LOGO.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

78
static/swagger-ui.css Normal file
View File

@ -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;
}

View File

@ -0,0 +1,16 @@
{% macro render_field(field, value=None, rows=None, autocomplete=None) %}
<div class="mb-3">
{{ 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 %}
<div class="invalid-feedback">
{% for error in field.errors %}<span>{{ error }}</span>{% endfor %}
</div>
{% endif %}
<small class="form-text text-muted">{{ field.description }}</small>
</div>
{% endmacro %}

View File

@ -9,6 +9,7 @@
<p>Are you sure you want to delete this application? This action cannot be undone.</p>
<hr>
<form method="POST" action="{{ url_for('delete_app', app_id=app.id) }}">
{{ form.hidden_tag() }}
<button type="submit" class="btn btn-danger me-2">Yes, Delete</button>
<a href="{{ url_for('app_gallery') }}" class="btn btn-secondary">Cancel</a>
</form>

View File

@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "_formhelpers.html" import render_field %}
{% block content %}
<div class="container mt-4">
@ -11,83 +12,13 @@
<div class="card-body">
<form method="POST" action="{{ url_for('edit_app', app_id=app.id) }}" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ 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 %}
<div class="invalid-feedback">
{% for error in form.app_name.errors %}<span>{{ error }}</span>{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
{{ 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 %}
<div class="invalid-feedback">
{% for error in form.redirect_uris.errors %}<span>{{ error }}</span>{% endfor %}
</div>
{% endif %}
<small class="form-text text-muted">{{ form.redirect_uris.description }}</small>
</div>
<div class="mb-3">
{{ 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 %}
<div class="invalid-feedback">
{% for error in form.scopes.errors %}<span>{{ error }}</span>{% endfor %}
</div>
{% endif %}
<small class="form-text text-muted">{{ form.scopes.description }}</small>
</div>
<div class="mb-3">
{{ 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 %}
<div class="invalid-feedback">
{% for error in form.logo_uri.errors %}<span>{{ error }}</span>{% endfor %}
</div>
{% endif %}
<small class="form-text text-muted">{{ form.logo_uri.description }}</small>
</div>
<div class="mb-3">
{{ 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 %}
<div class="invalid-feedback">
{% for error in form.contacts.errors %}<span>{{ error }}</span>{% endfor %}
</div>
{% endif %}
<small class="form-text text-muted">{{ form.contacts.description }}</small>
</div>
<div class="mb-3">
{{ 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 %}
<div class="invalid-feedback">
{% for error in form.tos_uri.errors %}<span>{{ error }}</span>{% endfor %}
</div>
{% endif %}
<small class="form-text text-muted">{{ form.tos_uri.description }}</small>
</div>
<div class="mb-3">
{{ 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 %}
<div class="invalid-feedback">
{% for error in form.policy_uri.errors %}<span>{{ error }}</span>{% endfor %}
</div>
{% endif %}
<small class="form-text text-muted">{{ form.policy_uri.description }}</small>
</div>
{{ 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") }}
<div class="d-grid gap-2 mt-4">
{{ form.submit(class="btn btn-primary btn-lg") }}
<a href="{{ url_for('app_gallery') }}" class="btn btn-outline-secondary">Cancel</a>

View File

@ -18,20 +18,22 @@
<img src="{{ app.logo_uri }}" class="card-img-top mt-3 mx-auto" alt="{{ app.app_name }} Logo" style="width: 80px; height: 80px; object-fit: contain;">
{% else %}
<div class="text-center mt-3">
<i class="bi bi-app-indicator" style="font-size: 5rem; color: #ccc;"></i> {# Placeholder Icon #}
<i class="bi bi-app-indicator" style="font-size: 5rem; color: #ccc;" aria-hidden="true"></i>
</div>
{% endif %}
<div class="card-body d-flex flex-column">
<h5 class="card-title text-center">{{ app.app_name }}</h5>
<p class="card-text text-muted small">
<strong>Client ID:</strong> <code class="user-select-all">{{ app.client_id }}</code><br>
{# Do NOT display client secret here #}
<strong>Registered:</strong> {{ app.date_registered.strftime('%Y-%m-%d %H:%M') if app.date_registered else 'N/A' }}<br>
{% if app.last_updated %}
<strong>Updated:</strong> {{ app.last_updated.strftime('%Y-%m-%d %H:%M') }}
{% endif %}
<strong>Registered:</strong> {{ app.date_registered.strftime('%Y-%m-%d %H:%M') if app.date_registered else 'N/A' }}<br>
{% if app.last_updated %}
<strong>Updated:</strong> {{ app.last_updated.strftime('%Y-%m-%d %H:%M') }}
{% endif %}
{% if app.is_test_app %}
<br><strong>Test App:</strong> Expires {{ app.test_app_expires_at.strftime('%Y-%m-%d %H:%M') }}
{% endif %}
</p>
<ul class="list-group list-group-flush small flex-grow-1 mb-3">
<ul class="list-group list-group-flush small flex-grow-1 mb-3">
<li class="list-group-item px-0">
<strong>Redirect URIs:</strong>
<ul class="list-unstyled ms-2">
@ -42,23 +44,26 @@
{% endfor %}
</ul>
</li>
<li class="list-group-item px-0">
<strong>Allowed Scopes:</strong>
<div>
<li class="list-group-item px-0">
<strong>Allowed Scopes:</strong>
<div>
{% for scope in app.scopes.split() %}
<span class="badge bg-secondary me-1 mb-1">{{ scope }}</span>
{% else %}
<span class="text-danger">None configured</span>
<span class="text-danger">None configured</span>
{% endfor %}
</div>
</li>
{% if app.contacts %}
<li class="list-group-item px-0"><strong>Contacts:</strong> {{ app.contacts }}</li>
{% endif %}
</div>
</li>
{% if app.contacts %}
<li class="list-group-item px-0"><strong>Contacts:</strong> {{ app.contacts }}</li>
{% endif %}
</ul>
<div class="mt-auto d-flex justify-content-end gap-2">
<a href="{{ url_for('edit_app', app_id=app.id) }}" class="btn btn-sm btn-outline-primary">Edit</a>
<a href="{{ url_for('delete_app', app_id=app.id) }}" class="btn btn-sm btn-outline-danger">Delete</a>
<a href="{{ url_for('edit_app', app_id=app.id) }}" class="btn btn-sm btn-outline-primary" aria-label="Edit application {{ app.app_name }}">Edit</a>
<form method="POST" action="{{ url_for('delete_app', app_id=app.id) }}" class="d-inline">
{{ form.hidden_tag() }}
<button type="submit" class="btn btn-sm btn-outline-danger" aria-label="Delete application {{ app.app_name }}">Delete</button>
</form>
</div>
</div>
</div>

View File

@ -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 %}
<div class="container mt-4">
@ -13,101 +12,27 @@
<div class="card-body">
<p class="text-muted text-center small mb-4">
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 %}
</p>
<form method="POST" action="{{ url_for('register_app') }}" novalidate>
{{ form.hidden_tag() }} {# Include CSRF token #}
{# --- Application Name --- #}
<div class="mb-3">
{{ 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 %}
<div class="invalid-feedback">
{% for error in form.app_name.errors %}<span>{{ error }}</span>{% endfor %}
</div>
{% endif %} {# Closes if form.app_name.errors #}
</div>
{# --- Redirect URIs --- #}
<div class="mb-3">
{{ form.redirect_uris.label(class="form-label") }} <small class="text-muted">({{ form.redirect_uris.description }})</small>
{{ form.redirect_uris(class="form-control" + (" is-invalid" if form.redirect_uris.errors else ""), rows="3") }}
{% if form.redirect_uris.errors %}
<div class="invalid-feedback">
{% for error in form.redirect_uris.errors %}<span>{{ error }}</span>{% endfor %}
</div>
{% endif %} {# Closes if form.redirect_uris.errors #}
</div>
{# --- Scopes --- #}
<div class="mb-3">
{{ form.scopes.label(class="form-label") }} <small class="text-muted">({{ form.scopes.description }})</small>
{{ form.scopes(class="form-control" + (" is-invalid" if form.scopes.errors else ""), rows="4") }}
{% if form.scopes.errors %}
<div class="invalid-feedback">
{% for error in form.scopes.errors %}<span>{{ error }}</span>{% endfor %}
</div>
{% endif %} {# Closes if form.scopes.errors #}
</div>
<hr>
<p class="text-muted small">Optional Information:</p>
{# --- Logo URI --- #}
<div class="mb-3">
{{ form.logo_uri.label(class="form-label") }} <small class="text-muted">({{ form.logo_uri.description }})</small>
{{ form.logo_uri(class="form-control" + (" is-invalid" if form.logo_uri.errors else "")) }}
{% if form.logo_uri.errors %}
<div class="invalid-feedback">
{% for error in form.logo_uri.errors %}<span>{{ error }}</span>{% endfor %}
</div>
{% endif %} {# Closes if form.logo_uri.errors #}
</div>
{# --- Contacts --- #}
<div class="mb-3">
{{ form.contacts.label(class="form-label") }} <small class="text-muted">({{ form.contacts.description }})</small>
{{ form.contacts(class="form-control" + (" is-invalid" if form.contacts.errors else ""), rows="2") }}
{% if form.contacts.errors %}
<div class="invalid-feedback">
{% for error in form.contacts.errors %}<span>{{ error }}</span>{% endfor %}
</div>
{% endif %} {# Closes if form.contacts.errors #}
</div>
{# --- Terms of Service URI --- #}
<div class="mb-3">
{{ form.tos_uri.label(class="form-label") }} <small class="text-muted">({{ form.tos_uri.description }})</small>
{{ form.tos_uri(class="form-control" + (" is-invalid" if form.tos_uri.errors else "")) }}
{% if form.tos_uri.errors %}
<div class="invalid-feedback">
{% for error in form.tos_uri.errors %}<span>{{ error }}</span>{% endfor %}
</div>
{% endif %} {# Closes if form.tos_uri.errors #}
</div>
{# --- Privacy Policy URI --- #}
<div class="mb-3">
{{ form.policy_uri.label(class="form-label") }} <small class="text-muted">({{ form.policy_uri.description }})</small>
{{ form.policy_uri(class="form-control" + (" is-invalid" if form.policy_uri.errors else "")) }}
{% if form.policy_uri.errors %}
<div class="invalid-feedback">
{% for error in form.policy_uri.errors %}<span>{{ error }}</span>{% endfor %}
</div>
{% endif %} {# Closes if form.policy_uri.errors #}
</div>
{# --- Submit Buttons --- #}
<form method="POST" action="{{ url_for('register_app', test='1' if is_test else '0') }}" novalidate>
{{ form.hidden_tag() }}
{{ render_field(form.app_name) }}
{{ render_field(form.redirect_uris, rows=3) }}
{{ render_field(form.scopes, rows=4) }}
<hr>
<p class="text-muted small">Optional Information:</p>
{{ 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") }}
<div class="d-grid gap-2 mt-4">
{{ form.submit(class="btn btn-primary btn-lg") }}
<a href="{{ url_for('app_gallery') }}" class="btn btn-outline-secondary">Cancel</a>
{{ form.submit(class="btn btn-primary btn-lg") }}
<a href="{{ url_for('app_gallery') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form> {# Closes form tag #}
</div> {# Closes card-body #}
</div> {# Closes card #}
</div> {# Closes col #}
</div> {# Closes row #}
</div> {# Closes container #}
{% endblock %} {# Closes block content #}
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -6,12 +6,9 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" integrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-okaidia.min.css" rel="stylesheet" />
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
<title>{% if title %}{{ title }} - {% endif %}{{ site_name }}</title>
<style>
/* Inherit most styles from FHIRFLARE's base.html */
/* Default (Light Theme) Styles */
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f8f9fa;
@ -33,7 +30,7 @@
}
.navbar-brand {
font-weight: bold;
color: #0d6efd !important; /* Standard Bootstrap Blue */
color: #0d6efd !important;
}
.nav-link {
color: #333 !important;
@ -47,8 +44,7 @@
font-weight: bold;
border-bottom: 2px solid #0d6efd;
}
/* ... (Keep other styles like dropdown, card, footer, alerts from FHIRFLARE base.html) ... */
.dropdown-menu {
.dropdown-menu {
list-style: none !important;
position: absolute;
background-color: #ffffff;
@ -83,7 +79,6 @@
.text-muted {
color: #6c757d !important;
}
/* Footer Styles */
footer {
background-color: #ffffff;
height: 56px;
@ -91,7 +86,7 @@
align-items: center;
padding: 0.5rem 1rem;
border-top: 1px solid #e0e0e0;
margin-top: auto; /* Push footer to bottom */
margin-top: auto;
}
.footer-left,
.footer-right {
@ -109,21 +104,16 @@
.footer-right a:hover {
text-decoration: underline;
}
/* Alert Styles */
.alert { border-radius: 8px; }
.alert-danger { background-color: #f8d7da; color: #721c24; border-color: #f5c6cb; }
.alert-success { background-color: #d4edda; color: #155724; border-color: #c3e6cb; }
.alert-info { background-color: #d1ecf1; color: #0c5460; border-color: #bee5eb; }
.alert-warning { background-color: #fff3cd; color: #856404; border-color: #ffeeba;}
/* Theme Toggle Styles */
.navbar-controls {
.alert-warning { background-color: #fff3cd; color: #856404; border-color: #ffeeba; }
.navbar-controls {
gap: 0.5rem;
padding-right: 0;
}
.form-check-input {
.form-check-input {
width: 1.25rem !important;
height: 1.25rem !important;
margin-top: 0.25rem;
@ -144,45 +134,8 @@
margin-left: 0.5rem;
}
.form-check {
margin-bottom: 1rem; /* Or adjust as needed */
margin-bottom: 1rem;
}
/* Prism JS overrides if needed */
/* (Copy relevant Prism theme overrides from FHIRFLARE base.html if desired) */
/* --- Prism.js Theme Overrides for Light Mode --- */
html:not([data-theme="dark"]) .token.punctuation,
html:not([data-theme="dark"]) .token.operator,
html:not([data-theme="dark"]) .token.entity,
html:not([data-theme="dark"]) .token.url,
html:not([data-theme="dark"]) .token.symbol,
html:not([data-theme="dark"]) .token.number,
html:not([data-theme="dark"]) .token.boolean,
html:not([data-theme="dark"]) .token.variable,
html:not([data-theme="dark"]) .token.constant,
html:not([data-theme="dark"]) .token.property,
html:not([data-theme="dark"]) .token.regex,
html:not([data-theme="dark"]) .token.inserted,
html:not([data-theme="dark"]) .token.null, /* Target null specifically */
html:not([data-theme="dark"]) .language-css .token.string,
html:not([data-theme="dark"]) .style .token.string,
html:not([data-theme="dark"]) .token.important,
html:not([data-theme="dark"]) .token.bold {
color: #333 !important;
}
html:not([data-theme="dark"]) .token.string {
color: #005cc5 !important;
}
html:not([data-theme="dark"]) pre[class*="language-"] {
background: #e9ecef !important;
}
html:not([data-theme="dark"]) :not(pre) > code[class*="language-"] {
background: #e9ecef !important;
color: #333 !important;
}
/* --- End Prism.js Overrides --- */
/* --- Dark Theme Overrides --- */
/* (Copy relevant Dark theme overrides from FHIRFLARE base.html) */
html[data-theme="dark"] body { background-color: #212529; color: #f8f9fa; }
html[data-theme="dark"] .navbar-light { background-color: #343a40 !important; border-bottom: 1px solid #495057; }
html[data-theme="dark"] .navbar-brand { color: #4dabf7 !important; }
@ -200,22 +153,19 @@
html[data-theme="dark"] .form-control::placeholder { color: #adb5bd; }
html[data-theme="dark"] .alert-danger { background-color: #dc3545; color: white; border-color: #dc3545; }
html[data-theme="dark"] .alert-success { background-color: #198754; color: white; border-color: #198754; }
html[data-theme="dark"] .alert-info { background-color: #0dcaf0; color: #000; border-color: #0dcaf0; } /* Adjusted info for dark */
html[data-theme="dark"] .alert-warning { background-color: #ffc107; color: #000; border-color: #ffc107; } /* Adjusted warning for dark */
html[data-theme="dark"] .alert-info { background-color: #0dcaf0; color: #000; border-color: #0dcaf0; }
html[data-theme="dark"] .alert-warning { background-color: #ffc107; color: #000; border-color: #ffc107; }
html[data-theme="dark"] .form-check-input { border-color: #adb5bd; }
html[data-theme="dark"] .form-check-input:checked { background-color: #4dabf7; border-color: #4dabf7; }
html[data-theme="dark"] footer { background-color: #343a40; border-top: 1px solid #495057; }
html[data-theme="dark"] .footer-left a, html[data-theme="dark"] .footer-right a { color: #4dabf7; }
/* Responsive adjustments */
@media (max-width: 576px) {
@media (max-width: 576px) {
.navbar-controls { flex-direction: column; align-items: stretch; width: 100%; }
.navbar-controls .form-check { margin-bottom: 0.5rem; }
footer { height: auto; padding: 0.5rem; flex-direction: column; gap: 0.5rem; }
.footer-left, .footer-right { flex-direction: column; text-align: center; gap: 0.5rem; }
.footer-left a, .footer-right a { font-size: 0.8rem; }
}
</style>
</head>
<body class="d-flex flex-column min-vh-100">
@ -233,25 +183,28 @@
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'app_gallery' else '' }}" href="{{ url_for('app_gallery') }}"><i class="bi bi-grid-fill me-1"></i> App Gallery</a>
</li>
<li class="nav-item">
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'register_app' else '' }}" href="{{ url_for('register_app') }}"><i class="bi bi-plus-square-fill me-1"></i> Register App</a>
</li>
<li class="nav-item">
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'test_client' else '' }}" href="{{ url_for('test_client') }}"><i class="bi bi-play-circle-fill me-1"></i> Test Client Launch</a>
</li>
<li class="nav-item dropdown">
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'test_server' else '' }}" href="{{ url_for('test_server') }}"><i class="bi bi-play-circle-fill me-1"></i> Test Server</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownConfig" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-gear-fill me-1"></i> Configure
<i class="bi bi-gear-fill me-1"></i> Configure
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdownConfig">
<li><a class="dropdown-item" href="#">Proxy Settings</a></li>
<li><a class="dropdown-item" href="#">Server Endpoints</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#">Security</a></li>
<li><a class="dropdown-item" href="{{ url_for('proxy_settings') }}">Proxy Settings</a></li>
<li><a class="dropdown-item" href="{{ url_for('server_endpoints') }}">Server Endpoints</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('security_settings') }}">Security</a></li>
</ul>
</li>
</ul>
<div class="navbar-controls d-flex align-items-center">
</ul>
<div class="navbar-controls d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="themeToggle" onchange="toggleTheme()" aria-label="Toggle dark mode">
<label class="form-check-label" for="themeToggle">
@ -262,7 +215,6 @@
</div>
</div>
</nav>
<main class="flex-grow-1">
<div class="container mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
@ -286,30 +238,25 @@
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</main>
<footer class="main-footer" role="contentinfo">
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center">
<div class="footer-left">
<a href="https://github.com/YOUR_USERNAME/FHIRVINE" target="_blank" rel="noreferrer" aria-label="Project Github">FHIRVINE Github</a>
<a href="/about" aria-label="About FHIRVINE">About FHIRVINE</a>
<a href="https://www.hl7.org/fhir/smart-app-launch/" target="_blank" rel="noreferrer" aria-label="SMART on FHIR">SMART on FHIR</a>
<a href="https://github.com/Sudo-JHare/FHIRVINE" target="_blank" rel="noreferrer" aria-label="Project Github">FHIRVINE Github</a>
<a href="{{ url_for('about') }}" aria-label="About FHIRVINE">About FHIRVINE</a>
<a href="https://www.hl7.org/fhir/smart-app-launch/" target="_blank" rel="noreferrer" aria-label="SMART on FHIR">SMART on FHIR</a>
</div>
<div class="footer-right">
<a href="https://github.com/YOUR_USERNAME/FHIRVINE/issues" target="_blank" rel="noreferrer" aria-label="Report Issue">Report Issue</a>
<a href="https://github.com/YOUR_USERNAME" aria-label="Developer">Developer</a>
</div>
<a href="https://github.com/Sudo-JHare/FHIRVINE/issues" target="_blank" rel="noreferrer" aria-label="Report Issue">Report Issue</a>
<a href="https://github.com/Sudo-JHare" aria-label="Developer">Developer</a>
</div>
</div>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script>
function toggleTheme() {
const html = document.documentElement;
@ -317,70 +264,53 @@
const sunIcon = document.querySelector('.fa-sun');
const moonIcon = document.querySelector('.fa-moon');
const isDark = toggle.checked;
html.setAttribute('data-theme', isDark ? 'dark' : 'light');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
document.cookie = `theme=${isDark ? 'dark' : 'light'}; path=/; max-age=31536000; SameSite=Lax`; // Added SameSite
document.cookie = `theme=${isDark ? 'dark' : 'light'}; path=/; max-age=31536000; SameSite=Lax`;
if (sunIcon && moonIcon) {
sunIcon.classList.toggle('d-none', isDark);
moonIcon.classList.toggle('d-none', !isDark);
}
// Optional: Dispatch a custom event for other components to listen to
// window.dispatchEvent(new CustomEvent('themeChanged', { detail: { theme: isDark ? 'dark' : 'light' } }));
}
document.addEventListener('DOMContentLoaded', () => {
// Prioritize localStorage, then cookie, then system preference
let currentTheme = localStorage.getItem('theme');
if (!currentTheme) {
const cookieMatch = document.cookie.match(/theme=(dark|light)/);
currentTheme = cookieMatch ? cookieMatch[1] : null;
}
if (!currentTheme) {
currentTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
currentTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
const themeToggle = document.getElementById('themeToggle');
const sunIcon = document.querySelector('.fa-sun');
const moonIcon = document.querySelector('.fa-moon');
if (currentTheme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
if(themeToggle) themeToggle.checked = true;
} else {
document.documentElement.removeAttribute('data-theme');
if(themeToggle) themeToggle.checked = false;
document.documentElement.removeAttribute('data-theme');
if(themeToggle) themeToggle.checked = false;
}
if (sunIcon && moonIcon) {
sunIcon.classList.toggle('d-none', currentTheme === 'dark');
moonIcon.classList.toggle('d-none', currentTheme !== 'dark');
}
// Update icons based on the final theme state
if (sunIcon && moonIcon) {
sunIcon.classList.toggle('d-none', currentTheme === 'dark');
moonIcon.classList.toggle('d-none', currentTheme !== 'dark');
}
// Initialize tooltips
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach(t => new bootstrap.Tooltip(t));
});
// Listener for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
// Only change if no theme explicitly set by user (via localStorage or cookie)
if (!localStorage.getItem('theme') && !document.cookie.includes('theme=')) {
const newColorScheme = event.matches ? "dark" : "light";
document.documentElement.setAttribute('data-theme', newColorScheme);
const themeToggle = document.getElementById('themeToggle');
if (themeToggle) themeToggle.checked = (newColorScheme === 'dark');
// Update icons
const sunIcon = document.querySelector('.fa-sun');
const moonIcon = document.querySelector('.fa-moon');
if (sunIcon && moonIcon) {
sunIcon.classList.toggle('d-none', newColorScheme === 'dark');
moonIcon.classList.toggle('d-none', newColorScheme !== 'dark');
}
}
if (!localStorage.getItem('theme') && !document.cookie.includes('theme=')) {
const newColorScheme = event.matches ? "dark" : "light";
document.documentElement.setAttribute('data-theme', newColorScheme);
const themeToggle = document.getElementById('themeToggle');
if (themeToggle) themeToggle.checked = (newColorScheme === 'dark');
const sunIcon = document.querySelector('.fa-sun');
const moonIcon = document.querySelector('.fa-moon');
if (sunIcon && moonIcon) {
sunIcon.classList.toggle('d-none', newColorScheme === 'dark');
moonIcon.classList.toggle('d-none', newColorScheme !== 'dark');
}
}
});
</script>
{% block scripts %}{% endblock %}

View File

@ -0,0 +1,32 @@
{% extends "base.html" %}
{% from "_formhelpers.html" import render_field %}
{% block title %}Proxy Settings - {{ site_name }}{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card shadow-sm">
<div class="card-header">
<h2 class="text-center mb-0">Proxy Settings</h2>
</div>
<div class="card-body">
<p class="text-muted text-center small mb-4">
Configure the upstream FHIR server and proxy settings.
</p>
<form method="POST" action="{{ url_for('proxy_settings') }}" novalidate>
{{ 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) }}
<div class="d-grid gap-2 mt-4">
{{ form.submit(class="btn btn-primary btn-lg") }}
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary btn-lg">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% from "_formhelpers.html" import render_field %}
{% block title %}Security Settings - {{ site_name }}{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card shadow-sm">
<div class="card-header">
<h2 class="text-center mb-0">Security Settings</h2>
</div>
<div class="card-body">
<p class="text-muted text-center small mb-4">
Configure security settings for OAuth2 authentication.
</p>
<form method="POST" action="{{ url_for('security_settings') }}" novalidate>
{{ 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) }}
<div class="d-grid gap-2 mt-4">
{{ form.submit(class="btn btn-primary btn-lg") }}
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary btn-lg">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,46 @@
{% extends "base.html" %}
{% from "_formhelpers.html" import render_field %}
{% block title %}Server Endpoints - {{ site_name }}{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card shadow-sm">
<div class="card-header">
<h2 class="text-center mb-0">Server Endpoints</h2>
</div>
<div class="card-body">
<p class="text-muted text-center small mb-4">
Configure endpoints for the upstream FHIR server and view available SMART endpoints.
</p>
<form method="POST" action="{{ url_for('server_endpoints') }}" novalidate>
{{ form.hidden_tag() }}
{{ render_field(form.metadata_endpoint) }}
{{ render_field(form.capability_endpoint) }}
{{ render_field(form.resource_base_endpoint) }}
<div class="d-grid gap-2 mt-4">
{{ form.submit(class="btn btn-primary btn-lg") }}
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary btn-lg">Cancel</a>
</div>
</form>
<hr class="my-4">
<h5>Available SMART Endpoints</h5>
<ul class="list-group mb-4">
<li class="list-group-item"><strong>Configuration:</strong> <code>/.well-known/smart-configuration</code> - SMART on FHIR discovery</li>
<li class="list-group-item"><strong>Authorization:</strong> <code>/oauth2/authorize</code> - Initiate OAuth2 flow</li>
<li class="list-group-item"><strong>Token:</strong> <code>/oauth2/token</code> - Exchange code for token</li>
<li class="list-group-item"><strong>Revoke:</strong> <code>/oauth2/revoke</code> - Revoke tokens</li>
<li class="list-group-item"><strong>Introspect:</strong> <code>/oauth2/introspect</code> - Verify token status</li>
<li class="list-group-item"><strong>FHIR Proxy:</strong> <code>/oauth2/proxy/<path></code> - Proxy FHIR requests</li>
</ul>
<div class="text-center">
<a href="{{ url_for('custom_apidocs') }}" class="btn btn-primary">View API Documentation</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,48 +1,31 @@
{% extends "base.html" %}
{% block title %}Authorize {{ client.app_name }} - {{ site_name }}{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card shadow-sm">
<div class="card-header text-center">
<h2 class="mb-0">Authorize Application</h2>
<div class="card-header">
<h2 class="text-center mb-0">Authorize {{ client.app_name }}</h2>
</div>
<div class="card-body">
<p class="text-center">
<strong>{{ client.app_name }}</strong> is requesting access to your data.
<p class="text-muted text-center small mb-4">
The application <strong>{{ client.app_name }}</strong> is requesting access to your FHIR data.
</p>
<h5 class="mt-4">Permissions Requested:</h5>
<h5 class="mb-3">Scopes Requested:</h5>
<ul class="list-group mb-4">
{% for scope in scopes %}
<li class="list-group-item">
<strong>{{ scope }}</strong>
<p class="mb-0 text-muted">
{% 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 %}
</p>
</li>
<li class="list-group-item">{{ scope }}</li>
{% endfor %}
</ul>
<form method="POST" action="{{ url_for('smart_proxy.handle_consent') }}">
<div class="d-flex justify-content-between">
<button type="submit" name="consent" value="allow" class="btn btn-primary">
<form method="POST" action="{{ url_for('smart_proxy.handle_consent') }}" novalidate>
{{ form.hidden_tag() }}
<input type="hidden" name="consent" id="consent_value">
<div class="d-flex justify-content-between gap-2">
<button type="submit" class="btn btn-primary" onclick="document.getElementById('consent_value').value='allow'">
<i class="bi bi-check-circle-fill me-1"></i> Allow
</button>
<button type="submit" name="consent" value="deny" class="btn btn-secondary">
<button type="submit" class="btn btn-secondary" onclick="document.getElementById('consent_value').value='deny'">
<i class="bi bi-x-circle-fill me-1"></i> Deny
</button>
</div>

View File

@ -2,7 +2,8 @@
{% block content %}
<div class="px-4 py-5 my-5 text-center">
<i class="bi bi-lock-fill" style="font-size: 4rem; color: var(--bs-primary);"></i> <h1 class="display-5 fw-bold text-body-emphasis mt-3">Welcome to {{ site_name }}</h1>
<img class="d-block mx-auto mb-4" src="{{ url_for('static', filename='FHIRVINE_LOGO.png') }}" alt="FHIRVINE SMART PROXY" width="400" height="400">
<h1 class="display-5 fw-bold text-body-emphasis mt-3">Welcome to {{ site_name }}</h1>
<div class="col-lg-6 mx-auto">
<p class="lead mb-4">
A modular SMART on FHIR Single Sign-On (SSO) Proxy. Register applications, test client launches, and manage connections.
@ -11,6 +12,7 @@
<a href="{{ url_for('app_gallery') }}" class="btn btn-primary btn-lg px-4 gap-3 mb-2">View App Gallery</a>
<a href="{{ url_for('register_app') }}" class="btn btn-outline-success btn-lg px-4 mb-2">Register New App</a>
<a href="{{ url_for('test_client') }}" class="btn btn-outline-info btn-lg px-4 mb-2">Test Client Launch</a>
<a href="{{ url_for('test_server') }}" class="btn btn-outline-info btn-lg px-4 mb-2">Test Server</a>
</div>
</div>
</div>

87
templates/swagger-ui.html Normal file
View File

@ -0,0 +1,87 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>FHIRVINE API Documentation</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='swagger-ui.css') }}">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css">
<style>
body {
margin: 0;
padding-top: 70px;
}
.navbar-brand img {
height: 40px;
margin-right: 10px;
}
.swagger-ui .topbar {
display: none; /* Hide Swagger's topbar since we have our own navbar */
}
.swagger-ui .info {
margin: 20px 0;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('index') }}">
<img src="{{ url_for('static', filename='FHIRVINE_LOGO.png') }}" alt="FHIRVINE Logo" height="40">
FHIRVINE SMART Proxy
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('app_gallery') }}">App Gallery</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('test_client') }}">Test Client</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('test_server') }}">Test Server</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="configureDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Configure
</a>
<ul class="dropdown-menu" aria-labelledby="configureDropdown">
<li><a class="dropdown-item" href="{{ url_for('security_settings') }}">Security</a></li>
<li><a class="dropdown-item" href="{{ url_for('proxy_settings') }}">Proxy Settings</a></li>
<li><a class="dropdown-item" href="{{ url_for('server_endpoints') }}">Server Endpoints</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('about') }}">About</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="container">
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js"></script>
<script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-standalone-preset.js"></script>
<script>
window.onload = function() {
const ui = SwaggerUIBundle({
url: "{{ url_for('flasgger.apispec_1', _external=True) }}",
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
layout: "StandaloneLayout"
});
window.ui = ui;
}
</script>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@ -0,0 +1,87 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>FHIRVINE API Documentation</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='swagger-ui.css') }}">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css">
<style>
body {
margin: 0;
padding-top: 70px;
}
.navbar-brand img {
height: 40px;
margin-right: 10px;
}
.swagger-ui .topbar {
display: none; /* Hide Swagger's topbar since we have our own navbar */
}
.swagger-ui .info {
margin: 20px 0;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('index') }}">
<img src="{{ url_for('static', filename='FHIRVINE_LOGO.png') }}" alt="FHIRVINE Logo" height="40">
FHIRVINE SMART Proxy
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('app_gallery') }}">App Gallery</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('test_client') }}">Test Client</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('test_server') }}">Test Server</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="configureDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Configure
</a>
<ul class="dropdown-menu" aria-labelledby="configureDropdown">
<li><a class="dropdown-item" href="{{ url_for('security_settings') }}">Security</a></li>
<li><a class="dropdown-item" href="{{ url_for('proxy_settings') }}">Proxy Settings</a></li>
<li><a class="dropdown-item" href="{{ url_for('server_endpoints') }}">Server Endpoints</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('about') }}">About</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="container">
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js"></script>
<script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-standalone-preset.js"></script>
<script>
window.onload = function() {
const ui = SwaggerUIBundle({
url: "{{ url_for('flasgger.apispec_1', _external=True) }}",
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
layout: "StandaloneLayout"
});
window.ui = ui;
}
</script>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@ -10,31 +10,53 @@
</div>
<div class="card-body">
<p class="text-muted text-center small mb-4">
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.
</p>
<form method="POST" action="{{ url_for('test_client') }}" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.client_id.label(class="form-label") }}
<select name="client_id" class="form-control {{ 'is-invalid' if form.client_id.errors else '' }}" required>
<option value="" disabled selected>Select an application</option>
{% for app in apps %}
<option value="{{ app.client_id }}">{{ app.app_name }} ({{ app.client_id }})</option>
{% endfor %}
<select name="client_id" class="form-control {{ 'is-invalid' if form.client_id.errors else '' }}" required aria-describedby="client_id_help">
<option value="" disabled {% if not form.client_id.data %}selected{% endif %}>Select an application</option>
{% if apps %}
{% for app in apps %}
<option value="{{ app.client_id }}" {% if form.client_id.data == app.client_id %}selected{% endif %}>{{ app.app_name }} ({{ app.client_id }}) {% if app.is_test_app %}[Test]{% endif %}</option>
{% endfor %}
{% else %}
<option value="" disabled>No applications available</option>
{% endif %}
</select>
<small id="client_id_help" class="form-text text-muted">Choose an app to test its OAuth2 flow.</small>
{% if form.client_id.errors %}
<div class="invalid-feedback">
{% for error in form.client_id.errors %}<span>{{ error }}</span>{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
{{ form.response_mode.label(class="form-label") }}
<div>
{% for subfield in form.response_mode %}
<div class="form-check form-check-inline">
{{ subfield(class="form-check-input") }}
{{ subfield.label(class="form-check-label") }}
</div>
{% endfor %}
</div>
</div>
<div class="d-grid gap-2 mt-4">
{{ form.submit(class="btn btn-primary btn-lg") }}
<a href="{{ url_for('register_app', test='1') }}" class="btn btn-outline-success">Register Test App</a>
<a href="{{ url_for('app_gallery') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
{% if response_data %}
<hr>
<h5 class="mt-4">Test Response:</h5>
<pre class="border p-3 bg-light" style="max-height: 400px; overflow-y: auto;">
{{ response_data | tojson(indent=2) }}
</pre>
{% endif %}
</div>
</div>
</div>

View File

@ -0,0 +1,39 @@
{% extends "base.html" %}
{% from "_formhelpers.html" import render_field %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card shadow-sm">
<div class="card-header">
<h2 class="text-center mb-0">Test SMART Server</h2>
</div>
<div class="card-body">
<p class="text-muted text-center small mb-4">
Test FHIRVINE as an OAuth2 server by simulating an external client.
</p>
<form method="POST" action="{{ url_for('test_server') }}" novalidate>
{{ form.hidden_tag() }}
{{ render_field(form.client_id) }}
{{ render_field(form.client_secret) }}
{{ render_field(form.redirect_uri) }}
{{ render_field(form.scopes) }}
<div class="d-grid gap-2 mt-4">
{{ form.submit(class="btn btn-primary btn-lg") }}
<a href="{{ url_for('app_gallery') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
{% if response_data %}
<hr>
<h5 class="mt-4">Server Test Response:</h5>
<pre class="border p-3 bg-light" style="max-height: 400px; overflow-y: auto;">
{{ response_data | tojson(indent=2) }}
</pre>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}