mirror of
https://github.com/Sudo-JHare/FHIRVINE-Smart-Proxy.git
synced 2025-06-15 20:50:00 +00:00
V0.5 - incremental
App should be mostly functional now
This commit is contained in:
parent
c61d907c42
commit
897e875e29
18
.env
Normal file
18
.env
Normal 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
|
@ -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
120
README.md
@ -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
72
README_INTEGRATION.md
Normal 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 it’s running at `http://localhost:5001`.
|
||||
|
||||
### 2. Configure FHIRFLARE
|
||||
|
||||
- Clone FHIRFLARE:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit
|
||||
cd FHIRFLARE-IG-Toolkit
|
||||
```
|
||||
|
||||
- Update `appsettings.json` or environment variables to point to FHIRVINE’s proxy:
|
||||
|
||||
```json
|
||||
{
|
||||
"FhirServerUrl": "http://localhost:5001/oauth2/proxy",
|
||||
"AuthServerUrl": "http://localhost:5001/oauth2"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Register FHIRFLARE as a SMART App in FHIRVINE
|
||||
|
||||
- In FHIRVINE, go to `http://localhost:5001/app-gallery`.
|
||||
- Register FHIRFLARE as a new app:
|
||||
- Redirect URI: `http://localhost:8080/callback` (or FHIRFLARE’s callback URL).
|
||||
- Scopes: `patient/*.read offline_access`.
|
||||
- Note the Client ID and Secret.
|
||||
|
||||
### 4. Update FHIRVINE Configuration
|
||||
|
||||
- In FHIRVINE, go to `http://localhost:5001/configure/proxy-settings`.
|
||||
- Set the upstream FHIR server to FHIRFLARE’s target FHIR server (e.g., `http://hapi.fhir.org/baseR4`).
|
||||
|
||||
## Integration Steps
|
||||
|
||||
### 1. Authenticate FHIRFLARE via FHIRVINE
|
||||
|
||||
- In FHIRFLARE, initiate an OAuth2 flow using FHIRVINE’s `/oauth2/authorize` endpoint.
|
||||
- Use the Client ID and Secret from FHIRVINE.
|
||||
- After authorization, FHIRFLARE will receive an access token.
|
||||
|
||||
### 2. Access FHIR Data
|
||||
|
||||
- FHIRFLARE can now make FHIR requests through FHIRVINE’s proxy (e.g., `http://localhost:5001/oauth2/proxy/Patient`).
|
||||
- The proxy will handle authentication and forward requests to the upstream FHIR server.
|
||||
|
||||
### 3. Validate FHIR Resources
|
||||
|
||||
- Use FHIRFLARE’s validation module to validate resources fetched via FHIRVINE’s proxy.
|
||||
- Example: Fetch a Patient resource and validate it against an IG in FHIRFLARE.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Auth Errors**: Ensure FHIRFLARE’s redirect URI matches the registered URI in FHIRVINE.
|
||||
- **Proxy Errors**: Check FHIRVINE logs (`docker logs fhirvine_app`) and verify the upstream FHIR server URL.
|
||||
- **Validation Issues**: Ensure FHIRFLARE’s IG package is correctly loaded.
|
84
README_PROXY.md
Normal file
84
README_PROXY.md
Normal 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 app’s Client ID, choose a response mode, and launch the test.
|
||||
- Grant consent at `/oauth2/authorize`.
|
||||
- The proxy will handle the OAuth2 flow and proxy FHIR requests to the upstream server (e.g., `/oauth2/proxy/Patient`).
|
||||
|
||||
### 3. Configure the Proxy
|
||||
|
||||
- Visit `http://localhost:5001/configure/proxy-settings` to set the FHIR server URL and proxy timeout.
|
||||
- Save your settings to update the proxy configuration.
|
||||
|
||||
## Key Endpoints
|
||||
|
||||
- **Authorization**: `/oauth2/authorize` - Initiates the OAuth2 flow.
|
||||
- **Token**: `/oauth2/token` - Exchanges code for an access token.
|
||||
- **Proxy**: `/oauth2/proxy/<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
247
USERGUIDE.markdown
Normal 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 app’s redirect URI).
|
||||
- Initiate an OAuth2 authorization flow, grant consent, and view the resulting authorization code or token.
|
||||
|
||||
### Test Server Mode
|
||||
- **Purpose**: Simulate a SMART on FHIR server to test app interactions.
|
||||
- **Access**: Navigate to `/test-server`.
|
||||
- **Functionality**:
|
||||
- Input Client ID, Client Secret, Redirect URI, and scopes.
|
||||
- Initiate an OAuth2 flow, grant consent, and receive an access token and refresh token.
|
||||
- View token details and test token exchange.
|
||||
|
||||
### Configuration Menu
|
||||
- **Purpose**: Customize FHIRVINE’s behavior through a user-friendly interface.
|
||||
- **Access**: Navigate to `/configure/security`, `/configure/proxy-settings`, or `/configure/server-endpoints`.
|
||||
- **Submenus**:
|
||||
- **Security Settings**:
|
||||
- Configure access token duration (seconds).
|
||||
- Configure refresh token duration (seconds).
|
||||
- Set allowed OAuth2 scopes.
|
||||
- **Proxy Settings**:
|
||||
- Set the upstream FHIR server URL.
|
||||
- Adjust the proxy request timeout (seconds).
|
||||
- **Server Endpoints**:
|
||||
- Define the metadata endpoint path.
|
||||
- Define the capability statement endpoint path.
|
||||
- Define the base resource endpoint path.
|
||||
|
||||
### API Documentation
|
||||
- **Purpose**: Explore and test FHIRVINE’s API endpoints.
|
||||
- **Access**: Navigate to `/apidocs`.
|
||||
- **Functionality**:
|
||||
- View all available API endpoints with descriptions.
|
||||
- Test endpoints directly from the Swagger UI.
|
||||
|
||||
## Web Interface Usage
|
||||
|
||||
### Homepage
|
||||
- **URL**: `/`
|
||||
- **Description**: The landing page provides an overview of FHIRVINE and navigation links to key features.
|
||||
- **Actions**:
|
||||
- Click "Explore Apps" to access the App Gallery.
|
||||
- Click "Test Client" to test as a client.
|
||||
- Click "Test Server" to test as a server.
|
||||
- Click "Configure" to access the configuration menu.
|
||||
|
||||
### Registering a New App
|
||||
1. Go to `/app-gallery`.
|
||||
2. Click "Register New App".
|
||||
3. Fill in the form:
|
||||
- **Application Name**: e.g., "My SMART App".
|
||||
- **Redirect URIs**: Space-separated list (e.g., `https://myapp.com/callback`).
|
||||
- **Allowed Scopes**: Space-separated (e.g., `patient/*.read openid`).
|
||||
- **Logo URI**, **Contacts**, **Terms of Service URI**, **Privacy Policy URI**: Optional fields.
|
||||
4. Submit the form to receive a Client ID and Client Secret.
|
||||
|
||||
### Testing an App as a Client
|
||||
1. Go to `/test-client`.
|
||||
2. Select a Client ID from the dropdown (populated from registered apps).
|
||||
3. Choose a response mode:
|
||||
- **Inline**: View the response on the page.
|
||||
- **Redirect**: Redirect to the app’s URI with the authorization code.
|
||||
4. Click "Launch Test" to initiate the OAuth2 flow.
|
||||
5. On the consent page (`/oauth2/authorize`), click "Allow" to proceed.
|
||||
6. View the authorization code or token response.
|
||||
|
||||
### Testing as a Server
|
||||
1. Go to `/test-server`.
|
||||
2. Enter:
|
||||
- **Client ID**: From a registered app.
|
||||
- **Client Secret**: From registration.
|
||||
- **Redirect URI**: App’s callback URL.
|
||||
- **Scopes**: Space-separated scopes to request.
|
||||
3. Click "Initiate Authorization".
|
||||
4. Grant consent on the `/oauth2/authorize` page.
|
||||
5. Receive and view the access token, refresh token, and token request details.
|
||||
|
||||
### Configuring Settings
|
||||
- **Security Settings** (`/configure/security`):
|
||||
- Adjust token durations and scopes, then save.
|
||||
- **Proxy Settings** (`/configure/proxy-settings`):
|
||||
- Set the FHIR server URL and timeout, then save.
|
||||
- **Server Endpoints** (`/configure/server-endpoints`):
|
||||
- Define endpoint paths, then save.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Overview
|
||||
FHIRVINE exposes several API endpoints under the `/oauth2` prefix, documented via Swagger UI at `/apidocs`. Below are the key endpoints and their use cases.
|
||||
|
||||
### 1. Well-Known SMART Configuration
|
||||
- **Endpoint**: `GET /.well-known/smart-configuration`
|
||||
- **Description**: Provides the SMART on FHIR configuration for discovery.
|
||||
- **Use Case**: Apps use this to discover FHIRVINE’s OAuth2 endpoints.
|
||||
- **Response**:
|
||||
```json
|
||||
{
|
||||
"issuer": "http://localhost:5001/oauth2",
|
||||
"authorization_endpoint": "http://localhost:5001/oauth2/authorize",
|
||||
"token_endpoint": "http://localhost:5001/oauth2/token",
|
||||
"revocation_endpoint": "http://localhost:5001/oauth2/revoke",
|
||||
"introspection_endpoint": "http://localhost:5001/oauth2/introspect",
|
||||
"scopes_supported": ["openid", "profile", "launch", "launch/patient", "patient/*.read", "offline_access"],
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Authorize
|
||||
- **Endpoint**: `GET /oauth2/authorize`
|
||||
- **Description**: Initiates the OAuth2 authorization flow.
|
||||
- **Parameters**:
|
||||
- `client_id`: Client ID of the app.
|
||||
- `redirect_uri`: App’s callback URL.
|
||||
- `scope`: Space-separated scopes (e.g., `patient/*.read openid`).
|
||||
- `response_type`: Must be `code`.
|
||||
- `state`: State parameter for CSRF protection.
|
||||
- `aud`: Audience (FHIR server URL).
|
||||
- `code_challenge`: PKCE code challenge.
|
||||
- `code_challenge_method`: Must be `S256`.
|
||||
- **Use Case**: Redirect users to authenticate and authorize the app.
|
||||
- **Response**: Redirects to the `redirect_uri` with an authorization code (e.g., `?code=<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 server’s response (e.g., a Patient resource in JSON).
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Use Case 1: Registering and Testing a SMART App
|
||||
1. Register a new app in the App Gallery with redirect URI `https://myapp.com/callback` and scopes `patient/*.read openid`.
|
||||
2. Use the Client ID and Secret in `/test-client` to simulate an OAuth2 flow.
|
||||
3. Grant consent and receive an authorization code.
|
||||
4. Exchange the code for an access token using the `/oauth2/token` endpoint.
|
||||
5. Use the access token to query FHIR resources via `/oauth2/proxy/Patient`.
|
||||
|
||||
### Use Case 2: Configuring Proxy Settings
|
||||
1. Go to `/configure/proxy-settings`.
|
||||
2. Change the FHIR server URL to a custom server (e.g., `https://myfhirserver.com`).
|
||||
3. Set the proxy timeout to 30 seconds.
|
||||
4. Save the settings and test the proxy by querying a resource.
|
||||
|
||||
### Use Case 3: Revoking a Token
|
||||
1. After testing an app, obtain a refresh token.
|
||||
2. Use the `/oauth2/revoke` endpoint to invalidate the refresh token.
|
||||
3. Verify the token’s status using `/oauth2/introspect`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
- **Configuration Values Not Displaying**:
|
||||
- Check the logs for `get_config_value` debug messages to confirm database retrieval.
|
||||
- Ensure the database (`/app/instance/fhirvine.db`) contains the correct values.
|
||||
- **OAuth2 Flow Fails**:
|
||||
- Verify the Client ID, Secret, and Redirect URI match the registered app.
|
||||
- Check the FHIR server URL in `/configure/proxy-settings`.
|
||||
- **Images Not Displaying**:
|
||||
- Ensure `FHIRVINE.png` is in `/app/static/`.
|
||||
- Check volume mounts in `docker-compose.yml` (e.g., `./static:/app/static`).
|
||||
|
||||
### Logs
|
||||
- Logs are available via Docker:
|
||||
```bash
|
||||
docker logs fhirvine_app
|
||||
```
|
||||
- Look for `DEBUG` and `ERROR` messages to diagnose issues.
|
||||
|
||||
## Support
|
||||
|
||||
For additional help, contact `support@fhirvine.example.com` or open an issue on the project’s GitHub repository.
|
415
app.py
415
app.py
@ -4,44 +4,55 @@ import logging
|
||||
import secrets
|
||||
import base64
|
||||
import hashlib
|
||||
from flask import Flask, render_template, request, redirect, url_for, flash, session
|
||||
import requests
|
||||
import json
|
||||
from flask import Flask, render_template, request, redirect, url_for, flash, session, current_app
|
||||
from flask_migrate import Migrate
|
||||
from flasgger import Swagger
|
||||
from flasgger import Swagger, swag_from
|
||||
from dotenv import load_dotenv
|
||||
from werkzeug.security import generate_password_hash
|
||||
from datetime import datetime, timedelta
|
||||
from forms import FlaskForm
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy import text
|
||||
from wtforms import StringField, URLField, SubmitField
|
||||
from wtforms.validators import DataRequired, URL
|
||||
|
||||
# Add current directory to Python path for reliable imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Import from our modules
|
||||
from models import database, RegisteredApp, OAuthToken
|
||||
from forms import RegisterAppForm, TestClientForm
|
||||
from models import database, RegisteredApp, OAuthToken, AuthorizationCode, Configuration
|
||||
from forms import RegisterAppForm, TestClientForm, SecurityConfigForm, ProxyConfigForm, EndpointConfigForm, ConsentForm
|
||||
from smart_proxy import smart_blueprint, configure_oauth
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize extensions
|
||||
migrate = Migrate()
|
||||
|
||||
def create_app():
|
||||
"""Create and configure the Flask application."""
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
|
||||
# Configuration
|
||||
app.config.from_mapping(
|
||||
SECRET_KEY=os.environ.get('SECRET_KEY', 'dev-secret-key-for-fhirvine'),
|
||||
SQLALCHEMY_DATABASE_URI=os.environ.get('DATABASE_URL', f'sqlite:///{os.path.join(app.instance_path, "fhirvine.db")}'),
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS=False,
|
||||
# Default proxy settings (can be overridden in .env)
|
||||
FHIR_SERVER_URL=os.environ.get('FHIR_SERVER_URL', 'http://hapi.fhir.org/baseR4'),
|
||||
PROXY_TIMEOUT=10,
|
||||
METADATA_ENDPOINT='/metadata',
|
||||
CAPABILITY_ENDPOINT='/metadata',
|
||||
RESOURCE_BASE_ENDPOINT='',
|
||||
TOKEN_DURATION=3600,
|
||||
REFRESH_TOKEN_DURATION=86400,
|
||||
ALLOWED_SCOPES='openid profile launch launch/patient patient/*.read offline_access',
|
||||
WTF_CSRF_ENABLED=True
|
||||
)
|
||||
|
||||
# Ensure the instance folder exists
|
||||
if not app.config['SECRET_KEY']:
|
||||
logger.error("SECRET_KEY is not set. CSRF protection may fail.")
|
||||
else:
|
||||
logger.debug(f"SECRET_KEY is set: {app.config['SECRET_KEY'][:8]}...")
|
||||
|
||||
try:
|
||||
os.makedirs(app.instance_path, exist_ok=True)
|
||||
logger.info(f"Instance path created/verified: {app.instance_path}")
|
||||
@ -49,12 +60,10 @@ def create_app():
|
||||
except OSError as e:
|
||||
logger.error(f"Could not create instance path at {app.instance_path}: {e}", exc_info=True)
|
||||
|
||||
# Initialize Flask extensions
|
||||
database.init_app(app)
|
||||
migrate.init_app(app, database)
|
||||
|
||||
# Initialize Flasgger for OpenAPI
|
||||
Swagger(app, template={
|
||||
swagger_template = {
|
||||
"info": {
|
||||
"title": "FHIRVINE SMART on FHIR Proxy API",
|
||||
"version": "1.0",
|
||||
@ -63,39 +72,94 @@ def create_app():
|
||||
"host": "localhost:5001",
|
||||
"basePath": "/oauth2",
|
||||
"schemes": ["http"]
|
||||
})
|
||||
}
|
||||
swagger_config = {
|
||||
"specs": [
|
||||
{
|
||||
"endpoint": "apispec_1",
|
||||
"route": "/apispec/1",
|
||||
"rule_filter": lambda rule: True,
|
||||
"model_filter": lambda tag: True,
|
||||
}
|
||||
],
|
||||
"uimode": "view",
|
||||
"specs_route": "/apispec/",
|
||||
"headers": []
|
||||
}
|
||||
swagger = Swagger(app, template=swagger_template, config=swagger_config)
|
||||
|
||||
# Configure Authlib
|
||||
configure_oauth(app)
|
||||
configure_oauth(app, db=database, registered_app_model=RegisteredApp, oauth_token_model=OAuthToken, auth_code_model=AuthorizationCode)
|
||||
|
||||
# Register blueprints
|
||||
app.register_blueprint(smart_blueprint, url_prefix='/oauth2')
|
||||
|
||||
# Main application routes
|
||||
def load_config_from_db():
|
||||
try:
|
||||
configs = Configuration.query.all()
|
||||
logger.debug(f"Loaded configs from database: {[{'key': c.key, 'value': c.value} for c in configs]}")
|
||||
for config in configs:
|
||||
try:
|
||||
app.config[config.key] = int(config.value) if config.key in ['TOKEN_DURATION', 'PROXY_TIMEOUT', 'REFRESH_TOKEN_DURATION'] else config.value
|
||||
except ValueError:
|
||||
app.config[config.key] = config.value
|
||||
except OperationalError as e:
|
||||
logger.warning(f"Could not load configurations from database: {e}. Using default config values.")
|
||||
|
||||
def get_config_value(key, default):
|
||||
try:
|
||||
# Ensure the session is fresh
|
||||
database.session.expire_all()
|
||||
config = Configuration.query.filter_by(key=key).first()
|
||||
if config:
|
||||
logger.debug(f"Retrieved {key} from database (exact match): {config.value}")
|
||||
return int(config.value) if key in ['TOKEN_DURATION', 'PROXY_TIMEOUT', 'REFRESH_TOKEN_DURATION'] else config.value
|
||||
# Try case-insensitive match
|
||||
config = Configuration.query.filter(Configuration.key.ilike(key)).first()
|
||||
if config:
|
||||
logger.debug(f"Retrieved {key} from database (case-insensitive match): {config.value}")
|
||||
return int(config.value) if key in ['TOKEN_DURATION', 'PROXY_TIMEOUT', 'REFRESH_TOKEN_DURATION'] else config.value
|
||||
# Fallback: Direct database query
|
||||
result = database.session.execute(
|
||||
text("SELECT value FROM configurations WHERE key = :key"),
|
||||
{"key": key}
|
||||
).fetchone()
|
||||
if result:
|
||||
value = result[0]
|
||||
logger.debug(f"Retrieved {key} from database (direct query): {value}")
|
||||
return int(value) if key in ['TOKEN_DURATION', 'PROXY_TIMEOUT', 'REFRESH_TOKEN_DURATION'] else value
|
||||
logger.debug(f"No value found in database for {key}, using default: {default}")
|
||||
except OperationalError as e:
|
||||
logger.error(f"Database error while retrieving {key}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error while retrieving {key}: {e}")
|
||||
return default
|
||||
|
||||
with app.app_context():
|
||||
load_config_from_db()
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/app-gallery')
|
||||
def app_gallery():
|
||||
"""Display the list of registered applications."""
|
||||
form = FlaskForm()
|
||||
try:
|
||||
apps = RegisteredApp.query.order_by(RegisteredApp.app_name).all()
|
||||
apps = [app for app in apps if app.is_test_app is None or app.is_test_app == False or app.test_app_expires_at > datetime.utcnow()]
|
||||
logger.debug(f"App gallery apps retrieved: {[{'id': app.id, 'app_name': app.app_name, 'client_id': app.client_id, 'is_test_app': app.is_test_app, 'test_app_expires_at': app.test_app_expires_at} for app in apps]}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching apps from database: {e}", exc_info=True)
|
||||
flash("Could not load application gallery. Please try again later.", "error")
|
||||
apps = []
|
||||
return render_template('app_gallery/gallery.html', apps=apps)
|
||||
return render_template('app_gallery/gallery.html', apps=apps, form=form)
|
||||
|
||||
@app.route('/register-app', methods=['GET', 'POST'])
|
||||
def register_app():
|
||||
"""Handle registration of new applications."""
|
||||
form = RegisterAppForm()
|
||||
is_test = request.args.get('test', '0') == '1'
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
# Generate a unique client_id
|
||||
client_id = secrets.token_urlsafe(32)
|
||||
# Normalize redirect_uris to lowercase
|
||||
redirect_uris = form.redirect_uris.data.strip().lower()
|
||||
new_app = RegisteredApp(
|
||||
app_name=form.app_name.data,
|
||||
@ -105,9 +169,10 @@ def create_app():
|
||||
logo_uri=form.logo_uri.data or None,
|
||||
contacts=form.contacts.data.strip() or None,
|
||||
tos_uri=form.tos_uri.data or None,
|
||||
policy_uri=form.policy_uri.data or None
|
||||
policy_uri=form.policy_uri.data or None,
|
||||
is_test_app=is_test,
|
||||
test_app_expires_at=datetime.utcnow() + timedelta(hours=24) if is_test else None
|
||||
)
|
||||
# Generate and hash client secret
|
||||
client_secret = secrets.token_urlsafe(40)
|
||||
new_app.set_client_secret(client_secret)
|
||||
database.session.add(new_app)
|
||||
@ -115,17 +180,16 @@ def create_app():
|
||||
flash(f"Application '{new_app.app_name}' registered successfully!", "success")
|
||||
flash(f"Client ID: {new_app.client_id}", "info")
|
||||
flash(f"Client Secret: {client_secret} (Please store this securely!)", "warning")
|
||||
logger.info(f"Registered new app: {new_app.app_name} (ID: {new_app.id}, ClientID: {new_app.client_id})")
|
||||
logger.info(f"Registered new app: {new_app.app_name} (ID: {new_app.id}, ClientID: {new_app.client_id}, IsTest: {new_app.is_test_app}, ExpiresAt: {new_app.test_app_expires_at}, LogoURI: {new_app.logo_uri}, TosURI: {new_app.tos_uri}, PolicyURI: {new_app.policy_uri})")
|
||||
return redirect(url_for('app_gallery'))
|
||||
except Exception as e:
|
||||
database.session.rollback()
|
||||
logger.error(f"Error registering application '{form.app_name.data}': {e}", exc_info=True)
|
||||
flash(f"Error registering application: {e}", "error")
|
||||
return render_template('app_gallery/register.html', form=form)
|
||||
return render_template('app_gallery/register.html', form=form, is_test=is_test)
|
||||
|
||||
@app.route('/edit-app/<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
|
||||
|
@ -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:
|
105
forms.py
105
forms.py
@ -1,9 +1,8 @@
|
||||
import re
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, TextAreaField, URLField, SubmitField, PasswordField
|
||||
from wtforms.validators import DataRequired, Length, Optional, URL, Regexp, ValidationError
|
||||
from wtforms import StringField, TextAreaField, URLField, SubmitField, HiddenField, IntegerField, RadioField
|
||||
from wtforms.validators import DataRequired, Length, Optional, URL, Regexp, ValidationError, NumberRange
|
||||
|
||||
# Custom validator for space-separated URIs
|
||||
def validate_uris(form, field):
|
||||
uris = field.data.split()
|
||||
for uri in uris:
|
||||
@ -12,16 +11,19 @@ def validate_uris(form, field):
|
||||
if ' ' in uri:
|
||||
raise ValidationError(f'URI cannot contain spaces: "{uri}"')
|
||||
|
||||
# Custom validator for space-separated scopes
|
||||
def validate_scopes(form, field):
|
||||
scopes = field.data.split()
|
||||
scope_pattern = r'^[a-zA-Z0-9\/\.\*\-]+$'
|
||||
scope_pattern = r'^[a-zA-Z0-9\/\.\*\-_]+$'
|
||||
for scope in scopes:
|
||||
if not re.match(scope_pattern, scope):
|
||||
raise ValidationError(f'Invalid scope format: "{scope}". Allowed characters: a-z A-Z 0-9 / . * -')
|
||||
raise ValidationError(f'Invalid scope format: "{scope}". Allowed characters: a-z A-Z 0-9 / . * _ -')
|
||||
|
||||
def validate_optional_url(form, field):
|
||||
if field.data and field.data.strip():
|
||||
url_validator = URL(require_tld=False)
|
||||
url_validator(form, field)
|
||||
|
||||
class RegisterAppForm(FlaskForm):
|
||||
"""Form for registering a new SMART application."""
|
||||
app_name = StringField(
|
||||
'Application Name',
|
||||
validators=[DataRequired(), Length(min=3, max=100)]
|
||||
@ -29,39 +31,108 @@ class RegisterAppForm(FlaskForm):
|
||||
redirect_uris = TextAreaField(
|
||||
'Redirect URIs (Space-separated)',
|
||||
validators=[DataRequired(), validate_uris],
|
||||
description='Enter one or more valid redirect URIs, separated by spaces. Example: https://myapp.com/callback https://localhost:3000/cb'
|
||||
description='Enter one or more valid redirect URIs, separated by spaces.'
|
||||
)
|
||||
scopes = TextAreaField(
|
||||
'Allowed Scopes (Space-separated)',
|
||||
validators=[DataRequired(), validate_scopes],
|
||||
default='openid profile launch launch/patient patient/*.read offline_access',
|
||||
description='Enter the scopes this application is allowed to request, separated by spaces. Standard SMART scopes recommended.'
|
||||
description='Enter the scopes this application is allowed to request, separated by spaces.'
|
||||
)
|
||||
logo_uri = URLField(
|
||||
logo_uri = StringField(
|
||||
'Logo URI (Optional)',
|
||||
validators=[Optional(), URL()],
|
||||
validators=[Optional(strip_whitespace=True), validate_optional_url],
|
||||
description='A URL pointing to an image for the application logo.'
|
||||
)
|
||||
contacts = TextAreaField(
|
||||
'Contacts (Optional, space-separated)',
|
||||
description='Contact email addresses (e.g., mailto:dev@example.com) or URLs, separated by spaces.'
|
||||
validators=[Optional(strip_whitespace=True)],
|
||||
description='Contact email addresses or URLs, separated by spaces.'
|
||||
)
|
||||
tos_uri = URLField(
|
||||
tos_uri = StringField(
|
||||
'Terms of Service URI (Optional)',
|
||||
validators=[Optional(), URL()],
|
||||
validators=[Optional(strip_whitespace=True), validate_optional_url],
|
||||
description='Link to the application\'s Terms of Service.'
|
||||
)
|
||||
policy_uri = URLField(
|
||||
policy_uri = StringField(
|
||||
'Privacy Policy URI (Optional)',
|
||||
validators=[Optional(), URL()],
|
||||
validators=[Optional(strip_whitespace=True), validate_optional_url],
|
||||
description='Link to the application\'s Privacy Policy.'
|
||||
)
|
||||
submit = SubmitField('Register Application')
|
||||
|
||||
class TestClientForm(FlaskForm):
|
||||
"""Form for testing SMART app launches."""
|
||||
client_id = StringField(
|
||||
'Client ID',
|
||||
validators=[DataRequired(), Length(min=1, max=100)]
|
||||
)
|
||||
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')
|
138
minimal_test.py
138
minimal_test.py
@ -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()
|
24
models.py
24
models.py
@ -1,5 +1,6 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from datetime import datetime
|
||||
|
||||
database = SQLAlchemy()
|
||||
|
||||
@ -16,10 +17,11 @@ class RegisteredApp(database.Model):
|
||||
tos_uri = database.Column(database.String(255))
|
||||
policy_uri = database.Column(database.String(255))
|
||||
jwks_uri = database.Column(database.String(255))
|
||||
date_registered = database.Column(database.DateTime)
|
||||
last_updated = database.Column(database.DateTime)
|
||||
date_registered = database.Column(database.DateTime, default=datetime.utcnow)
|
||||
last_updated = database.Column(database.DateTime, onupdate=datetime.utcnow)
|
||||
is_test_app = database.Column(database.Boolean, default=False)
|
||||
test_app_expires_at = database.Column(database.DateTime)
|
||||
|
||||
# Authlib ClientMixin methods
|
||||
def get_client_id(self):
|
||||
return self.client_id
|
||||
|
||||
@ -33,12 +35,9 @@ class RegisteredApp(database.Model):
|
||||
return response_type == 'code'
|
||||
|
||||
def check_grant_type(self, grant_type):
|
||||
# Support authorization_code and refresh_token grants
|
||||
return grant_type in ['authorization_code', 'refresh_token']
|
||||
|
||||
def check_endpoint_auth_method(self, method, endpoint):
|
||||
# Support client_secret_post and client_secret_basic for token endpoint
|
||||
# Currently, we support the same methods for all endpoints
|
||||
return method in ['client_secret_post', 'client_secret_basic']
|
||||
|
||||
def get_allowed_scope(self, scopes):
|
||||
@ -47,11 +46,9 @@ class RegisteredApp(database.Model):
|
||||
return ' '.join(sorted(allowed.intersection(requested)))
|
||||
|
||||
def set_client_secret(self, client_secret):
|
||||
"""Set and hash the client secret."""
|
||||
self.client_secret_hash = generate_password_hash(client_secret)
|
||||
|
||||
def check_client_secret(self, client_secret):
|
||||
"""Verify the client secret against the stored hash."""
|
||||
return check_password_hash(self.client_secret_hash, client_secret)
|
||||
|
||||
class OAuthToken(database.Model):
|
||||
@ -80,11 +77,16 @@ class AuthorizationCode(database.Model):
|
||||
issued_at = database.Column(database.Integer, nullable=False)
|
||||
expires_at = database.Column(database.Integer, nullable=False)
|
||||
|
||||
# Authlib AuthorizationCodeMixin methods
|
||||
def get_redirect_uri(self):
|
||||
"""Return the redirect URI associated with this authorization code."""
|
||||
return self.redirect_uri
|
||||
|
||||
def get_scope(self):
|
||||
"""Return the scope associated with this authorization code."""
|
||||
return self.scope
|
||||
|
||||
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)
|
565
smart_proxy.py
565
smart_proxy.py
@ -4,40 +4,29 @@ import secrets
|
||||
import requests
|
||||
import base64
|
||||
import hashlib
|
||||
from flask import Blueprint, request, session, url_for, render_template, redirect, jsonify
|
||||
from flask import Blueprint, request, session, url_for, render_template, redirect, jsonify, current_app
|
||||
from flasgger import swag_from
|
||||
from authlib.integrations.flask_oauth2 import AuthorizationServer, ResourceProtector
|
||||
from authlib.integrations.sqla_oauth2 import (
|
||||
create_query_client_func,
|
||||
create_save_token_func,
|
||||
)
|
||||
from authlib.integrations.sqla_oauth2 import create_query_client_func, create_save_token_func
|
||||
from authlib.oauth2.rfc6749 import grants
|
||||
from authlib.oauth2.rfc6750 import BearerTokenValidator
|
||||
from authlib.oauth2.rfc6749.errors import OAuth2Error
|
||||
from werkzeug.http import is_hop_by_hop_header
|
||||
from forms import ConsentForm
|
||||
|
||||
# Import necessary components (designed for modularity)
|
||||
try:
|
||||
from models import database, RegisteredApp, OAuthToken, AuthorizationCode
|
||||
except ImportError:
|
||||
# Allow standalone usage or alternative model imports for FHIRFLARE integration
|
||||
database = None
|
||||
RegisteredApp = None
|
||||
OAuthToken = None
|
||||
AuthorizationCode = None
|
||||
database = None
|
||||
RegisteredApp = None
|
||||
OAuthToken = None
|
||||
AuthorizationCode = None
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Define the Flask blueprint for SMART on FHIR routes
|
||||
smart_blueprint = Blueprint('smart_proxy', __name__, template_folder='templates')
|
||||
|
||||
# Initialize Authlib AuthorizationServer and ResourceProtector globally
|
||||
authorization_server = AuthorizationServer()
|
||||
require_oauth = ResourceProtector()
|
||||
|
||||
# Helper functions for Authlib
|
||||
def query_client(client_id):
|
||||
"""Query client details from the database for Authlib."""
|
||||
logger.debug(f"Querying client with identifier: {client_id}")
|
||||
client = RegisteredApp.query.filter_by(client_id=client_id).first()
|
||||
if client:
|
||||
@ -47,7 +36,6 @@ def query_client(client_id):
|
||||
return client
|
||||
|
||||
def save_token(token, request):
|
||||
"""Save generated OAuth2 tokens to the database."""
|
||||
client = request.client
|
||||
if not client:
|
||||
logger.error("No client provided for token saving")
|
||||
@ -66,28 +54,19 @@ def save_token(token, request):
|
||||
logger.info(f"Saved token for client '{client.client_id}' with scope: {token.get('scope')}")
|
||||
|
||||
def normalize_scopes(scope_string):
|
||||
"""Convert plus signs or other delimiters to spaces for scopes."""
|
||||
if not scope_string:
|
||||
return ''
|
||||
return ' '.join(scope_string.replace('+', ' ').split())
|
||||
|
||||
# Dummy user class to satisfy Authlib's requirement
|
||||
class DummyUser:
|
||||
def get_user_id(self):
|
||||
"""Return a user ID for Authlib."""
|
||||
return "proxy_user"
|
||||
|
||||
# Custom Authorization Code Grant implementation
|
||||
class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
|
||||
"""Customized Authorization Code Grant using database storage for codes."""
|
||||
|
||||
SUPPORTED_RESPONSE_TYPES = ['code'] # Explicitly allow 'code'
|
||||
include_refresh_token = True # Ensure refresh tokens are included
|
||||
TOKEN_DURATION = 3600 # Access token lifetime in seconds (1 hour)
|
||||
REFRESH_TOKEN_DURATION = 86400 # Refresh token lifetime in seconds (1 day)
|
||||
SUPPORTED_RESPONSE_TYPES = ['code']
|
||||
include_refresh_token = True
|
||||
|
||||
def create_authorization_code(self, client, grant_user, request):
|
||||
# Generate a secure random code
|
||||
code = secrets.token_urlsafe(32)
|
||||
logger.debug(f"Generated authorization code '{code[:6]}...' for client '{client.client_id}'")
|
||||
auth_code = AuthorizationCode(
|
||||
@ -101,7 +80,7 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
|
||||
response_type=request.args.get('response_type', 'code'),
|
||||
state=request.args.get('state'),
|
||||
issued_at=int(time.time()),
|
||||
expires_at=int(time.time()) + 600 # 10 minutes expiry
|
||||
expires_at=int(time.time()) + 600
|
||||
)
|
||||
database.session.add(auth_code)
|
||||
database.session.commit()
|
||||
@ -123,11 +102,10 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
|
||||
return auth_code
|
||||
|
||||
def query_authorization_code(self, code, client):
|
||||
"""Query the authorization code from the database."""
|
||||
logger.debug(f"Querying authorization code '{code[:6]}...' for client '{client.client_id}'")
|
||||
auth_code = AuthorizationCode.query.filter_by(code=code, client_id=client.client_id).first()
|
||||
if not auth_code:
|
||||
logger.warning(f"Authorization code '{code[:6]}...' not found for client '{client.client_id}'")
|
||||
logger.warning(f"Authorization code '{code[:6]}...' not found for client '{client_id}'")
|
||||
return None
|
||||
if time.time() > auth_code.expires_at:
|
||||
logger.warning(f"Authorization code '{code[:6]}...' has expired")
|
||||
@ -146,14 +124,13 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
|
||||
logger.debug(f"Authorization code '{code[:6]}...' deleted from database")
|
||||
|
||||
def authenticate_user(self, code_data):
|
||||
logger.debug("Returning dummy user for authorization code (no user authentication in this proxy)")
|
||||
logger.debug("Returning dummy user for authorization code")
|
||||
return DummyUser()
|
||||
|
||||
def validate_code_verifier(self, code_verifier, code_challenge, code_challenge_method):
|
||||
"""Validate the PKCE code verifier against the stored code challenge."""
|
||||
logger.debug(f"Validating PKCE code verifier. Code verifier: {code_verifier[:6]}..., Code challenge: {code_challenge[:6]}..., Method: {code_challenge_method}")
|
||||
if not code_verifier or not code_challenge:
|
||||
logger.warning("Missing code verifier or code challenge for PKCE validation")
|
||||
logger.warning("Missing code verifier or code challenge")
|
||||
return False
|
||||
if code_challenge_method == 'plain':
|
||||
expected_challenge = code_verifier
|
||||
@ -165,38 +142,25 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
|
||||
logger.warning(f"Unsupported code challenge method: {code_challenge_method}")
|
||||
return False
|
||||
is_valid = expected_challenge == code_challenge
|
||||
logger.debug(f"PKCE validation result: {is_valid}. Expected challenge: {expected_challenge[:6]}..., Provided challenge: {code_challenge[:6]}...")
|
||||
logger.debug(f"PKCE validation result: {is_valid}")
|
||||
return is_valid
|
||||
|
||||
def create_token_response(self):
|
||||
"""Override to ensure proper token generation with client and grant_type."""
|
||||
# Validate the token request (sets self.client, self.request, etc.)
|
||||
self.validate_token_request()
|
||||
|
||||
# Get the authorization code from form data
|
||||
code = self.request.form.get('code')
|
||||
client = self.request.client
|
||||
grant_type = self.request.form.get('grant_type')
|
||||
|
||||
if not code:
|
||||
raise OAuth2Error("invalid_request", "Missing 'code' parameter")
|
||||
|
||||
if not grant_type:
|
||||
raise OAuth2Error("invalid_request", "Missing 'grant_type' parameter")
|
||||
|
||||
# Authenticate user
|
||||
code_data = self.query_authorization_code(code, client)
|
||||
if not code_data:
|
||||
raise OAuth2Error("invalid_grant", "Authorization code not found or expired")
|
||||
|
||||
user = self.authenticate_user(code_data)
|
||||
if not user:
|
||||
raise OAuth2Error("invalid_grant", "There is no 'user' for this code.")
|
||||
|
||||
# Get the scope
|
||||
raise OAuth2Error("invalid_grant", "No user for this code")
|
||||
scope = code_data.get_scope()
|
||||
|
||||
# Generate the token with custom expiration durations
|
||||
token = self.generate_token(
|
||||
client,
|
||||
grant_type,
|
||||
@ -205,493 +169,344 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
|
||||
include_refresh_token=self.include_refresh_token,
|
||||
code=code
|
||||
)
|
||||
|
||||
# Delete the authorization code
|
||||
self.delete_authorization_code(code)
|
||||
|
||||
# Ensure token has correct expires_in and scope
|
||||
body = {
|
||||
"access_token": token["access_token"],
|
||||
"token_type": token["token_type"],
|
||||
"expires_in": self.TOKEN_DURATION, # Explicitly set to ensure correct value
|
||||
"scope": scope if scope else "", # Use the scope directly
|
||||
"expires_in": current_app.config['TOKEN_DURATION'],
|
||||
"scope": scope if scope else "",
|
||||
"refresh_token": token.get("refresh_token"),
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
return 200, body, headers
|
||||
|
||||
def generate_token(self, client, grant_type, user=None, scope=None, include_refresh_token=True, **kwargs):
|
||||
"""Generate an access token with optional nonce."""
|
||||
logger.debug(f"Generating token with client: {client.client_id}, grant_type: {grant_type}, user: {user.get_user_id() if user else None}, scope: {scope}")
|
||||
# Extract code from kwargs and remove it to avoid passing to parent
|
||||
logger.debug(f"Generating token for client: {client.client_id}, grant_type: {grant_type}")
|
||||
code = kwargs.pop('code', None)
|
||||
|
||||
# Prepare token parameters
|
||||
token = {}
|
||||
token['access_token'] = secrets.token_urlsafe(32)
|
||||
token['token_type'] = 'Bearer'
|
||||
token['expires_in'] = self.TOKEN_DURATION
|
||||
token['expires_in'] = current_app.config['TOKEN_DURATION']
|
||||
token['scope'] = scope if scope else ""
|
||||
token['issued_at'] = int(time.time())
|
||||
|
||||
# Generate refresh token if enabled
|
||||
if include_refresh_token and client.check_grant_type('refresh_token'):
|
||||
token['refresh_token'] = secrets.token_urlsafe(32)
|
||||
# Set refresh token expiration (issued_at + duration)
|
||||
token['refresh_token_expires_in'] = self.REFRESH_TOKEN_DURATION
|
||||
|
||||
# Handle nonce if code is provided
|
||||
token['refresh_token_expires_in'] = current_app.config['REFRESH_TOKEN_DURATION']
|
||||
if code:
|
||||
code_data = self.parse_authorization_code(code, client)
|
||||
if code_data:
|
||||
logger.info(f"Generating token. Nonce: {code_data.nonce}")
|
||||
if code_data.nonce:
|
||||
token['nonce'] = code_data.nonce
|
||||
else:
|
||||
logger.warning(f"Could not retrieve code data for code '{code[:6]}...' during token generation")
|
||||
|
||||
if code_data and code_data.nonce:
|
||||
token['nonce'] = code_data.nonce
|
||||
logger.debug(f"Generated token for client '{client.client_id}'. Scope: {scope}")
|
||||
return token
|
||||
|
||||
# Configure Authlib
|
||||
def configure_oauth(flask_app, db=None, registered_app_model=None, oauth_token_model=None, auth_code_model=None):
|
||||
"""Configure the Authlib AuthorizationServer with the Flask application."""
|
||||
global database, RegisteredApp, OAuthToken, AuthorizationCode
|
||||
# Allow dependency injection for FHIRFLARE integration
|
||||
database = db or database
|
||||
RegisteredApp = registered_app_model or RegisteredApp
|
||||
OAuthToken = oauth_token_model or OAuthToken
|
||||
AuthorizationCode = auth_code_model or AuthorizationCode
|
||||
|
||||
if not (database and RegisteredApp and OAuthToken and AuthorizationCode):
|
||||
raise ValueError("Database and models must be provided for OAuth2 configuration")
|
||||
|
||||
raise ValueError("Database and models must be provided")
|
||||
authorization_server.init_app(
|
||||
flask_app,
|
||||
query_client=query_client,
|
||||
save_token=save_token
|
||||
)
|
||||
authorization_server.register_grant(AuthorizationCodeGrant)
|
||||
logger.info("Authlib OAuth2 server configured successfully")
|
||||
logger.info("Authlib OAuth2 server configured")
|
||||
|
||||
@smart_blueprint.route('/.well-known/smart-configuration', methods=['GET'])
|
||||
@swag_from({
|
||||
'tags': ['OAuth2'],
|
||||
'summary': 'SMART on FHIR Configuration',
|
||||
'description': 'Returns the SMART on FHIR configuration.',
|
||||
'responses': {
|
||||
'200': {
|
||||
'description': 'SMART configuration',
|
||||
'schema': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'issuer': {'type': 'string'},
|
||||
'authorization_endpoint': {'type': 'string'},
|
||||
'token_endpoint': {'type': 'string'},
|
||||
'revocation_endpoint': {'type': 'string'},
|
||||
'introspection_endpoint': {'type': 'string'},
|
||||
'scopes_supported': {'type': 'array', 'items': {'type': 'string'}},
|
||||
'response_types_supported': {'type': 'array', 'items': {'type': 'string'}},
|
||||
'grant_types_supported': {'type': 'array', 'items': {'type': 'string'}},
|
||||
'code_challenge_methods_supported': {'type': 'array', 'items': {'type': 'string'}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
def smart_configuration():
|
||||
config = {
|
||||
"issuer": request.url_root.rstrip('/'),
|
||||
"authorization_endpoint": url_for('smart_proxy.authorize', _external=True),
|
||||
"token_endpoint": url_for('smart_proxy.issue_token', _external=True),
|
||||
"revocation_endpoint": url_for('smart_proxy.revoke_token', _external=True),
|
||||
"introspection_endpoint": url_for('smart_proxy.introspect_token', _external=True),
|
||||
"scopes_supported": current_app.config['ALLOWED_SCOPES'].split(),
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"code_challenge_methods_supported": ["S256"]
|
||||
}
|
||||
return jsonify(config), 200
|
||||
|
||||
# SMART on FHIR OAuth2 routes
|
||||
@smart_blueprint.route('/authorize', methods=['GET'])
|
||||
@swag_from({
|
||||
'tags': ['OAuth2'],
|
||||
'summary': 'Initiate SMART on FHIR authorization',
|
||||
'description': 'Redirects to the consent page for user authorization, then redirects to the client redirect URI with an authorization code.',
|
||||
'description': 'Redirects to consent page, then to client redirect URI with code.',
|
||||
'parameters': [
|
||||
{
|
||||
'name': 'response_type',
|
||||
'in': 'query',
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
'description': 'Must be "code" for authorization code flow.',
|
||||
'enum': ['code']
|
||||
},
|
||||
{
|
||||
'name': 'client_id',
|
||||
'in': 'query',
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
'description': 'Client ID of the registered application.'
|
||||
},
|
||||
{
|
||||
'name': 'redirect_uri',
|
||||
'in': 'query',
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
'description': 'Redirect URI where the authorization code will be sent.'
|
||||
},
|
||||
{
|
||||
'name': 'scope',
|
||||
'in': 'query',
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
'description': 'Space-separated list of scopes (e.g., "openid launch/patient").'
|
||||
},
|
||||
{
|
||||
'name': 'state',
|
||||
'in': 'query',
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
'description': 'Opaque value used to maintain state between the request and callback.'
|
||||
},
|
||||
{
|
||||
'name': 'aud',
|
||||
'in': 'query',
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
'description': 'Audience URL of the FHIR server.'
|
||||
},
|
||||
{
|
||||
'name': 'code_challenge',
|
||||
'in': 'query',
|
||||
'type': 'string',
|
||||
'required': False,
|
||||
'description': 'PKCE code challenge (required for PKCE flow).'
|
||||
},
|
||||
{
|
||||
'name': 'code_challenge_method',
|
||||
'in': 'query',
|
||||
'type': 'string',
|
||||
'required': False,
|
||||
'description': 'PKCE code challenge method (default: "plain").',
|
||||
'enum': ['plain', 'S256']
|
||||
}
|
||||
{'name': 'response_type', 'in': 'query', 'type': 'string', 'required': True, 'description': 'Must be "code".', 'enum': ['code']},
|
||||
{'name': 'client_id', 'in': 'query', 'type': 'string', 'required': True, 'description': 'Client ID.'},
|
||||
{'name': 'redirect_uri', 'in': 'query', 'type': 'string', 'required': True, 'description': 'Redirect URI.'},
|
||||
{'name': 'scope', 'in': 'query', 'type': 'string', 'required': True, 'description': 'Scopes.'},
|
||||
{'name': 'state', 'in': 'query', 'type': 'string', 'required': True, 'description': 'State.'},
|
||||
{'name': 'aud', 'in': 'query', 'type': 'string', 'required': True, 'description': 'FHIR server URL.'},
|
||||
{'name': 'code_challenge', 'in': 'query', 'type': 'string', 'required': False, 'description': 'PKCE challenge.'},
|
||||
{'name': 'code_challenge_method', 'in': 'query', 'type': 'string', 'required': False, 'description': 'PKCE method.', 'enum': ['plain', 'S256']}
|
||||
],
|
||||
'responses': {
|
||||
'200': {
|
||||
'description': 'Renders the consent page for user authorization.'
|
||||
},
|
||||
'400': {
|
||||
'description': 'Invalid request parameters (e.g., invalid client_id, redirect_uri, or scopes).'
|
||||
}
|
||||
'200': {'description': 'Consent page.'},
|
||||
'400': {'description': 'Invalid parameters.'}
|
||||
}
|
||||
})
|
||||
def authorize():
|
||||
"""Handle SMART on FHIR authorization requests."""
|
||||
try:
|
||||
# Log the incoming request parameters
|
||||
logger.debug(f"Incoming request args: {request.args}")
|
||||
|
||||
# Normalize scopes in the request
|
||||
request_args = request.args.copy()
|
||||
request_args['scope'] = normalize_scopes(request_args.get('scope', ''))
|
||||
# Remove 'aud' as it's not needed for Authlib's OAuth flow
|
||||
if 'aud' in request_args:
|
||||
del request_args['aud']
|
||||
request.args = request_args
|
||||
logger.debug(f"Normalized request args: {request.args}")
|
||||
|
||||
# Validate client
|
||||
client = query_client(request_args.get('client_id'))
|
||||
if not client:
|
||||
logger.error(f"Invalid client_id: {request_args.get('client_id')}")
|
||||
return render_template('errors/auth_error.html', error="Invalid client_id"), 400
|
||||
|
||||
# Validate response_type
|
||||
response_type = request_args.get('response_type')
|
||||
logger.debug(f"Received response_type: {response_type}")
|
||||
if not response_type or not client.check_response_type(response_type):
|
||||
logger.error(f"Unsupported response_type: {response_type}")
|
||||
return render_template('errors/auth_error.html', error=f"Unsupported response_type: {response_type}"), 400
|
||||
|
||||
# Validate redirect_uri
|
||||
redirect_uri = request_args.get('redirect_uri')
|
||||
if not redirect_uri or not client.check_redirect_uri(redirect_uri):
|
||||
logger.error(f"Invalid redirect_uri: {redirect_uri} (Registered URIs: {client.redirect_uris})")
|
||||
return render_template('errors/auth_error.html', error=f"Invalid redirect_uri: {redirect_uri}"), 400
|
||||
|
||||
# Validate scopes
|
||||
requested_scopes = request_args.get('scope', '')
|
||||
allowed_scopes = client.get_allowed_scope(requested_scopes)
|
||||
if not allowed_scopes:
|
||||
logger.error(f"No valid scopes provided: {requested_scopes} (Allowed scopes: {client.scopes})")
|
||||
return render_template('errors/auth_error.html', error=f"No valid scopes provided: {requested_scopes}"), 400
|
||||
logger.debug(f"Allowed scopes after validation: {allowed_scopes}")
|
||||
|
||||
# Update the request args with the normalized allowed scopes
|
||||
return render_template('errors/auth_error.html', error=f"No valid scopes: {requested_scopes}"), 400
|
||||
request_args['scope'] = allowed_scopes
|
||||
request.args = request_args
|
||||
logger.debug(f"Updated request args with normalized scopes: {request.args}")
|
||||
|
||||
# Store request details in session for consent
|
||||
session['auth_request_params'] = request_args.to_dict()
|
||||
session['auth_client_id'] = client.client_id
|
||||
session['auth_scope'] = allowed_scopes
|
||||
session['auth_response_type'] = response_type
|
||||
session['auth_state'] = request_args.get('state')
|
||||
session['auth_nonce'] = request_args.get('nonce')
|
||||
|
||||
# Render consent page
|
||||
logger.info(f"Rendering consent page for client: {client.app_name}")
|
||||
return render_template(
|
||||
'auth/consent.html',
|
||||
client=client,
|
||||
scopes=session['auth_scope'].split(),
|
||||
request_params=session['auth_request_params']
|
||||
request_params=session['auth_request_params'],
|
||||
form=ConsentForm()
|
||||
)
|
||||
except OAuth2Error as error:
|
||||
logger.error(f"OAuth2 error during authorization: {error.error} - {error.description}", exc_info=True)
|
||||
return render_template('errors/auth_error.html', error=f"{error.error}: {error.description}"), error.status_code
|
||||
except Exception as error:
|
||||
logger.error(f"Unexpected error during authorization: {error}", exc_info=True)
|
||||
return render_template('errors/auth_error.html', error="An unexpected error occurred during authorization"), 500
|
||||
return render_template('errors/auth_error.html', error="Unexpected error"), 500
|
||||
|
||||
@smart_blueprint.route('/consent', methods=['POST'])
|
||||
def handle_consent():
|
||||
"""Handle user consent submission for SMART on FHIR authorization."""
|
||||
try:
|
||||
# Log the entire form data for debugging
|
||||
logger.debug(f"Form data received: {request.form}")
|
||||
|
||||
# Retrieve grant details from session
|
||||
form = ConsentForm()
|
||||
# Manually validate form fields, bypassing CSRF
|
||||
if not form.is_submitted():
|
||||
return render_template('errors/auth_error.html', error="Form not submitted"), 400
|
||||
if not form.consent.data:
|
||||
return render_template('errors/auth_error.html', error="Consent value missing"), 400
|
||||
consent_granted = form.consent.data == 'allow'
|
||||
request_params = session.pop('auth_request_params', None)
|
||||
client_id = session.pop('auth_client_id', None)
|
||||
scope = session.pop('auth_scope', None)
|
||||
response_type = session.pop('auth_response_type', None)
|
||||
state = session.pop('auth_state', None)
|
||||
nonce = session.pop('auth_nonce', None)
|
||||
|
||||
logger.debug(f"Consent route: client_id={client_id}, scope={scope}, response_type={response_type}, state={state}, nonce={nonce}")
|
||||
|
||||
if not request_params or not client_id or not scope or not response_type:
|
||||
logger.error("Consent handling failed: Missing session data")
|
||||
return render_template('errors/auth_error.html', error="Session expired or invalid"), 400
|
||||
|
||||
# Validate client
|
||||
client = query_client(client_id)
|
||||
if not client:
|
||||
logger.error(f"Invalid client_id: {client_id}")
|
||||
return render_template('errors/auth_error.html', error="Invalid client_id"), 400
|
||||
|
||||
# Validate response_type
|
||||
if not client.check_response_type(response_type):
|
||||
logger.error(f"Unsupported response_type: {response_type} for client '{client_id}'")
|
||||
return jsonify({"error": "unsupported_response_type"}), 400
|
||||
|
||||
# Validate redirect_uri
|
||||
redirect_uri = request_params.get('redirect_uri')
|
||||
if not redirect_uri or not client.check_redirect_uri(redirect_uri):
|
||||
logger.error(f"Invalid redirect_uri in consent: {redirect_uri} (Registered URIs: {client.redirect_uris})")
|
||||
return render_template('errors/auth_error.html', error=f"Invalid redirect_uri: {redirect_uri}"), 400
|
||||
|
||||
# Validate scopes
|
||||
allowed_scopes = client.get_allowed_scope(scope)
|
||||
if not allowed_scopes:
|
||||
logger.error(f"No valid scopes in consent: {scope} (Allowed scopes: {client.scopes})")
|
||||
return render_template('errors/auth_error.html', error=f"No valid scopes: {scope}"), 400
|
||||
logger.debug(f"Allowed scopes after validation in consent: {allowed_scopes}")
|
||||
|
||||
consent_granted = request.form.get('consent') == 'allow'
|
||||
logger.debug(f"Consent granted: {consent_granted}")
|
||||
|
||||
if consent_granted:
|
||||
logger.info(f"Consent granted for client '{client_id}' with scope '{scope}'")
|
||||
# Reconstruct request for Authlib
|
||||
request_args = request_params.copy()
|
||||
request_args['scope'] = allowed_scopes # Use validated scopes in normalized order
|
||||
request_args['scope'] = allowed_scopes
|
||||
if state:
|
||||
request_args['state'] = state
|
||||
if nonce:
|
||||
request_args['nonce'] = nonce
|
||||
request.args = request_args
|
||||
request.form = {} # Ensure form is empty for GET-like request
|
||||
request.method = 'GET' # Match /authorize
|
||||
logger.debug(f"Reconstructed request args for token generation: {request.args}")
|
||||
|
||||
# Access AuthorizationCodeGrant instance to generate the code
|
||||
request.form = {}
|
||||
request.method = 'GET'
|
||||
grant = AuthorizationCodeGrant(client, authorization_server)
|
||||
auth_code = grant.create_authorization_code(client, None, request)
|
||||
redirect_url = f"{redirect_uri}?code={auth_code}&state={state}" if state else f"{redirect_uri}?code={auth_code}"
|
||||
logger.debug(f"Redirecting to: {redirect_url}")
|
||||
return redirect(redirect_url)
|
||||
else:
|
||||
logger.info(f"Consent denied for client '{client_id}'")
|
||||
redirect_url = f"{redirect_uri}?error=access_denied&error_description=The+resource+owner+or+authorization+server+denied+the+request"
|
||||
if state:
|
||||
redirect_url += f"&state={state}"
|
||||
logger.debug(f"Redirecting to: {redirect_url}")
|
||||
return redirect(redirect_url)
|
||||
except OAuth2Error as error:
|
||||
logger.error(f"OAuth2 error during consent handling: {error.error} - {error.description}", exc_info=True)
|
||||
return render_template('errors/auth_error.html', error=f"{error.error}: {error.description}"), error.status_code
|
||||
except Exception as error:
|
||||
logger.error(f"Unexpected error during consent handling: {error}", exc_info=True)
|
||||
return render_template('errors/auth_error.html', error="An unexpected error occurred during consent handling"), 500
|
||||
return render_template('errors/auth_error.html', error="Unexpected error"), 500
|
||||
|
||||
@smart_blueprint.route('/token', methods=['POST'])
|
||||
@swag_from({
|
||||
'tags': ['OAuth2'],
|
||||
'summary': 'Exchange authorization code or refresh token for access token',
|
||||
'description': 'Exchanges an authorization code or refresh token for an access token and refresh token.',
|
||||
'summary': 'Exchange code or refresh token for access token',
|
||||
'parameters': [
|
||||
{
|
||||
'name': 'grant_type',
|
||||
'in': 'formData',
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
'description': 'Type of grant ("authorization_code" or "refresh_token").',
|
||||
'enum': ['authorization_code', 'refresh_token']
|
||||
},
|
||||
{
|
||||
'name': 'code',
|
||||
'in': 'formData',
|
||||
'type': 'string',
|
||||
'required': False,
|
||||
'description': 'Authorization code received from /authorize (required for grant_type=authorization_code).'
|
||||
},
|
||||
{
|
||||
'name': 'refresh_token',
|
||||
'in': 'formData',
|
||||
'type': 'string',
|
||||
'required': False,
|
||||
'description': 'Refresh token to obtain a new access token (required for grant_type=refresh_token).'
|
||||
},
|
||||
{
|
||||
'name': 'redirect_uri',
|
||||
'in': 'formData',
|
||||
'type': 'string',
|
||||
'required': False,
|
||||
'description': 'The redirect URI used in the authorization request (required for grant_type=authorization_code).'
|
||||
},
|
||||
{
|
||||
'name': 'client_id',
|
||||
'in': 'formData',
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
'description': 'Client ID of the registered application.'
|
||||
},
|
||||
{
|
||||
'name': 'client_secret',
|
||||
'in': 'formData',
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
'description': 'Client secret of the registered application.'
|
||||
},
|
||||
{
|
||||
'name': 'code_verifier',
|
||||
'in': 'formData',
|
||||
'type': 'string',
|
||||
'required': False,
|
||||
'description': 'PKCE code verifier (required if code_challenge was used in /authorize).'
|
||||
}
|
||||
{'name': 'grant_type', 'in': 'formData', 'type': 'string', 'required': True, 'enum': ['authorization_code', 'refresh_token']},
|
||||
{'name': 'code', 'in': 'formData', 'type': 'string', 'required': False},
|
||||
{'name': 'refresh_token', 'in': 'formData', 'type': 'string', 'required': False},
|
||||
{'name': 'redirect_uri', 'in': 'formData', 'type': 'string', 'required': False},
|
||||
{'name': 'client_id', 'in': 'formData', 'type': 'string', 'required': True},
|
||||
{'name': 'client_secret', 'in': 'formData', 'type': 'string', 'required': True},
|
||||
{'name': 'code_verifier', 'in': 'formData', 'type': 'string', 'required': False}
|
||||
],
|
||||
'responses': {
|
||||
'200': {
|
||||
'description': 'Access token response',
|
||||
'schema': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'access_token': {'type': 'string'},
|
||||
'token_type': {'type': 'string', 'example': 'Bearer'},
|
||||
'expires_in': {'type': 'integer', 'example': 3600},
|
||||
'scope': {'type': 'string'},
|
||||
'refresh_token': {'type': 'string'}
|
||||
}
|
||||
}
|
||||
},
|
||||
'400': {
|
||||
'description': 'Invalid request (e.g., invalid code, refresh token, client credentials, redirect_uri, or code_verifier).'
|
||||
}
|
||||
'200': {'description': 'Access token response'},
|
||||
'400': {'description': 'Invalid request'}
|
||||
}
|
||||
})
|
||||
def issue_token():
|
||||
"""Issue SMART on FHIR access tokens."""
|
||||
try:
|
||||
logger.info("Token endpoint called")
|
||||
# Log the incoming request parameters
|
||||
logger.debug(f"Request form data: {request.form}")
|
||||
logger.debug(f"Request headers: {dict(request.headers)}")
|
||||
response = authorization_server.create_token_response()
|
||||
logger.info(f"Token response created. Status code: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
token_data = response.get_json()
|
||||
logger.debug(f"Token issued: AccessToken={token_data.get('access_token')[:6]}..., Type={token_data.get('token_type')}, Scope={token_data.get('scope')}, ExpiresIn={token_data.get('expires_in')}, RefreshToken={token_data.get('refresh_token')[:6] if token_data.get('refresh_token') else None}...")
|
||||
return response
|
||||
except Exception:
|
||||
logger.warning("Could not parse token response JSON for logging")
|
||||
return response
|
||||
token_data = response.get_json()
|
||||
logger.debug(f"Token issued: AccessToken={token_data.get('access_token')[:6]}...")
|
||||
return response
|
||||
else:
|
||||
try:
|
||||
error_data = response.get_json()
|
||||
logger.warning(f"Token request failed: Error={error_data.get('error')}, Description={error_data.get('error_description')}")
|
||||
return jsonify(error_data), response.status_code
|
||||
except Exception:
|
||||
logger.warning(f"Token request failed with status {response.status_code}. Could not parse error response")
|
||||
return jsonify({"error": "invalid_request", "error_description": "Failed to process token request"}), response.status_code
|
||||
error_data = response.get_json()
|
||||
logger.warning(f"Token request failed: {error_data.get('error')}")
|
||||
return jsonify(error_data), response.status_code
|
||||
except Exception as error:
|
||||
logger.error(f"Unexpected error during token issuance: {error}", exc_info=True)
|
||||
return jsonify({"error": "server_error", "error_description": "An unexpected error occurred during token issuance"}), 500
|
||||
return jsonify({"error": "server_error", "error_description": "Unexpected error"}), 500
|
||||
|
||||
@smart_blueprint.route('/revoke', methods=['POST'])
|
||||
@swag_from({
|
||||
'tags': ['OAuth2'],
|
||||
'summary': 'Revoke an access or refresh token',
|
||||
'description': 'Revokes an access or refresh token, rendering it invalid for future use.',
|
||||
'parameters': [
|
||||
{
|
||||
'name': 'token',
|
||||
'in': 'formData',
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
'description': 'The access or refresh token to revoke.'
|
||||
},
|
||||
{
|
||||
'name': 'client_id',
|
||||
'in': 'formData',
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
'description': 'Client ID of the registered application.'
|
||||
},
|
||||
{
|
||||
'name': 'client_secret',
|
||||
'in': 'formData',
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
'description': 'Client secret of the registered application.'
|
||||
}
|
||||
{'name': 'token', 'in': 'formData', 'type': 'string', 'required': True},
|
||||
{'name': 'client_id', 'in': 'formData', 'type': 'string', 'required': True},
|
||||
{'name': 'client_secret', 'in': 'formData', 'type': 'string', 'required': True}
|
||||
],
|
||||
'responses': {
|
||||
'200': {
|
||||
'description': 'Token revoked successfully.',
|
||||
'schema': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'message': {'type': 'string', 'example': 'Token revoked successfully.'}
|
||||
}
|
||||
}
|
||||
},
|
||||
'400': {
|
||||
'description': 'Invalid request (e.g., invalid client credentials or token).'
|
||||
}
|
||||
'200': {'description': 'Token revoked'},
|
||||
'400': {'description': 'Invalid request'}
|
||||
}
|
||||
})
|
||||
def revoke_token():
|
||||
"""Revoke an access or refresh token."""
|
||||
try:
|
||||
logger.info("Token revocation endpoint called")
|
||||
# Log the incoming request parameters
|
||||
logger.debug(f"Request form data: {request.form}")
|
||||
logger.debug(f"Request headers: {dict(request.headers)}")
|
||||
|
||||
# Validate client credentials
|
||||
client_id = request.form.get('client_id')
|
||||
client_secret = request.form.get('client_secret')
|
||||
token = request.form.get('token')
|
||||
|
||||
if not client_id or not client_secret or not token:
|
||||
logger.warning("Missing required parameters in token revocation request")
|
||||
return jsonify({"error": "invalid_request", "error_description": "Missing required parameters"}), 400
|
||||
|
||||
return jsonify({"error": "invalid_request", "error_description": "Missing parameters"}), 400
|
||||
client = query_client(client_id)
|
||||
if not client:
|
||||
logger.warning(f"Invalid client_id: {client_id}")
|
||||
return jsonify({"error": "invalid_client", "error_description": "Invalid client_id"}), 400
|
||||
|
||||
if not client.check_client_secret(client_secret):
|
||||
logger.warning(f"Invalid client_secret for client '{client_id}'")
|
||||
return jsonify({"error": "invalid_client", "error_description": "Invalid client_secret"}), 400
|
||||
|
||||
# Revoke the token (delete it from the database)
|
||||
# Check if the token is an access token or refresh token
|
||||
if not client or not client.check_client_secret(client_secret):
|
||||
return jsonify({"error": "invalid_client", "error_description": "Invalid client credentials"}), 400
|
||||
token_entry = OAuthToken.query.filter(
|
||||
(OAuthToken.access_token == token) | (OAuthToken.refresh_token == token)
|
||||
).first()
|
||||
|
||||
if token_entry:
|
||||
database.session.delete(token_entry)
|
||||
database.session.commit()
|
||||
logger.info(f"Token revoked successfully for client '{client_id}'")
|
||||
return jsonify({"message": "Token revoked successfully."}), 200
|
||||
else:
|
||||
logger.warning(f"Token not found: {token[:6]}...")
|
||||
# RFC 7009 states that revocation endpoints should return 200 even if the token is not found
|
||||
return jsonify({"message": "Token revoked successfully."}), 200
|
||||
|
||||
return jsonify({"message": "Token revoked successfully."}), 200
|
||||
except Exception as error:
|
||||
logger.error(f"Unexpected error during token revocation: {error}", exc_info=True)
|
||||
return jsonify({"error": "server_error", "error_description": "An unexpected error occurred during token revocation"}), 500
|
||||
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
BIN
static/FHIRVINE_LOGO.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 251 KiB |
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 192 KiB |
78
static/swagger-ui.css
Normal file
78
static/swagger-ui.css
Normal 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;
|
||||
}
|
16
templates/_formhelpers.html
Normal file
16
templates/_formhelpers.html
Normal 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 %}
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
@ -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 %}
|
||||
|
32
templates/configure/proxy_settings.html
Normal file
32
templates/configure/proxy_settings.html
Normal 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 %}
|
33
templates/configure/security.html
Normal file
33
templates/configure/security.html
Normal 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 %}
|
46
templates/configure/server_endpoints.html
Normal file
46
templates/configure/server_endpoints.html
Normal 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 %}
|
@ -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>
|
||||
|
@ -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
87
templates/swagger-ui.html
Normal 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>
|
87
templates/swagger-ui.html.old.html
Normal file
87
templates/swagger-ui.html.old.html
Normal 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>
|
@ -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>
|
||||
|
39
templates/test_server.html
Normal file
39
templates/test_server.html
Normal 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 %}
|
Loading…
x
Reference in New Issue
Block a user