6.4 KiB
6.4 KiB
SMART on FHIR Authorization
SMART on FHIR is the standard authorization framework for FHIR APIs, built on OAuth 2.0.
Table of Contents
Discovery Endpoint
Servers MUST publish authorization configuration at:
GET /.well-known/smart-configuration
Response:
{
"authorization_endpoint": "https://auth.example.org/authorize",
"token_endpoint": "https://auth.example.org/token",
"token_endpoint_auth_methods_supported": ["client_secret_basic", "private_key_jwt"],
"scopes_supported": ["openid", "fhirUser", "launch", "launch/patient",
"patient/*.rs", "user/*.cruds", "offline_access"],
"capabilities": ["launch-ehr", "launch-standalone", "client-public",
"client-confidential-symmetric", "permission-v2", "sso-openid-connect"]
}
Scope Syntax
SMART v2 (Current Standard):
<context>/<resource>.<permissions>
context: patient | user | system
resource: Patient | Observation | * (wildcard)
permissions: c (create) | r (read) | u (update) | d (delete) | s (search)
Examples:
| Scope | Meaning |
|---|---|
patient/Patient.rs |
Read and search Patient for current patient context |
patient/Observation.rs |
Read and search Observations for current patient |
patient/*.rs |
Read and search all resources for current patient |
user/Encounter.cruds |
Full CRUD + search on Encounters the user can access |
system/*.rs |
Backend service: read/search all resources |
SMART v1 (Legacy - still widely used):
<context>/<resource>.<read|write|*>
| v1 Scope | Equivalent v2 |
|---|---|
patient/Patient.read |
patient/Patient.rs |
patient/Patient.write |
patient/Patient.cud |
patient/Patient.* |
patient/Patient.cruds |
Scope Types
| Scope Type | Use Case | Context Required |
|---|---|---|
patient/ |
Patient-facing apps | patient launch context |
user/ |
Provider-facing apps | User's access permissions |
system/ |
Backend services | Pre-configured policy |
Launch Context
| Scope | Provides | In Token Response |
|---|---|---|
launch |
EHR launch context | patient, encounter |
launch/patient |
Standalone patient selection | patient |
launch/encounter |
Standalone encounter selection | encounter |
openid fhirUser |
User identity | id_token with fhirUser claim |
offline_access |
Refresh token (persistent) | refresh_token |
online_access |
Refresh token (session-bound) | refresh_token |
Authorization Flow (EHR Launch)
1. EHR redirects to app with launch parameter:
GET https://app.example.com/launch?iss=https://fhir.hospital.org&launch=abc123
2. App discovers authorization endpoints:
GET https://fhir.hospital.org/.well-known/smart-configuration
3. App redirects to authorization:
GET https://auth.hospital.org/authorize?
response_type=code&
client_id=my-app&
redirect_uri=https://app.example.com/callback&
scope=launch patient/Patient.rs patient/Observation.rs&
state=xyz789&
aud=https://fhir.hospital.org&
launch=abc123
4. User authenticates & authorizes
5. Authorization server redirects back:
GET https://app.example.com/callback?code=auth_code_here&state=xyz789
6. App exchanges code for token:
POST https://auth.hospital.org/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=auth_code_here&
redirect_uri=https://app.example.com/callback&
client_id=my-app
7. Token response includes context:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "launch patient/Patient.rs patient/Observation.rs",
"patient": "123",
"encounter": "456"
}
Backend Services (System-to-System)
For automated systems without user interaction:
import jwt
import time
import requests
# 1. Create signed JWT assertion
now = int(time.time())
claims = {
"iss": CLIENT_ID,
"sub": CLIENT_ID,
"aud": TOKEN_ENDPOINT,
"exp": now + 300,
"jti": str(uuid.uuid4())
}
assertion = jwt.encode(claims, PRIVATE_KEY, algorithm="RS384")
# 2. Exchange for access token
response = requests.post(TOKEN_ENDPOINT, data={
"grant_type": "client_credentials",
"scope": "system/*.rs",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": assertion
})
token = response.json()["access_token"]
# 3. Use token in FHIR requests
headers = {"Authorization": f"Bearer {token}"}
patients = requests.get(f"{FHIR_BASE}/Patient", headers=headers)
Enforcing Scopes
Python/FastAPI Middleware:
from fastapi import Request, HTTPException
import re
def parse_smart_scopes(token_scopes: str) -> dict:
"""Parse SMART scopes into a permissions structure."""
permissions = {}
for scope in token_scopes.split():
match = re.match(r'(patient|user|system)/(\w+|\*)\.([cruds]+|read|write|\*)', scope)
if match:
context, resource, perms = match.groups()
if perms == 'read': perms = 'rs'
elif perms == 'write': perms = 'cud'
elif perms == '*': perms = 'cruds'
if resource not in permissions:
permissions[resource] = set()
permissions[resource].update(perms)
return permissions
async def check_scope(request: Request, resource_type: str, action: str):
"""Verify the token has required scope for the action."""
token_scopes = request.state.token_scopes
permissions = parse_smart_scopes(token_scopes)
action_map = {'create': 'c', 'read': 'r', 'update': 'u', 'delete': 'd', 'search': 's'}
required = action_map.get(action, action)
allowed = permissions.get(resource_type, set()) | permissions.get('*', set())
if required not in allowed:
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {resource_type}.{required}")
Common Authorization Errors
| Status | Error | When |
|---|---|---|
401 |
invalid_token |
Token expired, malformed, or revoked |
403 |
insufficient_scope |
Valid token but missing required scope |
403 |
access_denied |
Resource outside patient compartment |