mirror of
https://github.com/Sudo-JHare/FHIRVINE-Smart-Proxy.git
synced 2025-06-15 20:50:00 +00:00
Beta 0.1
initial commit
This commit is contained in:
parent
5ae40ad1ef
commit
c61d907c42
65
Designing for FHIRFLARE Integration
Normal file
65
Designing for FHIRFLARE Integration
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
Designing for FHIRFLARE Integration
|
||||||
|
To ensure FHIRVINE can be a module or plugin proxy for FHIRFLARE (per your GitHub repo: https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit, April 12–18, 2025), the updated smart_proxy.py is designed as a standalone Blueprint. To integrate into FHIRFLARE:
|
||||||
|
|
||||||
|
Import the Blueprint:
|
||||||
|
In FHIRFLARE’s app.py, register the smart_blueprint:
|
||||||
|
python
|
||||||
|
|
||||||
|
Copy
|
||||||
|
from fhirvine.smart_proxy import smart_blueprint, configure_oauth
|
||||||
|
app.register_blueprint(smart_blueprint, url_prefix='/oauth2')
|
||||||
|
configure_oauth(app, db=database, registered_app_model=RegisteredApp, oauth_token_model=OAuthToken)
|
||||||
|
Ensure FHIRFLARE’s models.py includes RegisteredApp and OAuthToken or equivalent models.
|
||||||
|
Configuration:
|
||||||
|
Use environment variables for FHIRVINE-specific settings (e.g., FHIR_SERVER_URL, OAUTH2_TOKEN_EXPIRES_IN) in .env:
|
||||||
|
text
|
||||||
|
|
||||||
|
Copy
|
||||||
|
FHIR_SERVER_URL=http://placeholder-fhir-server.com/fhir
|
||||||
|
OAUTH2_TOKEN_EXPIRES_IN=3600
|
||||||
|
Load in FHIRFLARE’s app.py using python-dotenv.
|
||||||
|
Template Integration:
|
||||||
|
Copy FHIRVINE’s templates (consent.html, auth_error.html) to FHIRFLARE’s templates/auth/ or configure smart_blueprint.template_folder to point to FHIRVINE’s templates directory.
|
||||||
|
Update FHIRFLARE’s base.html to include FHIRVINE routes (e.g., /oauth2/authorize) in the navbar if needed.
|
||||||
|
Database:
|
||||||
|
Use FHIRFLARE’s SQLite database (instance/flarefhir.db, per April 12, 2025) by passing it to configure_oauth.
|
||||||
|
Run migrations to add registered_apps and oauth_tokens tables:
|
||||||
|
bash
|
||||||
|
|
||||||
|
Copy
|
||||||
|
docker-compose exec flarefhir flask db migrate -m "Add FHIRVINE tables"
|
||||||
|
docker-compose exec flarefhir flask db upgrade
|
||||||
|
Testing:
|
||||||
|
Test FHIRVINE routes within FHIRFLARE (e.g., http://localhost:5000/oauth2/authorize if FHIRFLARE runs on port 5000).
|
||||||
|
Ensure FHIRFLARE’s UI (e.g., fhir_ui_operations.html, April 16–17, 2025) can trigger FHIRVINE’s OAuth2 flow for app launches.
|
||||||
|
Troubleshooting
|
||||||
|
Error Persists:
|
||||||
|
Add debug logging in RegisteredApp.check_response_type:
|
||||||
|
python
|
||||||
|
|
||||||
|
Copy
|
||||||
|
def check_response_type(self, response_type):
|
||||||
|
logger.debug(f"Checking response_type: {response_type}")
|
||||||
|
return response_type == 'code'
|
||||||
|
Check session data in /consent:
|
||||||
|
python
|
||||||
|
|
||||||
|
Copy
|
||||||
|
logger.debug(f"Session data: {session}")
|
||||||
|
Verify request_params contains response_type=code.
|
||||||
|
Redirect Fails:
|
||||||
|
Confirm https://httpbin.org/get is in redirect_uris:
|
||||||
|
bash
|
||||||
|
|
||||||
|
Copy
|
||||||
|
docker-compose exec fhirvine sqlite3 /app/instance/fhirvine.db "SELECT redirect_uris FROM registered_apps WHERE client_id = 'fRnWBBuEBNF_mkH09nZdBA2_lwekV-_H';"
|
||||||
|
Ensure scope=openid launch/patient matches registered scopes.
|
||||||
|
Token Exchange Fails:
|
||||||
|
Verify client_secret matches the hashed value in client_secret_hashed.
|
||||||
|
Check code validity (expires in 600 seconds).
|
||||||
|
Addressing Prior Context
|
||||||
|
FHIRFLARE Integration: Your work on FHIRFLARE-IG-Toolkit (April 12–18, 2025) uses a similar Flask structure (app.py, templates/, SQLite). FHIRVINE’s Blueprint design mirrors FHIRFLARE’s modular routes (e.g., fhir_ig_importer, April 13, 2025), ensuring easy integration.
|
||||||
|
Security: Hashing client_secret (April 22, 2025) aligns with FHIRFLARE’s security practices (March 25, 2025).
|
||||||
|
UI: FHIRVINE’s templates (consent.html) use Bootstrap, matching FHIRFLARE’s UI (e.g., fhir_ui_operations.html, April 17, 2025).
|
||||||
|
FHIR Server: The aud=http://placeholder-fhir-server.com/fhir suggests a mock server. If using HAPI FHIR (April 16, 2025), update to its metadata endpoint.
|
||||||
|
Questions for Next Steps
|
50
Dockerfile
Normal file
50
Dockerfile
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Dockerfile for FHIRVINE (Single Stage - Log pip install)
|
||||||
|
|
||||||
|
# Use Python 3.11 Bullseye base image
|
||||||
|
FROM python:3.11-bullseye
|
||||||
|
|
||||||
|
# Set environment variables using recommended key=value format
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Set work directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies (needed if any Python package requires compilation)
|
||||||
|
# Keep build-essential just in case
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements first for caching layer optimization
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Ensure pip is up-to-date
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip
|
||||||
|
|
||||||
|
# Install dependencies directly from requirements.txt
|
||||||
|
# Redirect stdout and stderr to a log file
|
||||||
|
# Use --force-reinstall for Authlib as an extra measure
|
||||||
|
# Using Authlib 1.5.2 as 1.2.0 had Werkzeug incompatibility
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt --force-reinstall Authlib==1.5.2 > /pip_install.log 2>&1 || (cat /pip_install.log && exit 1)
|
||||||
|
|
||||||
|
# Copy the rest of the application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create a non-root user and group
|
||||||
|
RUN groupadd -r appuser && useradd --no-log-init -r -g appuser appuser
|
||||||
|
|
||||||
|
# Create the instance directory
|
||||||
|
RUN mkdir -p instance
|
||||||
|
|
||||||
|
# Change ownership AFTER creating instance dir and copying code
|
||||||
|
RUN chown -R appuser:appuser /app
|
||||||
|
|
||||||
|
# Switch to the non-root user
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# Expose the port
|
||||||
|
EXPOSE 5001
|
||||||
|
|
||||||
|
# Define the command to run the application
|
||||||
|
CMD exec /usr/local/bin/waitress-serve --host=0.0.0.0 --port=5001 --call 'app:create_app'
|
24
Dockerfile.minimal
Normal file
24
Dockerfile.minimal
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Dockerfile.minimal - Minimal environment for testing Authlib
|
||||||
|
|
||||||
|
# Use a slim Python 3.11 image
|
||||||
|
FROM python:3.11-slim-bullseye
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE 1
|
||||||
|
ENV PYTHONUNBUFFERED 1
|
||||||
|
|
||||||
|
# Set work directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Upgrade pip
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip
|
||||||
|
|
||||||
|
# Install only Flask and the specific Authlib version
|
||||||
|
# Use --no-cache-dir and --force-reinstall for certainty
|
||||||
|
RUN pip install --no-cache-dir Flask==2.3.3 Authlib==1.5.2
|
||||||
|
|
||||||
|
# Copy ONLY the minimal test script
|
||||||
|
COPY minimal_test.py .
|
||||||
|
|
||||||
|
# Command to run the test script directly
|
||||||
|
CMD ["python", "minimal_test.py"]
|
243
app.py
Normal file
243
app.py
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
from flask import Flask, render_template, request, redirect, url_for, flash, session
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
from flasgger import Swagger
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
|
# Add current directory to Python path for reliable imports
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
# Import from our modules
|
||||||
|
from models import database, RegisteredApp, OAuthToken
|
||||||
|
from forms import RegisterAppForm, TestClientForm
|
||||||
|
from smart_proxy import smart_blueprint, configure_oauth
|
||||||
|
|
||||||
|
# Load environment variables from .env file
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Set up logging
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Initialize extensions
|
||||||
|
migrate = Migrate()
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
"""Create and configure the Flask application."""
|
||||||
|
app = Flask(__name__, instance_relative_config=True)
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
app.config.from_mapping(
|
||||||
|
SECRET_KEY=os.environ.get('SECRET_KEY', 'dev-secret-key-for-fhirvine'),
|
||||||
|
SQLALCHEMY_DATABASE_URI=os.environ.get('DATABASE_URL', f'sqlite:///{os.path.join(app.instance_path, "fhirvine.db")}'),
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS=False,
|
||||||
|
# Default proxy settings (can be overridden in .env)
|
||||||
|
FHIR_SERVER_URL=os.environ.get('FHIR_SERVER_URL', 'http://hapi.fhir.org/baseR4'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure the instance folder exists
|
||||||
|
try:
|
||||||
|
os.makedirs(app.instance_path, exist_ok=True)
|
||||||
|
logger.info(f"Instance path created/verified: {app.instance_path}")
|
||||||
|
logger.info(f"Database URI set to: {app.config['SQLALCHEMY_DATABASE_URI']}")
|
||||||
|
except OSError as e:
|
||||||
|
logger.error(f"Could not create instance path at {app.instance_path}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Initialize Flask extensions
|
||||||
|
database.init_app(app)
|
||||||
|
migrate.init_app(app, database)
|
||||||
|
|
||||||
|
# Initialize Flasgger for OpenAPI
|
||||||
|
Swagger(app, template={
|
||||||
|
"info": {
|
||||||
|
"title": "FHIRVINE SMART on FHIR Proxy API",
|
||||||
|
"version": "1.0",
|
||||||
|
"description": "API for SMART on FHIR SSO proxy functionality."
|
||||||
|
},
|
||||||
|
"host": "localhost:5001",
|
||||||
|
"basePath": "/oauth2",
|
||||||
|
"schemes": ["http"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Configure Authlib
|
||||||
|
configure_oauth(app)
|
||||||
|
|
||||||
|
# Register blueprints
|
||||||
|
app.register_blueprint(smart_blueprint, url_prefix='/oauth2')
|
||||||
|
|
||||||
|
# Main application routes
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
@app.route('/app-gallery')
|
||||||
|
def app_gallery():
|
||||||
|
"""Display the list of registered applications."""
|
||||||
|
try:
|
||||||
|
apps = RegisteredApp.query.order_by(RegisteredApp.app_name).all()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching apps from database: {e}", exc_info=True)
|
||||||
|
flash("Could not load application gallery. Please try again later.", "error")
|
||||||
|
apps = []
|
||||||
|
return render_template('app_gallery/gallery.html', apps=apps)
|
||||||
|
|
||||||
|
@app.route('/register-app', methods=['GET', 'POST'])
|
||||||
|
def register_app():
|
||||||
|
"""Handle registration of new applications."""
|
||||||
|
form = RegisterAppForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
try:
|
||||||
|
# Generate a unique client_id
|
||||||
|
client_id = secrets.token_urlsafe(32)
|
||||||
|
# Normalize redirect_uris to lowercase
|
||||||
|
redirect_uris = form.redirect_uris.data.strip().lower()
|
||||||
|
new_app = RegisteredApp(
|
||||||
|
app_name=form.app_name.data,
|
||||||
|
client_id=client_id,
|
||||||
|
redirect_uris=redirect_uris,
|
||||||
|
scopes=form.scopes.data.strip(),
|
||||||
|
logo_uri=form.logo_uri.data or None,
|
||||||
|
contacts=form.contacts.data.strip() or None,
|
||||||
|
tos_uri=form.tos_uri.data or None,
|
||||||
|
policy_uri=form.policy_uri.data or None
|
||||||
|
)
|
||||||
|
# Generate and hash client secret
|
||||||
|
client_secret = secrets.token_urlsafe(40)
|
||||||
|
new_app.set_client_secret(client_secret)
|
||||||
|
database.session.add(new_app)
|
||||||
|
database.session.commit()
|
||||||
|
flash(f"Application '{new_app.app_name}' registered successfully!", "success")
|
||||||
|
flash(f"Client ID: {new_app.client_id}", "info")
|
||||||
|
flash(f"Client Secret: {client_secret} (Please store this securely!)", "warning")
|
||||||
|
logger.info(f"Registered new app: {new_app.app_name} (ID: {new_app.id}, ClientID: {new_app.client_id})")
|
||||||
|
return redirect(url_for('app_gallery'))
|
||||||
|
except Exception as e:
|
||||||
|
database.session.rollback()
|
||||||
|
logger.error(f"Error registering application '{form.app_name.data}': {e}", exc_info=True)
|
||||||
|
flash(f"Error registering application: {e}", "error")
|
||||||
|
return render_template('app_gallery/register.html', form=form)
|
||||||
|
|
||||||
|
@app.route('/edit-app/<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():
|
||||||
|
try:
|
||||||
|
app.app_name = form.app_name.data
|
||||||
|
app.redirect_uris = form.redirect_uris.data.strip().lower()
|
||||||
|
app.scopes = form.scopes.data.strip()
|
||||||
|
app.logo_uri = form.logo_uri.data or None
|
||||||
|
app.contacts = form.contacts.data.strip() or None
|
||||||
|
app.tos_uri = form.tos_uri.data or None
|
||||||
|
app.policy_uri = form.policy_uri.data or None
|
||||||
|
database.session.commit()
|
||||||
|
flash(f"Application '{app.app_name}' updated successfully!", "success")
|
||||||
|
logger.info(f"Updated app: {app.app_name} (ID: {app.id}, ClientID: {app.client_id})")
|
||||||
|
return redirect(url_for('app_gallery'))
|
||||||
|
except Exception as e:
|
||||||
|
database.session.rollback()
|
||||||
|
logger.error(f"Error updating application '{app.app_name}': {e}", exc_info=True)
|
||||||
|
flash(f"Error updating application: {e}", "error")
|
||||||
|
return render_template('app_gallery/edit.html', form=form, app=app)
|
||||||
|
|
||||||
|
@app.route('/delete-app/<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':
|
||||||
|
try:
|
||||||
|
# Delete associated tokens
|
||||||
|
OAuthToken.query.filter_by(client_id=app.client_id).delete()
|
||||||
|
database.session.delete(app)
|
||||||
|
database.session.commit()
|
||||||
|
flash(f"Application '{app.app_name}' deleted successfully!", "success")
|
||||||
|
logger.info(f"Deleted app: {app.app_name} (ID: {app.id}, ClientID: {app.client_id})")
|
||||||
|
return redirect(url_for('app_gallery'))
|
||||||
|
except Exception as e:
|
||||||
|
database.session.rollback()
|
||||||
|
logger.error(f"Error deleting application '{app.app_name}': {e}", exc_info=True)
|
||||||
|
flash(f"Error deleting application: {e}", "error")
|
||||||
|
return render_template('app_gallery/delete.html', app=app)
|
||||||
|
|
||||||
|
@app.route('/test-client', methods=['GET', 'POST'])
|
||||||
|
def test_client():
|
||||||
|
"""Handle testing of registered SMART app launches."""
|
||||||
|
form = TestClientForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
client_id = form.client_id.data
|
||||||
|
app = RegisteredApp.query.filter_by(client_id=client_id).first()
|
||||||
|
if not app:
|
||||||
|
flash("Invalid Client ID.", "error")
|
||||||
|
return redirect(url_for('test_client'))
|
||||||
|
# Deduplicate scopes
|
||||||
|
scopes = ' '.join(set(app.scopes.split()))
|
||||||
|
# Generate PKCE code verifier and challenge
|
||||||
|
code_verifier = secrets.token_urlsafe(32)
|
||||||
|
code_challenge = base64.urlsafe_b64encode(
|
||||||
|
hashlib.sha256(code_verifier.encode('ascii')).digest()
|
||||||
|
).decode('ascii').rstrip('=')
|
||||||
|
# Store code verifier in session for /token
|
||||||
|
session['code_verifier'] = code_verifier
|
||||||
|
# Construct SMART authorization URL, ensuring redirect_uri is lowercase
|
||||||
|
redirect_uri = app.get_default_redirect_uri().lower()
|
||||||
|
auth_url = url_for(
|
||||||
|
'smart_proxy.authorize',
|
||||||
|
client_id=client_id,
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
|
scope=scopes,
|
||||||
|
response_type='code',
|
||||||
|
state='test_state_123',
|
||||||
|
aud='http://hapi.fhir.org/baseR4/metadata',
|
||||||
|
code_challenge=code_challenge,
|
||||||
|
code_challenge_method='S256',
|
||||||
|
_external=True
|
||||||
|
)
|
||||||
|
logger.info(f"Redirecting to authorization URL for client '{client_id}'")
|
||||||
|
logger.info(f"Code verifier for client '{client_id}': {code_verifier}")
|
||||||
|
return redirect(auth_url)
|
||||||
|
apps = RegisteredApp.query.all()
|
||||||
|
return render_template('test_client.html', form=form, apps=apps)
|
||||||
|
|
||||||
|
@app.route('/configure/proxy-settings')
|
||||||
|
def proxy_settings():
|
||||||
|
"""Display proxy settings page."""
|
||||||
|
return render_template('configure/proxy_settings.html', fhir_server_url=app.config['FHIR_SERVER_URL'])
|
||||||
|
|
||||||
|
@app.route('/configure/server-endpoints')
|
||||||
|
def server_endpoints():
|
||||||
|
"""Display server endpoints page."""
|
||||||
|
return render_template('configure/server_endpoints.html')
|
||||||
|
|
||||||
|
@app.route('/configure/security')
|
||||||
|
def security_settings():
|
||||||
|
"""Display security settings page."""
|
||||||
|
return render_template('configure/security.html')
|
||||||
|
|
||||||
|
@app.route('/about')
|
||||||
|
def about():
|
||||||
|
"""Display about page."""
|
||||||
|
return render_template('about.html')
|
||||||
|
|
||||||
|
# Context processors
|
||||||
|
@app.context_processor
|
||||||
|
def inject_site_name():
|
||||||
|
return dict(site_name='FHIRVINE SMART Proxy')
|
||||||
|
|
||||||
|
# Error handlers
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found(error):
|
||||||
|
return render_template('errors/404.html'), 404
|
||||||
|
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def internal_error(error):
|
||||||
|
return render_template('errors/500.html'), 500
|
||||||
|
|
||||||
|
logger.info("FHIRVINE Flask application created and configured.")
|
||||||
|
return app
|
26
docker-compose.yml
Normal file
26
docker-compose.yml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# docker-compose.yml for FHIRVINE
|
||||||
|
|
||||||
|
services:
|
||||||
|
fhirvine:
|
||||||
|
build: . # Build the image from the Dockerfile in the current directory
|
||||||
|
container_name: fhirvine_app # Optional: Assign a specific name
|
||||||
|
ports:
|
||||||
|
- "5001:5001" # Map host port 5001 to container port 5001 (where Waitress listens)
|
||||||
|
volumes:
|
||||||
|
# Mount the instance directory (for DB) using a named volume
|
||||||
|
- fhirvine_instance:/app/instance
|
||||||
|
# Mount the migrations directory using a named volume
|
||||||
|
- fhirvine_migrations:/app/migrations # <-- ADDED THIS LINE
|
||||||
|
environment:
|
||||||
|
# Load environment variables from a .env file in the same directory as docker-compose.yml
|
||||||
|
- FLASK_ENV=${FLASK_ENV:-development} # Default to development if not set in .env
|
||||||
|
# SECRET_KEY should be set in your .env file for security
|
||||||
|
env_file:
|
||||||
|
- .env # Load variables from .env file
|
||||||
|
restart: unless-stopped # Optional: Restart policy
|
||||||
|
|
||||||
|
# Define the named volumes
|
||||||
|
volumes:
|
||||||
|
fhirvine_instance:
|
||||||
|
fhirvine_migrations: # <-- ADDED THIS LINE
|
||||||
|
|
67
forms.py
Normal file
67
forms.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import re
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, TextAreaField, URLField, SubmitField, PasswordField
|
||||||
|
from wtforms.validators import DataRequired, Length, Optional, URL, Regexp, ValidationError
|
||||||
|
|
||||||
|
# Custom validator for space-separated URIs
|
||||||
|
def validate_uris(form, field):
|
||||||
|
uris = field.data.split()
|
||||||
|
for uri in uris:
|
||||||
|
if not uri.startswith(('http://', 'https://')):
|
||||||
|
raise ValidationError(f'Invalid URI format: "{uri}". Must start with http:// or https://.')
|
||||||
|
if ' ' in uri:
|
||||||
|
raise ValidationError(f'URI cannot contain spaces: "{uri}"')
|
||||||
|
|
||||||
|
# Custom validator for space-separated scopes
|
||||||
|
def validate_scopes(form, field):
|
||||||
|
scopes = field.data.split()
|
||||||
|
scope_pattern = r'^[a-zA-Z0-9\/\.\*\-]+$'
|
||||||
|
for scope in scopes:
|
||||||
|
if not re.match(scope_pattern, scope):
|
||||||
|
raise ValidationError(f'Invalid scope format: "{scope}". Allowed characters: a-z A-Z 0-9 / . * -')
|
||||||
|
|
||||||
|
class RegisterAppForm(FlaskForm):
|
||||||
|
"""Form for registering a new SMART application."""
|
||||||
|
app_name = StringField(
|
||||||
|
'Application Name',
|
||||||
|
validators=[DataRequired(), Length(min=3, max=100)]
|
||||||
|
)
|
||||||
|
redirect_uris = TextAreaField(
|
||||||
|
'Redirect URIs (Space-separated)',
|
||||||
|
validators=[DataRequired(), validate_uris],
|
||||||
|
description='Enter one or more valid redirect URIs, separated by spaces. Example: https://myapp.com/callback https://localhost:3000/cb'
|
||||||
|
)
|
||||||
|
scopes = TextAreaField(
|
||||||
|
'Allowed Scopes (Space-separated)',
|
||||||
|
validators=[DataRequired(), validate_scopes],
|
||||||
|
default='openid profile launch launch/patient patient/*.read offline_access',
|
||||||
|
description='Enter the scopes this application is allowed to request, separated by spaces. Standard SMART scopes recommended.'
|
||||||
|
)
|
||||||
|
logo_uri = URLField(
|
||||||
|
'Logo URI (Optional)',
|
||||||
|
validators=[Optional(), URL()],
|
||||||
|
description='A URL pointing to an image for the application logo.'
|
||||||
|
)
|
||||||
|
contacts = TextAreaField(
|
||||||
|
'Contacts (Optional, space-separated)',
|
||||||
|
description='Contact email addresses (e.g., mailto:dev@example.com) or URLs, separated by spaces.'
|
||||||
|
)
|
||||||
|
tos_uri = URLField(
|
||||||
|
'Terms of Service URI (Optional)',
|
||||||
|
validators=[Optional(), URL()],
|
||||||
|
description='Link to the application\'s Terms of Service.'
|
||||||
|
)
|
||||||
|
policy_uri = URLField(
|
||||||
|
'Privacy Policy URI (Optional)',
|
||||||
|
validators=[Optional(), URL()],
|
||||||
|
description='Link to the application\'s Privacy Policy.'
|
||||||
|
)
|
||||||
|
submit = SubmitField('Register Application')
|
||||||
|
|
||||||
|
class TestClientForm(FlaskForm):
|
||||||
|
"""Form for testing SMART app launches."""
|
||||||
|
client_id = StringField(
|
||||||
|
'Client ID',
|
||||||
|
validators=[DataRequired(), Length(min=1, max=100)]
|
||||||
|
)
|
||||||
|
submit = SubmitField('Launch Test')
|
138
minimal_test.py
Normal file
138
minimal_test.py
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
# minimal_test.py (Updated to Run Test)
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from flask import Flask, request, jsonify
|
||||||
|
from authlib.integrations.flask_oauth2 import AuthorizationServer
|
||||||
|
from authlib.oauth2.rfc6749.errors import OAuth2Error
|
||||||
|
from werkzeug.datastructures import ImmutableMultiDict
|
||||||
|
|
||||||
|
# --- Basic Logging Setup ---
|
||||||
|
logging.basicConfig(level=logging.DEBUG, format='%(levelname)s:%(name)s:%(message)s') # Simplified format for clarity
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# --- Dummy Client Implementation ---
|
||||||
|
class DummyClient:
|
||||||
|
def __init__(self, client_id, redirect_uris, scopes, response_types=['code']):
|
||||||
|
self.client_id = client_id
|
||||||
|
self._redirect_uris = redirect_uris.split() if isinstance(redirect_uris, str) else redirect_uris
|
||||||
|
self._scopes = scopes.split() if isinstance(scopes, str) else scopes
|
||||||
|
self._response_types = response_types
|
||||||
|
|
||||||
|
def get_client_id(self):
|
||||||
|
return self.client_id
|
||||||
|
|
||||||
|
def check_redirect_uri(self, redirect_uri):
|
||||||
|
logger.debug(f"[DummyClient] Checking redirect_uri: '{redirect_uri}' against {self._redirect_uris}")
|
||||||
|
return redirect_uri in self._redirect_uris
|
||||||
|
|
||||||
|
def check_response_type(self, response_type):
|
||||||
|
logger.debug(f"[DummyClient] Checking response_type: '{response_type}' against {self._response_types}")
|
||||||
|
return response_type in self._response_types
|
||||||
|
|
||||||
|
def get_allowed_scopes(self, requested_scopes):
|
||||||
|
allowed = set(self._scopes)
|
||||||
|
requested = set(requested_scopes.split())
|
||||||
|
granted = allowed.intersection(requested)
|
||||||
|
logger.debug(f"[DummyClient] Checking scopes: Requested={requested}, Allowed={allowed}, Granted={granted}")
|
||||||
|
return ' '.join(list(granted))
|
||||||
|
|
||||||
|
def check_grant_type(self, grant_type):
|
||||||
|
return grant_type == 'authorization_code'
|
||||||
|
|
||||||
|
def has_client_secret(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def check_client_secret(self, client_secret):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# --- Dummy Query Client Function ---
|
||||||
|
DUMMY_CLIENT_ID = 'test-client-123'
|
||||||
|
DUMMY_REDIRECT_URI = 'http://localhost:8000/callback'
|
||||||
|
DUMMY_SCOPES = 'openid profile launch/patient patient/*.read'
|
||||||
|
|
||||||
|
dummy_client_instance = DummyClient(DUMMY_CLIENT_ID, DUMMY_REDIRECT_URI, DUMMY_SCOPES)
|
||||||
|
|
||||||
|
def query_client(client_id):
|
||||||
|
logger.debug(f"[Minimal Test] query_client called for ID: {client_id}")
|
||||||
|
if client_id == DUMMY_CLIENT_ID:
|
||||||
|
logger.debug("[Minimal Test] Returning dummy client instance.")
|
||||||
|
return dummy_client_instance
|
||||||
|
logger.warning(f"[Minimal Test] Client ID '{client_id}' not found.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# --- Flask App and Authlib Setup ---
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['SECRET_KEY'] = 'minimal-test-secret'
|
||||||
|
|
||||||
|
# Check Authlib version
|
||||||
|
try:
|
||||||
|
import authlib
|
||||||
|
import inspect
|
||||||
|
logger.info(f"Authlib version found: {authlib.__version__}")
|
||||||
|
logger.info(f"Authlib location: {inspect.getfile(authlib)}")
|
||||||
|
except ImportError:
|
||||||
|
logger.error("Could not import Authlib!")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting Authlib info: {e}")
|
||||||
|
|
||||||
|
# Initialize AuthorizationServer
|
||||||
|
authorization_server = AuthorizationServer(query_client=query_client)
|
||||||
|
authorization_server.init_app(app)
|
||||||
|
|
||||||
|
# --- Test Route ---
|
||||||
|
@app.route('/test-authlib')
|
||||||
|
def test_authlib_route():
|
||||||
|
logger.info("--- Entered /test-authlib route ---")
|
||||||
|
# NOTE: We are already inside a request context when run via test_client
|
||||||
|
logger.info(f"Actual request URL: {request.url}")
|
||||||
|
logger.info(f"Actual request Args: {request.args}")
|
||||||
|
logger.debug(f"Attempting validation. Type of authorization_server: {type(authorization_server)}")
|
||||||
|
try:
|
||||||
|
# --- The critical call ---
|
||||||
|
# Authlib uses the current request context (request.args, etc.)
|
||||||
|
grant = authorization_server.validate_authorization_request()
|
||||||
|
# ------------------------
|
||||||
|
|
||||||
|
logger.info(">>> SUCCESS: validate_authorization_request() completed.")
|
||||||
|
logger.info(f"Grant type: {type(grant)}")
|
||||||
|
logger.info(f"Grant client: {grant.client.get_client_id() if grant and grant.client else 'N/A'}")
|
||||||
|
return jsonify(status="success", message="validate_authorization_request() worked!", grant_type=str(type(grant)))
|
||||||
|
|
||||||
|
except AttributeError as ae:
|
||||||
|
logger.critical(f">>> FAILURE: AttributeError calling validate_authorization_request(): {ae}", exc_info=True)
|
||||||
|
return jsonify(status="error", message=f"AttributeError: {ae}"), 500
|
||||||
|
except OAuth2Error as error:
|
||||||
|
logger.error(f">>> FAILURE: OAuth2Error calling validate_authorization_request(): {error.error} - {error.description}", exc_info=True)
|
||||||
|
return jsonify(status="error", message=f"OAuth2Error: {error.error} - {error.description}"), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f">>> FAILURE: Unexpected Exception calling validate_authorization_request(): {e}", exc_info=True)
|
||||||
|
return jsonify(status="error", message=f"Unexpected Exception: {e}"), 500
|
||||||
|
|
||||||
|
# --- Function to Run the Test ---
|
||||||
|
def run_test():
|
||||||
|
"""Uses Flask's test client to execute the test route."""
|
||||||
|
logger.info("--- Starting Test Run ---")
|
||||||
|
client = app.test_client()
|
||||||
|
# Construct the query parameters for the test request
|
||||||
|
query_params = {
|
||||||
|
'response_type': 'code',
|
||||||
|
'client_id': DUMMY_CLIENT_ID,
|
||||||
|
'redirect_uri': DUMMY_REDIRECT_URI,
|
||||||
|
'scope': DUMMY_SCOPES,
|
||||||
|
'state': 'dummy_state'
|
||||||
|
}
|
||||||
|
# Make a GET request to the test route
|
||||||
|
response = client.get('/test-authlib', query_string=query_params)
|
||||||
|
logger.info(f"--- Test Run Complete --- Status Code: {response.status_code} ---")
|
||||||
|
# Print the JSON response body for verification
|
||||||
|
try:
|
||||||
|
logger.info(f"Response JSON: {response.get_json()}")
|
||||||
|
except Exception:
|
||||||
|
logger.info(f"Response Data: {response.data.decode()}")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Main execution block ---
|
||||||
|
if __name__ == '__main__':
|
||||||
|
logger.info("Minimal test script loaded.")
|
||||||
|
# Run the test function directly
|
||||||
|
run_test()
|
90
models.py
Normal file
90
models.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
|
||||||
|
database = SQLAlchemy()
|
||||||
|
|
||||||
|
class RegisteredApp(database.Model):
|
||||||
|
__tablename__ = 'registered_apps'
|
||||||
|
id = database.Column(database.Integer, primary_key=True)
|
||||||
|
app_name = database.Column(database.String(100), unique=True, nullable=False)
|
||||||
|
client_id = database.Column(database.String(100), unique=True, nullable=False)
|
||||||
|
client_secret_hash = database.Column(database.String(200), nullable=False)
|
||||||
|
redirect_uris = database.Column(database.Text, nullable=False)
|
||||||
|
scopes = database.Column(database.Text, nullable=False)
|
||||||
|
logo_uri = database.Column(database.String(255))
|
||||||
|
contacts = database.Column(database.Text)
|
||||||
|
tos_uri = database.Column(database.String(255))
|
||||||
|
policy_uri = database.Column(database.String(255))
|
||||||
|
jwks_uri = database.Column(database.String(255))
|
||||||
|
date_registered = database.Column(database.DateTime)
|
||||||
|
last_updated = database.Column(database.DateTime)
|
||||||
|
|
||||||
|
# Authlib ClientMixin methods
|
||||||
|
def get_client_id(self):
|
||||||
|
return self.client_id
|
||||||
|
|
||||||
|
def get_default_redirect_uri(self):
|
||||||
|
return self.redirect_uris.split(',')[0] if self.redirect_uris else None
|
||||||
|
|
||||||
|
def check_redirect_uri(self, redirect_uri):
|
||||||
|
return redirect_uri in self.redirect_uris.split(',')
|
||||||
|
|
||||||
|
def check_response_type(self, response_type):
|
||||||
|
return response_type == 'code'
|
||||||
|
|
||||||
|
def check_grant_type(self, grant_type):
|
||||||
|
# Support authorization_code and refresh_token grants
|
||||||
|
return grant_type in ['authorization_code', 'refresh_token']
|
||||||
|
|
||||||
|
def check_endpoint_auth_method(self, method, endpoint):
|
||||||
|
# Support client_secret_post and client_secret_basic for token endpoint
|
||||||
|
# Currently, we support the same methods for all endpoints
|
||||||
|
return method in ['client_secret_post', 'client_secret_basic']
|
||||||
|
|
||||||
|
def get_allowed_scope(self, scopes):
|
||||||
|
allowed = set(self.scopes.split())
|
||||||
|
requested = set(scopes.split())
|
||||||
|
return ' '.join(sorted(allowed.intersection(requested)))
|
||||||
|
|
||||||
|
def set_client_secret(self, client_secret):
|
||||||
|
"""Set and hash the client secret."""
|
||||||
|
self.client_secret_hash = generate_password_hash(client_secret)
|
||||||
|
|
||||||
|
def check_client_secret(self, client_secret):
|
||||||
|
"""Verify the client secret against the stored hash."""
|
||||||
|
return check_password_hash(self.client_secret_hash, client_secret)
|
||||||
|
|
||||||
|
class OAuthToken(database.Model):
|
||||||
|
__tablename__ = 'oauth_tokens'
|
||||||
|
id = database.Column(database.Integer, primary_key=True)
|
||||||
|
client_id = database.Column(database.String(100), nullable=False)
|
||||||
|
token_type = database.Column(database.String(40))
|
||||||
|
access_token = database.Column(database.String(255), nullable=False)
|
||||||
|
refresh_token = database.Column(database.String(255))
|
||||||
|
scope = database.Column(database.Text)
|
||||||
|
issued_at = database.Column(database.Integer, nullable=False)
|
||||||
|
expires_in = database.Column(database.Integer, nullable=False)
|
||||||
|
|
||||||
|
class AuthorizationCode(database.Model):
|
||||||
|
__tablename__ = 'authorization_codes'
|
||||||
|
id = database.Column(database.Integer, primary_key=True)
|
||||||
|
code = database.Column(database.String(255), nullable=False)
|
||||||
|
client_id = database.Column(database.String(100), database.ForeignKey('registered_apps.client_id', name='fk_authorization_codes_client_id'), nullable=False)
|
||||||
|
redirect_uri = database.Column(database.Text)
|
||||||
|
scope = database.Column(database.Text)
|
||||||
|
nonce = database.Column(database.String(255))
|
||||||
|
code_challenge = database.Column(database.String(255))
|
||||||
|
code_challenge_method = database.Column(database.String(10))
|
||||||
|
response_type = database.Column(database.String(40))
|
||||||
|
state = database.Column(database.String(255))
|
||||||
|
issued_at = database.Column(database.Integer, nullable=False)
|
||||||
|
expires_at = database.Column(database.Integer, nullable=False)
|
||||||
|
|
||||||
|
# Authlib AuthorizationCodeMixin methods
|
||||||
|
def get_redirect_uri(self):
|
||||||
|
"""Return the redirect URI associated with this authorization code."""
|
||||||
|
return self.redirect_uri
|
||||||
|
|
||||||
|
def get_scope(self):
|
||||||
|
"""Return the scope associated with this authorization code."""
|
||||||
|
return self.scope
|
87
pip_install.log
Normal file
87
pip_install.log
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
Collecting Authlib==1.5.2
|
||||||
|
Downloading authlib-1.5.2-py2.py3-none-any.whl.metadata (3.9 kB)
|
||||||
|
Collecting Flask<3.0,>=2.3 (from -r requirements.txt (line 2))
|
||||||
|
Downloading flask-2.3.3-py3-none-any.whl.metadata (3.6 kB)
|
||||||
|
Collecting Flask-SQLAlchemy>=3.0 (from -r requirements.txt (line 3))
|
||||||
|
Downloading flask_sqlalchemy-3.1.1-py3-none-any.whl.metadata (3.4 kB)
|
||||||
|
Collecting Flask-WTF>=1.1 (from -r requirements.txt (line 4))
|
||||||
|
Downloading flask_wtf-1.2.2-py3-none-any.whl.metadata (3.4 kB)
|
||||||
|
Collecting Flask-Migrate>=4.0 (from -r requirements.txt (line 5))
|
||||||
|
Downloading Flask_Migrate-4.1.0-py3-none-any.whl.metadata (3.3 kB)
|
||||||
|
Collecting requests>=2.28 (from -r requirements.txt (line 7))
|
||||||
|
Downloading requests-2.32.3-py3-none-any.whl.metadata (4.6 kB)
|
||||||
|
Collecting python-dotenv>=1.0 (from -r requirements.txt (line 8))
|
||||||
|
Downloading python_dotenv-1.1.0-py3-none-any.whl.metadata (24 kB)
|
||||||
|
Collecting werkzeug<3.0,>=2.3 (from -r requirements.txt (line 9))
|
||||||
|
Downloading werkzeug-2.3.8-py3-none-any.whl.metadata (4.1 kB)
|
||||||
|
Collecting waitress (from -r requirements.txt (line 10))
|
||||||
|
Downloading waitress-3.0.2-py3-none-any.whl.metadata (5.8 kB)
|
||||||
|
Collecting cryptography (from Authlib==1.5.2)
|
||||||
|
Downloading cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl.metadata (5.7 kB)
|
||||||
|
Collecting Jinja2>=3.1.2 (from Flask<3.0,>=2.3->-r requirements.txt (line 2))
|
||||||
|
Downloading jinja2-3.1.6-py3-none-any.whl.metadata (2.9 kB)
|
||||||
|
Collecting itsdangerous>=2.1.2 (from Flask<3.0,>=2.3->-r requirements.txt (line 2))
|
||||||
|
Downloading itsdangerous-2.2.0-py3-none-any.whl.metadata (1.9 kB)
|
||||||
|
Collecting click>=8.1.3 (from Flask<3.0,>=2.3->-r requirements.txt (line 2))
|
||||||
|
Downloading click-8.1.8-py3-none-any.whl.metadata (2.3 kB)
|
||||||
|
Collecting blinker>=1.6.2 (from Flask<3.0,>=2.3->-r requirements.txt (line 2))
|
||||||
|
Downloading blinker-1.9.0-py3-none-any.whl.metadata (1.6 kB)
|
||||||
|
Collecting sqlalchemy>=2.0.16 (from Flask-SQLAlchemy>=3.0->-r requirements.txt (line 3))
|
||||||
|
Downloading sqlalchemy-2.0.40-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (9.6 kB)
|
||||||
|
Collecting wtforms (from Flask-WTF>=1.1->-r requirements.txt (line 4))
|
||||||
|
Downloading wtforms-3.2.1-py3-none-any.whl.metadata (5.3 kB)
|
||||||
|
Collecting alembic>=1.9.0 (from Flask-Migrate>=4.0->-r requirements.txt (line 5))
|
||||||
|
Downloading alembic-1.15.2-py3-none-any.whl.metadata (7.3 kB)
|
||||||
|
Collecting charset-normalizer<4,>=2 (from requests>=2.28->-r requirements.txt (line 7))
|
||||||
|
Downloading charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (35 kB)
|
||||||
|
Collecting idna<4,>=2.5 (from requests>=2.28->-r requirements.txt (line 7))
|
||||||
|
Downloading idna-3.10-py3-none-any.whl.metadata (10 kB)
|
||||||
|
Collecting urllib3<3,>=1.21.1 (from requests>=2.28->-r requirements.txt (line 7))
|
||||||
|
Downloading urllib3-2.4.0-py3-none-any.whl.metadata (6.5 kB)
|
||||||
|
Collecting certifi>=2017.4.17 (from requests>=2.28->-r requirements.txt (line 7))
|
||||||
|
Downloading certifi-2025.1.31-py3-none-any.whl.metadata (2.5 kB)
|
||||||
|
Collecting MarkupSafe>=2.1.1 (from werkzeug<3.0,>=2.3->-r requirements.txt (line 9))
|
||||||
|
Downloading MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (4.0 kB)
|
||||||
|
Collecting Mako (from alembic>=1.9.0->Flask-Migrate>=4.0->-r requirements.txt (line 5))
|
||||||
|
Downloading mako-1.3.10-py3-none-any.whl.metadata (2.9 kB)
|
||||||
|
Collecting typing-extensions>=4.12 (from alembic>=1.9.0->Flask-Migrate>=4.0->-r requirements.txt (line 5))
|
||||||
|
Downloading typing_extensions-4.13.2-py3-none-any.whl.metadata (3.0 kB)
|
||||||
|
Collecting greenlet>=1 (from sqlalchemy>=2.0.16->Flask-SQLAlchemy>=3.0->-r requirements.txt (line 3))
|
||||||
|
Downloading greenlet-3.2.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl.metadata (4.1 kB)
|
||||||
|
Collecting cffi>=1.12 (from cryptography->Authlib==1.5.2)
|
||||||
|
Downloading cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (1.5 kB)
|
||||||
|
Collecting pycparser (from cffi>=1.12->cryptography->Authlib==1.5.2)
|
||||||
|
Downloading pycparser-2.22-py3-none-any.whl.metadata (943 bytes)
|
||||||
|
Downloading authlib-1.5.2-py2.py3-none-any.whl (232 kB)
|
||||||
|
Downloading flask-2.3.3-py3-none-any.whl (96 kB)
|
||||||
|
Downloading flask_sqlalchemy-3.1.1-py3-none-any.whl (25 kB)
|
||||||
|
Downloading flask_wtf-1.2.2-py3-none-any.whl (12 kB)
|
||||||
|
Downloading Flask_Migrate-4.1.0-py3-none-any.whl (21 kB)
|
||||||
|
Downloading requests-2.32.3-py3-none-any.whl (64 kB)
|
||||||
|
Downloading python_dotenv-1.1.0-py3-none-any.whl (20 kB)
|
||||||
|
Downloading werkzeug-2.3.8-py3-none-any.whl (242 kB)
|
||||||
|
Downloading waitress-3.0.2-py3-none-any.whl (56 kB)
|
||||||
|
Downloading alembic-1.15.2-py3-none-any.whl (231 kB)
|
||||||
|
Downloading blinker-1.9.0-py3-none-any.whl (8.5 kB)
|
||||||
|
Downloading certifi-2025.1.31-py3-none-any.whl (166 kB)
|
||||||
|
Downloading charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (143 kB)
|
||||||
|
Downloading click-8.1.8-py3-none-any.whl (98 kB)
|
||||||
|
Downloading idna-3.10-py3-none-any.whl (70 kB)
|
||||||
|
Downloading itsdangerous-2.2.0-py3-none-any.whl (16 kB)
|
||||||
|
Downloading jinja2-3.1.6-py3-none-any.whl (134 kB)
|
||||||
|
Downloading MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (23 kB)
|
||||||
|
Downloading sqlalchemy-2.0.40-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (3.2 MB)
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.2/3.2 MB 3.3 MB/s eta 0:00:00
|
||||||
|
Downloading urllib3-2.4.0-py3-none-any.whl (128 kB)
|
||||||
|
Downloading cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl (4.2 MB)
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 4.2/4.2 MB 1.8 MB/s eta 0:00:00
|
||||||
|
Downloading wtforms-3.2.1-py3-none-any.whl (152 kB)
|
||||||
|
Downloading cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (467 kB)
|
||||||
|
Downloading greenlet-3.2.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl (583 kB)
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 583.9/583.9 kB 1.8 MB/s eta 0:00:00
|
||||||
|
Downloading typing_extensions-4.13.2-py3-none-any.whl (45 kB)
|
||||||
|
Downloading mako-1.3.10-py3-none-any.whl (78 kB)
|
||||||
|
Downloading pycparser-2.22-py3-none-any.whl (117 kB)
|
||||||
|
Installing collected packages: waitress, urllib3, typing-extensions, python-dotenv, pycparser, MarkupSafe, itsdangerous, idna, greenlet, click, charset-normalizer, certifi, blinker, wtforms, werkzeug, sqlalchemy, requests, Mako, Jinja2, cffi, Flask, cryptography, alembic, Flask-WTF, Flask-SQLAlchemy, Authlib, Flask-Migrate
|
||||||
|
Successfully installed Authlib-1.5.2 Flask-2.3.3 Flask-Migrate-4.1.0 Flask-SQLAlchemy-3.1.1 Flask-WTF-1.2.2 Jinja2-3.1.6 Mako-1.3.10 MarkupSafe-3.0.2 alembic-1.15.2 blinker-1.9.0 certifi-2025.1.31 cffi-1.17.1 charset-normalizer-3.4.1 click-8.1.8 cryptography-44.0.2 greenlet-3.2.1 idna-3.10 itsdangerous-2.2.0 pycparser-2.22 python-dotenv-1.1.0 requests-2.32.3 sqlalchemy-2.0.40 typing-extensions-4.13.2 urllib3-2.4.0 waitress-3.0.2 werkzeug-2.3.8 wtforms-3.2.1
|
||||||
|
WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning.
|
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
Flask==2.3.3
|
||||||
|
Flask-SQLAlchemy==3.0.5
|
||||||
|
Flask-WTF==1.1.1
|
||||||
|
Flask-Migrate==4.0.7
|
||||||
|
flasgger==v0.9.7b2
|
||||||
|
Authlib==1.5.2
|
||||||
|
requests==2.31.0
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
werkzeug==2.3.8
|
||||||
|
waitress==3.0.0
|
697
smart_proxy.py
Normal file
697
smart_proxy.py
Normal file
@ -0,0 +1,697 @@
|
|||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import requests
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
from flask import Blueprint, request, session, url_for, render_template, redirect, jsonify
|
||||||
|
from flasgger import swag_from
|
||||||
|
from authlib.integrations.flask_oauth2 import AuthorizationServer, ResourceProtector
|
||||||
|
from authlib.integrations.sqla_oauth2 import (
|
||||||
|
create_query_client_func,
|
||||||
|
create_save_token_func,
|
||||||
|
)
|
||||||
|
from authlib.oauth2.rfc6749 import grants
|
||||||
|
from authlib.oauth2.rfc6750 import BearerTokenValidator
|
||||||
|
from authlib.oauth2.rfc6749.errors import OAuth2Error
|
||||||
|
|
||||||
|
# Import necessary components (designed for modularity)
|
||||||
|
try:
|
||||||
|
from models import database, RegisteredApp, OAuthToken, AuthorizationCode
|
||||||
|
except ImportError:
|
||||||
|
# Allow standalone usage or alternative model imports for FHIRFLARE integration
|
||||||
|
database = None
|
||||||
|
RegisteredApp = None
|
||||||
|
OAuthToken = None
|
||||||
|
AuthorizationCode = None
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Define the Flask blueprint for SMART on FHIR routes
|
||||||
|
smart_blueprint = Blueprint('smart_proxy', __name__, template_folder='templates')
|
||||||
|
|
||||||
|
# Initialize Authlib AuthorizationServer and ResourceProtector globally
|
||||||
|
authorization_server = AuthorizationServer()
|
||||||
|
require_oauth = ResourceProtector()
|
||||||
|
|
||||||
|
# Helper functions for Authlib
|
||||||
|
def query_client(client_id):
|
||||||
|
"""Query client details from the database for Authlib."""
|
||||||
|
logger.debug(f"Querying client with identifier: {client_id}")
|
||||||
|
client = RegisteredApp.query.filter_by(client_id=client_id).first()
|
||||||
|
if client:
|
||||||
|
logger.debug(f"Found client: {client.app_name} (ClientID: {client.client_id})")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Client not found for identifier: {client_id}")
|
||||||
|
return client
|
||||||
|
|
||||||
|
def save_token(token, request):
|
||||||
|
"""Save generated OAuth2 tokens to the database."""
|
||||||
|
client = request.client
|
||||||
|
if not client:
|
||||||
|
logger.error("No client provided for token saving")
|
||||||
|
return
|
||||||
|
token_item = OAuthToken(
|
||||||
|
client_id=client.client_id,
|
||||||
|
token_type=token.get('token_type'),
|
||||||
|
access_token=token.get('access_token'),
|
||||||
|
refresh_token=token.get('refresh_token'),
|
||||||
|
scope=token.get('scope'),
|
||||||
|
issued_at=token.get('issued_at'),
|
||||||
|
expires_in=token.get('expires_in')
|
||||||
|
)
|
||||||
|
database.session.add(token_item)
|
||||||
|
database.session.commit()
|
||||||
|
logger.info(f"Saved token for client '{client.client_id}' with scope: {token.get('scope')}")
|
||||||
|
|
||||||
|
def normalize_scopes(scope_string):
|
||||||
|
"""Convert plus signs or other delimiters to spaces for scopes."""
|
||||||
|
if not scope_string:
|
||||||
|
return ''
|
||||||
|
return ' '.join(scope_string.replace('+', ' ').split())
|
||||||
|
|
||||||
|
# Dummy user class to satisfy Authlib's requirement
|
||||||
|
class DummyUser:
|
||||||
|
def get_user_id(self):
|
||||||
|
"""Return a user ID for Authlib."""
|
||||||
|
return "proxy_user"
|
||||||
|
|
||||||
|
# Custom Authorization Code Grant implementation
|
||||||
|
class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
|
||||||
|
"""Customized Authorization Code Grant using database storage for codes."""
|
||||||
|
|
||||||
|
SUPPORTED_RESPONSE_TYPES = ['code'] # Explicitly allow 'code'
|
||||||
|
include_refresh_token = True # Ensure refresh tokens are included
|
||||||
|
TOKEN_DURATION = 3600 # Access token lifetime in seconds (1 hour)
|
||||||
|
REFRESH_TOKEN_DURATION = 86400 # Refresh token lifetime in seconds (1 day)
|
||||||
|
|
||||||
|
def create_authorization_code(self, client, grant_user, request):
|
||||||
|
# Generate a secure random code
|
||||||
|
code = secrets.token_urlsafe(32)
|
||||||
|
logger.debug(f"Generated authorization code '{code[:6]}...' for client '{client.client_id}'")
|
||||||
|
auth_code = AuthorizationCode(
|
||||||
|
code=code,
|
||||||
|
client_id=client.client_id,
|
||||||
|
redirect_uri=request.args.get('redirect_uri'),
|
||||||
|
scope=request.args.get('scope'),
|
||||||
|
nonce=request.args.get('nonce'),
|
||||||
|
code_challenge=request.args.get('code_challenge'),
|
||||||
|
code_challenge_method=request.args.get('code_challenge_method'),
|
||||||
|
response_type=request.args.get('response_type', 'code'),
|
||||||
|
state=request.args.get('state'),
|
||||||
|
issued_at=int(time.time()),
|
||||||
|
expires_at=int(time.time()) + 600 # 10 minutes expiry
|
||||||
|
)
|
||||||
|
database.session.add(auth_code)
|
||||||
|
database.session.commit()
|
||||||
|
logger.debug(f"Stored authorization code in database: {auth_code.code[:6]}...")
|
||||||
|
return code
|
||||||
|
|
||||||
|
def parse_authorization_code(self, code, client):
|
||||||
|
logger.debug(f"Parsing authorization code '{code[:6]}...' for client '{client.client_id}'")
|
||||||
|
auth_code = AuthorizationCode.query.filter_by(code=code, client_id=client.client_id).first()
|
||||||
|
if not auth_code:
|
||||||
|
logger.warning(f"Authorization code '{code[:6]}...' not found for client '{client.client_id}'")
|
||||||
|
return None
|
||||||
|
if time.time() > auth_code.expires_at:
|
||||||
|
logger.warning(f"Authorization code '{code[:6]}...' has expired")
|
||||||
|
database.session.delete(auth_code)
|
||||||
|
database.session.commit()
|
||||||
|
return None
|
||||||
|
logger.debug(f"Parsed valid authorization code data for '{code[:6]}...': {auth_code.__dict__}")
|
||||||
|
return auth_code
|
||||||
|
|
||||||
|
def query_authorization_code(self, code, client):
|
||||||
|
"""Query the authorization code from the database."""
|
||||||
|
logger.debug(f"Querying authorization code '{code[:6]}...' for client '{client.client_id}'")
|
||||||
|
auth_code = AuthorizationCode.query.filter_by(code=code, client_id=client.client_id).first()
|
||||||
|
if not auth_code:
|
||||||
|
logger.warning(f"Authorization code '{code[:6]}...' not found for client '{client.client_id}'")
|
||||||
|
return None
|
||||||
|
if time.time() > auth_code.expires_at:
|
||||||
|
logger.warning(f"Authorization code '{code[:6]}...' has expired")
|
||||||
|
database.session.delete(auth_code)
|
||||||
|
database.session.commit()
|
||||||
|
return None
|
||||||
|
logger.debug(f"Queried valid authorization code data for '{code[:6]}...': {auth_code.__dict__}")
|
||||||
|
return auth_code
|
||||||
|
|
||||||
|
def delete_authorization_code(self, code):
|
||||||
|
logger.debug(f"Deleting authorization code '{code[:6]}...'")
|
||||||
|
auth_code = AuthorizationCode.query.filter_by(code=code).first()
|
||||||
|
if auth_code:
|
||||||
|
database.session.delete(auth_code)
|
||||||
|
database.session.commit()
|
||||||
|
logger.debug(f"Authorization code '{code[:6]}...' deleted from database")
|
||||||
|
|
||||||
|
def authenticate_user(self, code_data):
|
||||||
|
logger.debug("Returning dummy user for authorization code (no user authentication in this proxy)")
|
||||||
|
return DummyUser()
|
||||||
|
|
||||||
|
def validate_code_verifier(self, code_verifier, code_challenge, code_challenge_method):
|
||||||
|
"""Validate the PKCE code verifier against the stored code challenge."""
|
||||||
|
logger.debug(f"Validating PKCE code verifier. Code verifier: {code_verifier[:6]}..., Code challenge: {code_challenge[:6]}..., Method: {code_challenge_method}")
|
||||||
|
if not code_verifier or not code_challenge:
|
||||||
|
logger.warning("Missing code verifier or code challenge for PKCE validation")
|
||||||
|
return False
|
||||||
|
if code_challenge_method == 'plain':
|
||||||
|
expected_challenge = code_verifier
|
||||||
|
elif code_challenge_method == 'S256':
|
||||||
|
expected_challenge = base64.urlsafe_b64encode(
|
||||||
|
hashlib.sha256(code_verifier.encode('ascii')).digest()
|
||||||
|
).decode('ascii').rstrip('=')
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unsupported code challenge method: {code_challenge_method}")
|
||||||
|
return False
|
||||||
|
is_valid = expected_challenge == code_challenge
|
||||||
|
logger.debug(f"PKCE validation result: {is_valid}. Expected challenge: {expected_challenge[:6]}..., Provided challenge: {code_challenge[:6]}...")
|
||||||
|
return is_valid
|
||||||
|
|
||||||
|
def create_token_response(self):
|
||||||
|
"""Override to ensure proper token generation with client and grant_type."""
|
||||||
|
# Validate the token request (sets self.client, self.request, etc.)
|
||||||
|
self.validate_token_request()
|
||||||
|
|
||||||
|
# Get the authorization code from form data
|
||||||
|
code = self.request.form.get('code')
|
||||||
|
client = self.request.client
|
||||||
|
grant_type = self.request.form.get('grant_type')
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
raise OAuth2Error("invalid_request", "Missing 'code' parameter")
|
||||||
|
|
||||||
|
if not grant_type:
|
||||||
|
raise OAuth2Error("invalid_request", "Missing 'grant_type' parameter")
|
||||||
|
|
||||||
|
# Authenticate user
|
||||||
|
code_data = self.query_authorization_code(code, client)
|
||||||
|
if not code_data:
|
||||||
|
raise OAuth2Error("invalid_grant", "Authorization code not found or expired")
|
||||||
|
|
||||||
|
user = self.authenticate_user(code_data)
|
||||||
|
if not user:
|
||||||
|
raise OAuth2Error("invalid_grant", "There is no 'user' for this code.")
|
||||||
|
|
||||||
|
# Get the scope
|
||||||
|
scope = code_data.get_scope()
|
||||||
|
|
||||||
|
# Generate the token with custom expiration durations
|
||||||
|
token = self.generate_token(
|
||||||
|
client,
|
||||||
|
grant_type,
|
||||||
|
user,
|
||||||
|
scope,
|
||||||
|
include_refresh_token=self.include_refresh_token,
|
||||||
|
code=code
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete the authorization code
|
||||||
|
self.delete_authorization_code(code)
|
||||||
|
|
||||||
|
# Ensure token has correct expires_in and scope
|
||||||
|
body = {
|
||||||
|
"access_token": token["access_token"],
|
||||||
|
"token_type": token["token_type"],
|
||||||
|
"expires_in": self.TOKEN_DURATION, # Explicitly set to ensure correct value
|
||||||
|
"scope": scope if scope else "", # Use the scope directly
|
||||||
|
"refresh_token": token.get("refresh_token"),
|
||||||
|
}
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
return 200, body, headers
|
||||||
|
|
||||||
|
def generate_token(self, client, grant_type, user=None, scope=None, include_refresh_token=True, **kwargs):
|
||||||
|
"""Generate an access token with optional nonce."""
|
||||||
|
logger.debug(f"Generating token with client: {client.client_id}, grant_type: {grant_type}, user: {user.get_user_id() if user else None}, scope: {scope}")
|
||||||
|
# Extract code from kwargs and remove it to avoid passing to parent
|
||||||
|
code = kwargs.pop('code', None)
|
||||||
|
|
||||||
|
# Prepare token parameters
|
||||||
|
token = {}
|
||||||
|
token['access_token'] = secrets.token_urlsafe(32)
|
||||||
|
token['token_type'] = 'Bearer'
|
||||||
|
token['expires_in'] = self.TOKEN_DURATION
|
||||||
|
token['scope'] = scope if scope else ""
|
||||||
|
token['issued_at'] = int(time.time())
|
||||||
|
|
||||||
|
# Generate refresh token if enabled
|
||||||
|
if include_refresh_token and client.check_grant_type('refresh_token'):
|
||||||
|
token['refresh_token'] = secrets.token_urlsafe(32)
|
||||||
|
# Set refresh token expiration (issued_at + duration)
|
||||||
|
token['refresh_token_expires_in'] = self.REFRESH_TOKEN_DURATION
|
||||||
|
|
||||||
|
# Handle nonce if code is provided
|
||||||
|
if code:
|
||||||
|
code_data = self.parse_authorization_code(code, client)
|
||||||
|
if code_data:
|
||||||
|
logger.info(f"Generating token. Nonce: {code_data.nonce}")
|
||||||
|
if code_data.nonce:
|
||||||
|
token['nonce'] = code_data.nonce
|
||||||
|
else:
|
||||||
|
logger.warning(f"Could not retrieve code data for code '{code[:6]}...' during token generation")
|
||||||
|
|
||||||
|
logger.debug(f"Generated token for client '{client.client_id}'. Scope: {scope}")
|
||||||
|
return token
|
||||||
|
|
||||||
|
# Configure Authlib
|
||||||
|
def configure_oauth(flask_app, db=None, registered_app_model=None, oauth_token_model=None, auth_code_model=None):
|
||||||
|
"""Configure the Authlib AuthorizationServer with the Flask application."""
|
||||||
|
global database, RegisteredApp, OAuthToken, AuthorizationCode
|
||||||
|
# Allow dependency injection for FHIRFLARE integration
|
||||||
|
database = db or database
|
||||||
|
RegisteredApp = registered_app_model or RegisteredApp
|
||||||
|
OAuthToken = oauth_token_model or OAuthToken
|
||||||
|
AuthorizationCode = auth_code_model or AuthorizationCode
|
||||||
|
|
||||||
|
if not (database and RegisteredApp and OAuthToken and AuthorizationCode):
|
||||||
|
raise ValueError("Database and models must be provided for OAuth2 configuration")
|
||||||
|
|
||||||
|
authorization_server.init_app(
|
||||||
|
flask_app,
|
||||||
|
query_client=query_client,
|
||||||
|
save_token=save_token
|
||||||
|
)
|
||||||
|
authorization_server.register_grant(AuthorizationCodeGrant)
|
||||||
|
logger.info("Authlib OAuth2 server configured successfully")
|
||||||
|
|
||||||
|
# SMART on FHIR OAuth2 routes
|
||||||
|
@smart_blueprint.route('/authorize', methods=['GET'])
|
||||||
|
@swag_from({
|
||||||
|
'tags': ['OAuth2'],
|
||||||
|
'summary': 'Initiate SMART on FHIR authorization',
|
||||||
|
'description': 'Redirects to the consent page for user authorization, then redirects to the client redirect URI with an authorization code.',
|
||||||
|
'parameters': [
|
||||||
|
{
|
||||||
|
'name': 'response_type',
|
||||||
|
'in': 'query',
|
||||||
|
'type': 'string',
|
||||||
|
'required': True,
|
||||||
|
'description': 'Must be "code" for authorization code flow.',
|
||||||
|
'enum': ['code']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'client_id',
|
||||||
|
'in': 'query',
|
||||||
|
'type': 'string',
|
||||||
|
'required': True,
|
||||||
|
'description': 'Client ID of the registered application.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'redirect_uri',
|
||||||
|
'in': 'query',
|
||||||
|
'type': 'string',
|
||||||
|
'required': True,
|
||||||
|
'description': 'Redirect URI where the authorization code will be sent.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'scope',
|
||||||
|
'in': 'query',
|
||||||
|
'type': 'string',
|
||||||
|
'required': True,
|
||||||
|
'description': 'Space-separated list of scopes (e.g., "openid launch/patient").'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'state',
|
||||||
|
'in': 'query',
|
||||||
|
'type': 'string',
|
||||||
|
'required': True,
|
||||||
|
'description': 'Opaque value used to maintain state between the request and callback.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'aud',
|
||||||
|
'in': 'query',
|
||||||
|
'type': 'string',
|
||||||
|
'required': True,
|
||||||
|
'description': 'Audience URL of the FHIR server.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'code_challenge',
|
||||||
|
'in': 'query',
|
||||||
|
'type': 'string',
|
||||||
|
'required': False,
|
||||||
|
'description': 'PKCE code challenge (required for PKCE flow).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'code_challenge_method',
|
||||||
|
'in': 'query',
|
||||||
|
'type': 'string',
|
||||||
|
'required': False,
|
||||||
|
'description': 'PKCE code challenge method (default: "plain").',
|
||||||
|
'enum': ['plain', 'S256']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'responses': {
|
||||||
|
'200': {
|
||||||
|
'description': 'Renders the consent page for user authorization.'
|
||||||
|
},
|
||||||
|
'400': {
|
||||||
|
'description': 'Invalid request parameters (e.g., invalid client_id, redirect_uri, or scopes).'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
def authorize():
|
||||||
|
"""Handle SMART on FHIR authorization requests."""
|
||||||
|
try:
|
||||||
|
# Log the incoming request parameters
|
||||||
|
logger.debug(f"Incoming request args: {request.args}")
|
||||||
|
|
||||||
|
# Normalize scopes in the request
|
||||||
|
request_args = request.args.copy()
|
||||||
|
request_args['scope'] = normalize_scopes(request_args.get('scope', ''))
|
||||||
|
# Remove 'aud' as it's not needed for Authlib's OAuth flow
|
||||||
|
if 'aud' in request_args:
|
||||||
|
del request_args['aud']
|
||||||
|
request.args = request_args
|
||||||
|
logger.debug(f"Normalized request args: {request.args}")
|
||||||
|
|
||||||
|
# Validate client
|
||||||
|
client = query_client(request_args.get('client_id'))
|
||||||
|
if not client:
|
||||||
|
logger.error(f"Invalid client_id: {request_args.get('client_id')}")
|
||||||
|
return render_template('errors/auth_error.html', error="Invalid client_id"), 400
|
||||||
|
|
||||||
|
# Validate response_type
|
||||||
|
response_type = request_args.get('response_type')
|
||||||
|
logger.debug(f"Received response_type: {response_type}")
|
||||||
|
if not response_type or not client.check_response_type(response_type):
|
||||||
|
logger.error(f"Unsupported response_type: {response_type}")
|
||||||
|
return render_template('errors/auth_error.html', error=f"Unsupported response_type: {response_type}"), 400
|
||||||
|
|
||||||
|
# Validate redirect_uri
|
||||||
|
redirect_uri = request_args.get('redirect_uri')
|
||||||
|
if not redirect_uri or not client.check_redirect_uri(redirect_uri):
|
||||||
|
logger.error(f"Invalid redirect_uri: {redirect_uri} (Registered URIs: {client.redirect_uris})")
|
||||||
|
return render_template('errors/auth_error.html', error=f"Invalid redirect_uri: {redirect_uri}"), 400
|
||||||
|
|
||||||
|
# Validate scopes
|
||||||
|
requested_scopes = request_args.get('scope', '')
|
||||||
|
allowed_scopes = client.get_allowed_scope(requested_scopes)
|
||||||
|
if not allowed_scopes:
|
||||||
|
logger.error(f"No valid scopes provided: {requested_scopes} (Allowed scopes: {client.scopes})")
|
||||||
|
return render_template('errors/auth_error.html', error=f"No valid scopes provided: {requested_scopes}"), 400
|
||||||
|
logger.debug(f"Allowed scopes after validation: {allowed_scopes}")
|
||||||
|
|
||||||
|
# Update the request args with the normalized allowed scopes
|
||||||
|
request_args['scope'] = allowed_scopes
|
||||||
|
request.args = request_args
|
||||||
|
logger.debug(f"Updated request args with normalized scopes: {request.args}")
|
||||||
|
|
||||||
|
# Store request details in session for consent
|
||||||
|
session['auth_request_params'] = request_args.to_dict()
|
||||||
|
session['auth_client_id'] = client.client_id
|
||||||
|
session['auth_scope'] = allowed_scopes
|
||||||
|
session['auth_response_type'] = response_type
|
||||||
|
session['auth_state'] = request_args.get('state')
|
||||||
|
session['auth_nonce'] = request_args.get('nonce')
|
||||||
|
|
||||||
|
# Render consent page
|
||||||
|
logger.info(f"Rendering consent page for client: {client.app_name}")
|
||||||
|
return render_template(
|
||||||
|
'auth/consent.html',
|
||||||
|
client=client,
|
||||||
|
scopes=session['auth_scope'].split(),
|
||||||
|
request_params=session['auth_request_params']
|
||||||
|
)
|
||||||
|
except OAuth2Error as error:
|
||||||
|
logger.error(f"OAuth2 error during authorization: {error.error} - {error.description}", exc_info=True)
|
||||||
|
return render_template('errors/auth_error.html', error=f"{error.error}: {error.description}"), error.status_code
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(f"Unexpected error during authorization: {error}", exc_info=True)
|
||||||
|
return render_template('errors/auth_error.html', error="An unexpected error occurred during authorization"), 500
|
||||||
|
|
||||||
|
@smart_blueprint.route('/consent', methods=['POST'])
|
||||||
|
def handle_consent():
|
||||||
|
"""Handle user consent submission for SMART on FHIR authorization."""
|
||||||
|
try:
|
||||||
|
# Log the entire form data for debugging
|
||||||
|
logger.debug(f"Form data received: {request.form}")
|
||||||
|
|
||||||
|
# Retrieve grant details from session
|
||||||
|
request_params = session.pop('auth_request_params', None)
|
||||||
|
client_id = session.pop('auth_client_id', None)
|
||||||
|
scope = session.pop('auth_scope', None)
|
||||||
|
response_type = session.pop('auth_response_type', None)
|
||||||
|
state = session.pop('auth_state', None)
|
||||||
|
nonce = session.pop('auth_nonce', None)
|
||||||
|
|
||||||
|
logger.debug(f"Consent route: client_id={client_id}, scope={scope}, response_type={response_type}, state={state}, nonce={nonce}")
|
||||||
|
|
||||||
|
if not request_params or not client_id or not scope or not response_type:
|
||||||
|
logger.error("Consent handling failed: Missing session data")
|
||||||
|
return render_template('errors/auth_error.html', error="Session expired or invalid"), 400
|
||||||
|
|
||||||
|
# Validate client
|
||||||
|
client = query_client(client_id)
|
||||||
|
if not client:
|
||||||
|
logger.error(f"Invalid client_id: {client_id}")
|
||||||
|
return render_template('errors/auth_error.html', error="Invalid client_id"), 400
|
||||||
|
|
||||||
|
# Validate response_type
|
||||||
|
if not client.check_response_type(response_type):
|
||||||
|
logger.error(f"Unsupported response_type: {response_type} for client '{client_id}'")
|
||||||
|
return jsonify({"error": "unsupported_response_type"}), 400
|
||||||
|
|
||||||
|
# Validate redirect_uri
|
||||||
|
redirect_uri = request_params.get('redirect_uri')
|
||||||
|
if not redirect_uri or not client.check_redirect_uri(redirect_uri):
|
||||||
|
logger.error(f"Invalid redirect_uri in consent: {redirect_uri} (Registered URIs: {client.redirect_uris})")
|
||||||
|
return render_template('errors/auth_error.html', error=f"Invalid redirect_uri: {redirect_uri}"), 400
|
||||||
|
|
||||||
|
# Validate scopes
|
||||||
|
allowed_scopes = client.get_allowed_scope(scope)
|
||||||
|
if not allowed_scopes:
|
||||||
|
logger.error(f"No valid scopes in consent: {scope} (Allowed scopes: {client.scopes})")
|
||||||
|
return render_template('errors/auth_error.html', error=f"No valid scopes: {scope}"), 400
|
||||||
|
logger.debug(f"Allowed scopes after validation in consent: {allowed_scopes}")
|
||||||
|
|
||||||
|
consent_granted = request.form.get('consent') == 'allow'
|
||||||
|
logger.debug(f"Consent granted: {consent_granted}")
|
||||||
|
|
||||||
|
if consent_granted:
|
||||||
|
logger.info(f"Consent granted for client '{client_id}' with scope '{scope}'")
|
||||||
|
# Reconstruct request for Authlib
|
||||||
|
request_args = request_params.copy()
|
||||||
|
request_args['scope'] = allowed_scopes # Use validated scopes in normalized order
|
||||||
|
if state:
|
||||||
|
request_args['state'] = state
|
||||||
|
if nonce:
|
||||||
|
request_args['nonce'] = nonce
|
||||||
|
request.args = request_args
|
||||||
|
request.form = {} # Ensure form is empty for GET-like request
|
||||||
|
request.method = 'GET' # Match /authorize
|
||||||
|
logger.debug(f"Reconstructed request args for token generation: {request.args}")
|
||||||
|
|
||||||
|
# Access AuthorizationCodeGrant instance to generate the code
|
||||||
|
grant = AuthorizationCodeGrant(client, authorization_server)
|
||||||
|
auth_code = grant.create_authorization_code(client, None, request)
|
||||||
|
redirect_url = f"{redirect_uri}?code={auth_code}&state={state}" if state else f"{redirect_uri}?code={auth_code}"
|
||||||
|
logger.debug(f"Redirecting to: {redirect_url}")
|
||||||
|
return redirect(redirect_url)
|
||||||
|
else:
|
||||||
|
logger.info(f"Consent denied for client '{client_id}'")
|
||||||
|
redirect_url = f"{redirect_uri}?error=access_denied&error_description=The+resource+owner+or+authorization+server+denied+the+request"
|
||||||
|
if state:
|
||||||
|
redirect_url += f"&state={state}"
|
||||||
|
logger.debug(f"Redirecting to: {redirect_url}")
|
||||||
|
return redirect(redirect_url)
|
||||||
|
except OAuth2Error as error:
|
||||||
|
logger.error(f"OAuth2 error during consent handling: {error.error} - {error.description}", exc_info=True)
|
||||||
|
return render_template('errors/auth_error.html', error=f"{error.error}: {error.description}"), error.status_code
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(f"Unexpected error during consent handling: {error}", exc_info=True)
|
||||||
|
return render_template('errors/auth_error.html', error="An unexpected error occurred during consent handling"), 500
|
||||||
|
|
||||||
|
@smart_blueprint.route('/token', methods=['POST'])
|
||||||
|
@swag_from({
|
||||||
|
'tags': ['OAuth2'],
|
||||||
|
'summary': 'Exchange authorization code or refresh token for access token',
|
||||||
|
'description': 'Exchanges an authorization code or refresh token for an access token and refresh token.',
|
||||||
|
'parameters': [
|
||||||
|
{
|
||||||
|
'name': 'grant_type',
|
||||||
|
'in': 'formData',
|
||||||
|
'type': 'string',
|
||||||
|
'required': True,
|
||||||
|
'description': 'Type of grant ("authorization_code" or "refresh_token").',
|
||||||
|
'enum': ['authorization_code', 'refresh_token']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'code',
|
||||||
|
'in': 'formData',
|
||||||
|
'type': 'string',
|
||||||
|
'required': False,
|
||||||
|
'description': 'Authorization code received from /authorize (required for grant_type=authorization_code).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'refresh_token',
|
||||||
|
'in': 'formData',
|
||||||
|
'type': 'string',
|
||||||
|
'required': False,
|
||||||
|
'description': 'Refresh token to obtain a new access token (required for grant_type=refresh_token).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'redirect_uri',
|
||||||
|
'in': 'formData',
|
||||||
|
'type': 'string',
|
||||||
|
'required': False,
|
||||||
|
'description': 'The redirect URI used in the authorization request (required for grant_type=authorization_code).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'client_id',
|
||||||
|
'in': 'formData',
|
||||||
|
'type': 'string',
|
||||||
|
'required': True,
|
||||||
|
'description': 'Client ID of the registered application.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'client_secret',
|
||||||
|
'in': 'formData',
|
||||||
|
'type': 'string',
|
||||||
|
'required': True,
|
||||||
|
'description': 'Client secret of the registered application.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'code_verifier',
|
||||||
|
'in': 'formData',
|
||||||
|
'type': 'string',
|
||||||
|
'required': False,
|
||||||
|
'description': 'PKCE code verifier (required if code_challenge was used in /authorize).'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'responses': {
|
||||||
|
'200': {
|
||||||
|
'description': 'Access token response',
|
||||||
|
'schema': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'access_token': {'type': 'string'},
|
||||||
|
'token_type': {'type': 'string', 'example': 'Bearer'},
|
||||||
|
'expires_in': {'type': 'integer', 'example': 3600},
|
||||||
|
'scope': {'type': 'string'},
|
||||||
|
'refresh_token': {'type': 'string'}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'400': {
|
||||||
|
'description': 'Invalid request (e.g., invalid code, refresh token, client credentials, redirect_uri, or code_verifier).'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
def issue_token():
|
||||||
|
"""Issue SMART on FHIR access tokens."""
|
||||||
|
try:
|
||||||
|
logger.info("Token endpoint called")
|
||||||
|
# Log the incoming request parameters
|
||||||
|
logger.debug(f"Request form data: {request.form}")
|
||||||
|
logger.debug(f"Request headers: {dict(request.headers)}")
|
||||||
|
response = authorization_server.create_token_response()
|
||||||
|
logger.info(f"Token response created. Status code: {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
try:
|
||||||
|
token_data = response.get_json()
|
||||||
|
logger.debug(f"Token issued: AccessToken={token_data.get('access_token')[:6]}..., Type={token_data.get('token_type')}, Scope={token_data.get('scope')}, ExpiresIn={token_data.get('expires_in')}, RefreshToken={token_data.get('refresh_token')[:6] if token_data.get('refresh_token') else None}...")
|
||||||
|
return response
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not parse token response JSON for logging")
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
error_data = response.get_json()
|
||||||
|
logger.warning(f"Token request failed: Error={error_data.get('error')}, Description={error_data.get('error_description')}")
|
||||||
|
return jsonify(error_data), response.status_code
|
||||||
|
except Exception:
|
||||||
|
logger.warning(f"Token request failed with status {response.status_code}. Could not parse error response")
|
||||||
|
return jsonify({"error": "invalid_request", "error_description": "Failed to process token request"}), response.status_code
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(f"Unexpected error during token issuance: {error}", exc_info=True)
|
||||||
|
return jsonify({"error": "server_error", "error_description": "An unexpected error occurred during token issuance"}), 500
|
||||||
|
|
||||||
|
@smart_blueprint.route('/revoke', methods=['POST'])
|
||||||
|
@swag_from({
|
||||||
|
'tags': ['OAuth2'],
|
||||||
|
'summary': 'Revoke an access or refresh token',
|
||||||
|
'description': 'Revokes an access or refresh token, rendering it invalid for future use.',
|
||||||
|
'parameters': [
|
||||||
|
{
|
||||||
|
'name': 'token',
|
||||||
|
'in': 'formData',
|
||||||
|
'type': 'string',
|
||||||
|
'required': True,
|
||||||
|
'description': 'The access or refresh token to revoke.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'client_id',
|
||||||
|
'in': 'formData',
|
||||||
|
'type': 'string',
|
||||||
|
'required': True,
|
||||||
|
'description': 'Client ID of the registered application.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'client_secret',
|
||||||
|
'in': 'formData',
|
||||||
|
'type': 'string',
|
||||||
|
'required': True,
|
||||||
|
'description': 'Client secret of the registered application.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'responses': {
|
||||||
|
'200': {
|
||||||
|
'description': 'Token revoked successfully.',
|
||||||
|
'schema': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'message': {'type': 'string', 'example': 'Token revoked successfully.'}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'400': {
|
||||||
|
'description': 'Invalid request (e.g., invalid client credentials or token).'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
def revoke_token():
|
||||||
|
"""Revoke an access or refresh token."""
|
||||||
|
try:
|
||||||
|
logger.info("Token revocation endpoint called")
|
||||||
|
# Log the incoming request parameters
|
||||||
|
logger.debug(f"Request form data: {request.form}")
|
||||||
|
logger.debug(f"Request headers: {dict(request.headers)}")
|
||||||
|
|
||||||
|
# Validate client credentials
|
||||||
|
client_id = request.form.get('client_id')
|
||||||
|
client_secret = request.form.get('client_secret')
|
||||||
|
token = request.form.get('token')
|
||||||
|
|
||||||
|
if not client_id or not client_secret or not token:
|
||||||
|
logger.warning("Missing required parameters in token revocation request")
|
||||||
|
return jsonify({"error": "invalid_request", "error_description": "Missing required parameters"}), 400
|
||||||
|
|
||||||
|
client = query_client(client_id)
|
||||||
|
if not client:
|
||||||
|
logger.warning(f"Invalid client_id: {client_id}")
|
||||||
|
return jsonify({"error": "invalid_client", "error_description": "Invalid client_id"}), 400
|
||||||
|
|
||||||
|
if not client.check_client_secret(client_secret):
|
||||||
|
logger.warning(f"Invalid client_secret for client '{client_id}'")
|
||||||
|
return jsonify({"error": "invalid_client", "error_description": "Invalid client_secret"}), 400
|
||||||
|
|
||||||
|
# Revoke the token (delete it from the database)
|
||||||
|
# Check if the token is an access token or refresh token
|
||||||
|
token_entry = OAuthToken.query.filter(
|
||||||
|
(OAuthToken.access_token == token) | (OAuthToken.refresh_token == token)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if token_entry:
|
||||||
|
database.session.delete(token_entry)
|
||||||
|
database.session.commit()
|
||||||
|
logger.info(f"Token revoked successfully for client '{client_id}'")
|
||||||
|
return jsonify({"message": "Token revoked successfully."}), 200
|
||||||
|
else:
|
||||||
|
logger.warning(f"Token not found: {token[:6]}...")
|
||||||
|
# RFC 7009 states that revocation endpoints should return 200 even if the token is not found
|
||||||
|
return jsonify({"message": "Token revoked successfully."}), 200
|
||||||
|
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(f"Unexpected error during token revocation: {error}", exc_info=True)
|
||||||
|
return jsonify({"error": "server_error", "error_description": "An unexpected error occurred during token revocation"}), 500
|
27
templates/about.html
Normal file
27
templates/about.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}About - {{ site_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h1 class="text-center mb-4">About {{ site_name }}</h1>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<p>{{ site_name }} is a SMART on FHIR SSO proxy designed to facilitate secure app integration with FHIR servers.</p>
|
||||||
|
<h5>Features</h5>
|
||||||
|
<ul>
|
||||||
|
<li>Secure OAuth2 authorization flows with PKCE support.</li>
|
||||||
|
<li>App gallery for managing registered applications.</li>
|
||||||
|
<li>Proxy FHIR API requests to backend servers.</li>
|
||||||
|
<li>Light and dark theme support.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Built with Flask, Bootstrap, and Authlib. Learn more about SMART on FHIR at <a href="https://www.hl7.org/fhir/smart-app-launch/" target="_blank">HL7 SMART on FHIR</a>.</p>
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-primary">Back to Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
19
templates/app_gallery/delete.html
Normal file
19
templates/app_gallery/delete.html
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8 col-lg-6">
|
||||||
|
<div class="alert alert-danger text-center" role="alert">
|
||||||
|
<h4 class="alert-heading"><i class="bi bi-exclamation-triangle-fill me-2"></i>Delete Application: {{ app.app_name }}</h4>
|
||||||
|
<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) }}">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
101
templates/app_gallery/edit.html
Normal file
101
templates/app_gallery/edit.html
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% 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">Edit Application: {{ app.app_name }}</h2>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
75
templates/app_gallery/gallery.html
Normal file
75
templates/app_gallery/gallery.html
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1>Registered Applications</h1>
|
||||||
|
<a href="{{ url_for('register_app') }}" class="btn btn-success">
|
||||||
|
<i class="bi bi-plus-circle-fill me-1"></i> Register New App
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if apps %}
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
|
||||||
|
{% for app in apps %}
|
||||||
|
<div class="col">
|
||||||
|
<div class="card h-100 shadow-sm">
|
||||||
|
{% if app.logo_uri %}
|
||||||
|
<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 #}
|
||||||
|
</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 %}
|
||||||
|
</p>
|
||||||
|
<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">
|
||||||
|
{% for uri in app.redirect_uris.split() %}
|
||||||
|
<li><code class="user-select-all" style="word-break: break-all;">{{ uri }}</code></li>
|
||||||
|
{% else %}
|
||||||
|
<li><span class="text-danger">None configured</span></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<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>
|
||||||
|
{% endfor %}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info text-center" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill me-2"></i> No applications have been registered yet.
|
||||||
|
<a href="{{ url_for('register_app') }}" class="alert-link ms-2">Register the first one!</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
113
templates/app_gallery/register.html
Normal file
113
templates/app_gallery/register.html
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{# Optional: Import form helpers if you create _formhelpers.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">Register New SMART Application</h2>
|
||||||
|
</div>
|
||||||
|
<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.
|
||||||
|
</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 --- #}
|
||||||
|
<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> {# Closes form tag #}
|
||||||
|
</div> {# Closes card-body #}
|
||||||
|
</div> {# Closes card #}
|
||||||
|
</div> {# Closes col #}
|
||||||
|
</div> {# Closes row #}
|
||||||
|
</div> {# Closes container #}
|
||||||
|
{% endblock %} {# Closes block content #}
|
||||||
|
|
68
templates/auth/consent.html
Normal file
68
templates/auth/consent.html
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8 col-lg-6">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header text-center">
|
||||||
|
<h3><i class="bi bi-shield-check me-2"></i>Authorize Application</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
{% if client.logo_uri %}
|
||||||
|
<img src="{{ client.logo_uri }}" alt="{{ client.app_name }} Logo" style="max-width: 80px; max-height: 80px; object-fit: contain; margin-bottom: 1rem;">
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-app-indicator" style="font-size: 4rem; color: #ccc;"></i>
|
||||||
|
{% endif %}
|
||||||
|
<h5 class="card-title mt-2">{{ client.app_name }}</h5>
|
||||||
|
<p class="text-muted">is requesting permission to access your data.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>This application is requesting the following permissions (scopes):</p>
|
||||||
|
<ul class="list-group list-group-flush mb-4">
|
||||||
|
{% for scope in scopes %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<i class="bi bi-key-fill text-secondary me-2"></i>
|
||||||
|
{{ scope }}
|
||||||
|
{# Add descriptions for common scopes later #}
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="list-group-item text-warning">No specific scopes requested.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{# Display other request parameters like launch context if available #}
|
||||||
|
{% if request_params.launch %}
|
||||||
|
<div class="alert alert-info small">
|
||||||
|
<strong>Launch Context:</strong> <code>{{ request_params.launch }}</code>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if request_params.aud %}
|
||||||
|
<div class="alert alert-secondary small">
|
||||||
|
<strong>Audience (FHIR Server):</strong> <code>{{ request_params.aud }}</code>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('smart_proxy.handle_consent') }}">
|
||||||
|
{# Include the auth_code_id as a hidden field #}
|
||||||
|
<input type="hidden" name="auth_code_id" value="{{ auth_code_id }}">
|
||||||
|
{# We don't need CSRF here as the state is handled by Authlib #}
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="submit" name="consent" value="allow" class="btn btn-primary btn-lg">
|
||||||
|
<i class="bi bi-check-circle-fill me-1"></i> Allow Access
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="consent" value="deny" class="btn btn-outline-danger btn-lg">
|
||||||
|
<i class="bi bi-x-circle-fill me-1"></i> Deny Access
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-muted small text-center">
|
||||||
|
You will be redirected back to the application after making a choice.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
388
templates/base.html
Normal file
388
templates/base.html
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<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;
|
||||||
|
color: #212529;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
h1, h5 {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.navbar-light {
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
.navbar-brand {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #0d6efd !important; /* Standard Bootstrap Blue */
|
||||||
|
}
|
||||||
|
.nav-link {
|
||||||
|
color: #333 !important;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
.nav-link:hover {
|
||||||
|
color: #0d6efd !important;
|
||||||
|
}
|
||||||
|
.nav-link.active {
|
||||||
|
color: #0d6efd !important;
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 2px solid #0d6efd;
|
||||||
|
}
|
||||||
|
/* ... (Keep other styles like dropdown, card, footer, alerts from FHIRFLARE base.html) ... */
|
||||||
|
.dropdown-menu {
|
||||||
|
list-style: none !important;
|
||||||
|
position: absolute;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0;
|
||||||
|
min-width: 160px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.dropdown-item {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
}
|
||||||
|
.dropdown-item.active {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15) !important;
|
||||||
|
}
|
||||||
|
.text-muted {
|
||||||
|
color: #6c757d !important;
|
||||||
|
}
|
||||||
|
/* Footer Styles */
|
||||||
|
footer {
|
||||||
|
background-color: #ffffff;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
margin-top: auto; /* Push footer to bottom */
|
||||||
|
}
|
||||||
|
.footer-left,
|
||||||
|
.footer-right {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.footer-left a,
|
||||||
|
.footer-right a {
|
||||||
|
color: #007bff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.footer-left a:hover,
|
||||||
|
.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 {
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
.form-check-input {
|
||||||
|
width: 1.25rem !important;
|
||||||
|
height: 1.25rem !important;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
display: inline-block !important;
|
||||||
|
border: 1px solid #6c757d;
|
||||||
|
}
|
||||||
|
.form-check-input:checked {
|
||||||
|
background-color: #007bff;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
.form-check-label {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.form-check-label i {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
.form-check {
|
||||||
|
margin-bottom: 1rem; /* Or adjust as needed */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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; }
|
||||||
|
html[data-theme="dark"] .nav-link { color: #f8f9fa !important; }
|
||||||
|
html[data-theme="dark"] .nav-link:hover, html[data-theme="dark"] .nav-link.active { color: #4dabf7 !important; border-bottom-color: #4dabf7; }
|
||||||
|
html[data-theme="dark"] .dropdown-menu { background-color: #495057; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); }
|
||||||
|
html[data-theme="dark"] .dropdown-item { color: #f8f9fa; }
|
||||||
|
html[data-theme="dark"] .dropdown-item:hover { background-color: #6c757d; }
|
||||||
|
html[data-theme="dark"] .dropdown-item.active { background-color: #4dabf7; color: white !important; }
|
||||||
|
html[data-theme="dark"] .card { background-color: #343a40; border: 1px solid #495057; }
|
||||||
|
html[data-theme="dark"] .text-muted { color: #adb5bd !important; }
|
||||||
|
html[data-theme="dark"] h1, html[data-theme="dark"] h5 { color: #f8f9fa; }
|
||||||
|
html[data-theme="dark"] .form-label { color: #f8f9fa; }
|
||||||
|
html[data-theme="dark"] .form-control, html[data-theme="dark"] .form-select { background-color: #495057; color: #f8f9fa; border-color: #6c757d; }
|
||||||
|
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"] .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) {
|
||||||
|
.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">
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="{{ url_for('index') }}">{{ site_name }}</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 d-flex justify-content-between" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if request.endpoint == 'index' else '' }}" aria-current="page" href="{{ url_for('index') }}"><i class="bi bi-house-door-fill me-1"></i> Home</a>
|
||||||
|
</li>
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
<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
|
||||||
|
</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>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</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">
|
||||||
|
<i class="fas fa-sun"></i> <i class="fas fa-moon d-none"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="flex-grow-1">
|
||||||
|
<div class="container mt-4">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="mt-3">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert
|
||||||
|
{{ 'alert-danger' if category == 'error' else
|
||||||
|
'alert-success' if category == 'success' else
|
||||||
|
'alert-warning' if category == 'warning' else
|
||||||
|
'alert-info' }}
|
||||||
|
alert-dismissible fade show" role="alert">
|
||||||
|
<i class="fas {{ 'fa-exclamation-triangle me-2' if category == 'error' else
|
||||||
|
'fa-check-circle me-2' if category == 'success' else
|
||||||
|
'fa-exclamation-triangle me-2' if category == 'warning' else
|
||||||
|
'fa-info-circle me-2' }}"></i>
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</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;
|
||||||
|
const toggle = document.getElementById('themeToggle');
|
||||||
|
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
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
55
templates/consent.html
Normal file
55
templates/consent.html
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Authorize {{ client.app_name }} - {{ site_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<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>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-center">
|
||||||
|
<strong>{{ client.app_name }}</strong> is requesting access to your data.
|
||||||
|
</p>
|
||||||
|
<h5 class="mt-4">Permissions 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>
|
||||||
|
{% 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">
|
||||||
|
<i class="bi bi-check-circle-fill me-1"></i> Allow
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="consent" value="deny" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-x-circle-fill me-1"></i> Deny
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
16
templates/errors/404.html
Normal file
16
templates/errors/404.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8 col-lg-6">
|
||||||
|
<div class="alert alert-warning text-center" role="alert">
|
||||||
|
<h4 class="alert-heading"><i class="bi bi-exclamation-triangle-fill me-2"></i>Page Not Found</h4>
|
||||||
|
<p>The page you are looking for does not exist.</p>
|
||||||
|
<hr>
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-secondary">Return Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
16
templates/errors/500.html
Normal file
16
templates/errors/500.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8 col-lg-6">
|
||||||
|
<div class="alert alert-danger text-center" role="alert">
|
||||||
|
<h4 class="alert-heading"><i class="bi bi-exclamation-triangle-fill me-2"></i>Internal Server Error</h4>
|
||||||
|
<p>Something went wrong on our end. Please try again later.</p>
|
||||||
|
<hr>
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-secondary">Return Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
20
templates/errors/auth_error.html
Normal file
20
templates/errors/auth_error.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8 col-lg-6">
|
||||||
|
<div class="alert alert-danger text-center" role="alert">
|
||||||
|
<h4 class="alert-heading"><i class="bi bi-exclamation-triangle-fill me-2"></i>Authorization Error</h4>
|
||||||
|
<p>An error occurred during the authorization process.</p>
|
||||||
|
<hr>
|
||||||
|
<p class="mb-0 small"><strong>Details:</strong> {{ error | e }}</p> {# Display the error message #}
|
||||||
|
</div>
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-secondary">Return Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
18
templates/index.html
Normal file
18
templates/index.html
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% 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>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center flex-wrap">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
21
templates/proxy_settings.html
Normal file
21
templates/proxy_settings.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Proxy Settings - {{ site_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h1 class="text-center mb-4">Proxy Settings</h1>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>FHIR Server URL</h5>
|
||||||
|
<p class="text-muted">Current FHIR Server: <code>{{ fhir_server_url }}</code></p>
|
||||||
|
<p class="text-warning">Editing the FHIR server URL requires environment variable changes. Please update <code>FHIR_SERVER_URL</code> in your <code>.env</code> file and restart the application.</p>
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-primary">Back to Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
25
templates/security.html
Normal file
25
templates/security.html
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Security Settings - {{ site_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h1 class="text-center mb-4">Security Settings</h1>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>Security Features</h5>
|
||||||
|
<ul class="list-group">
|
||||||
|
<li class="list-group-item"><strong>Client Secret Hashing:</strong> Enabled (uses PBKDF2-SHA256)</li>
|
||||||
|
<li class="list-group-item"><strong>PKCE Support:</strong> Enabled (S256 method supported)</li>
|
||||||
|
<li class="list-group-item"><strong>Token Expiration:</strong> Access tokens expire in 3600 seconds (1 hour)</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mt-3 text-muted">Security settings are managed via application configuration. Contact the administrator to modify these settings.</p>
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-primary">Back to Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
25
templates/server_endpoints.html
Normal file
25
templates/server_endpoints.html
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Server Endpoints - {{ site_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h1 class="text-center mb-4">Server Endpoints</h1>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>Available Endpoints</h5>
|
||||||
|
<ul class="list-group">
|
||||||
|
<li class="list-group-item"><strong>Authorization:</strong> <code>/oauth2/authorize</code></li>
|
||||||
|
<li class="list-group-item"><strong>Token:</strong> <code>/oauth2/token</code></li>
|
||||||
|
<li class="list-group-item"><strong>FHIR Proxy:</strong> <code>/oauth2/proxy/<path></code></li>
|
||||||
|
</ul>
|
||||||
|
<p class="mt-3"><a href="/apidocs" class="btn btn-primary">View API Documentation</a></p>
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-secondary">Back to Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
43
templates/test_client.html
Normal file
43
templates/test_client.html
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% 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 Client Launch</h2>
|
||||||
|
</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.
|
||||||
|
</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>
|
||||||
|
{% 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="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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
Loading…
x
Reference in New Issue
Block a user