mirror of
https://github.com/Sudo-JHare/FHIRVINE-Smart-Proxy.git
synced 2025-12-13 21:35:14 +00:00
697 lines
31 KiB
Python
697 lines
31 KiB
Python
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 |