Chuyển đến nội dung chính

Bài 7: SMART on FHIR — OAuth2/OIDC cho Healthcare APIs

Triển khai SMART on FHIR (Substitutable Medical Applications, Reusable Technologies) với Keycloak: SMART App Launch Framework, scopes cho FHIR resources (patient/*.read, user/*.write), launch context, EHR Launch vs Standalone Launch, Backend Services Authorization, và tích hợp với HAPI FHIR Server trên Quarkus.

🏗️ Kiến trúc — Bài 7 Bài 7: SMART on FHIR — OAuth2/OIDC cho Healthcare APIs

Xây dựng Hệ thống Y tế Microservices — Quarkus, PostgreSQL, Keycloak chuẩn HIPAA

Phần 2: Identity & Access Management với Keycloak

xdev.asia

1. SMART on FHIR Overview

SMART on FHIR Launch Flow — OAuth2/OIDC qua Keycloak cho EHR

1.1. SMART là gì?

SMART (Substitutable Medical Applications, Reusable Technologies) on FHIR là một tiêu chuẩn mở cho phép các ứng dụng third-party truy cập dữ liệu y tế một cách an toàn thông qua FHIR APIs. SMART định nghĩa:

  • App Launch Framework: Cách app được khởi chạy từ EHR hoặc standalone
  • Authorization scopes: Granular permissions cho FHIR resources
  • Launch context: Patient, encounter, location context truyền cho app
  • Backend Services: Service-to-service authorization không cần user interaction

1.2. SMART App Launch Flows

EHR Launch Flow:

  1. User clicks "Launch App" in EHR
  2. EHR sends launch request with context (patient ID, encounter ID)
  3. App redirects to Authorization Server (Keycloak)
  4. User authenticates (or SSO)
  5. Keycloak issues access token with SMART scopes
  6. App uses token to access FHIR resources

Standalone Launch Flow:

  1. User opens app directly (e.g., mobile app)
  2. App redirects to Keycloak for authentication
  3. User authenticates with credentials + MFA
  4. App requests SMART scopes
  5. If patient context needed → patient picker
  6. Keycloak issues access token
  7. App accesses FHIR resources

Backend Services Authorization:

  1. Service authenticates with signed JWT assertion
  2. Keycloak validates JWT and issues access token
  3. Service accesses FHIR resources → No user interaction required. Used for: data sync, analytics, reporting.

2. SMART Scopes

2.1. FHIR Resource Scopes

Format: <context>/<resource>.<permission>

Context:
  patient  → Access linked to a specific patient
  user     → Access linked to the current user
  system   → Full system-level access (backend services)

Resource:
  Patient, Observation, MedicationRequest, DiagnosticReport,
  Condition, Encounter, AllergyIntolerance, Immunization, etc.

Permission:
  read     → Search and read resources
  write    → Create, update, delete resources
  *        → All permissions

2.2. Scope Examples cho Healthcare

# Bác sĩ: Truy cập hồ sơ bệnh nhân trong context đang khám
user/Patient.read
user/Observation.read
user/Observation.write          # Vital signs, clinical observations
user/Condition.read
user/Condition.write            # Diagnoses
user/MedicationRequest.write    # Prescriptions
user/DiagnosticReport.read      # Lab results
user/AllergyIntolerance.read

# Bệnh nhân: Truy cập hồ sơ của chính mình
patient/Patient.read
patient/Observation.read
patient/MedicationRequest.read
patient/DiagnosticReport.read
patient/AllergyIntolerance.read
patient/Immunization.read

# Backend Service: Sync lab results
system/DiagnosticReport.write
system/Observation.write
system/ServiceRequest.read

# Ứng dụng nghiên cứu (de-identified data)
system/Patient.read?_elements=gender,birthDate,address.state
system/Condition.read
system/Observation.read

2.3. Keycloak Client Scopes cho SMART

{
  "clientScopes": [
    {
      "name": "patient/*.read",
      "description": "Read all FHIR resources for a specific patient",
      "protocol": "openid-connect",
      "attributes": {
        "include.in.token.scope": "true",
        "display.on.consent.screen": "true",
        "consent.screen.text": "Đọc hồ sơ bệnh án của bạn"
      },
      "protocolMappers": [
        {
          "name": "smart-patient-scope",
          "protocol": "openid-connect",
          "protocolMapper": "oidc-hardcoded-claim-mapper",
          "config": {
            "claim.name": "scope",
            "claim.value": "patient/*.read",
            "access.token.claim": "true"
          }
        }
      ]
    },
    {
      "name": "launch/patient",
      "description": "EHR launch with patient context",
      "protocol": "openid-connect",
      "attributes": {
        "include.in.token.scope": "true"
      },
      "protocolMappers": [
        {
          "name": "patient-launch-context",
          "protocol": "openid-connect",
          "protocolMapper": "oidc-usersessionmodel-note-mapper",
          "config": {
            "user.session.note": "patient_id",
            "claim.name": "patient",
            "access.token.claim": "true"
          }
        }
      ]
    },
    {
      "name": "fhirUser",
      "description": "FHIR User identity claim",
      "protocol": "openid-connect",
      "protocolMappers": [
        {
          "name": "fhir-user-mapper",
          "protocol": "openid-connect",
          "protocolMapper": "oidc-hardcoded-claim-mapper",
          "config": {
            "claim.name": "fhirUser",
            "claim.value": "",
            "access.token.claim": "true",
            "id.token.claim": "true"
          }
        }
      ]
    }
  ]
}

3. SMART on FHIR với Quarkus

3.1. FHIR Resource Server Implementation

@Path("/fhir/r4")
@Authenticated
@Produces("application/fhir+json")
public class FhirPatientResource {

    @Inject
    SecurityIdentity identity;

    @Inject
    PatientRepository patientRepository;

    @Inject
    SmartScopeValidator scopeValidator;

    @GET
    @Path("/Patient/{id}")
    public Response readPatient(@PathParam("id") String id) {
        // Validate SMART scopes
        scopeValidator.requireScope(identity, "patient/Patient.read",
                                    "user/Patient.read");

        // If patient context → verify patient match
        String launchPatient = identity.getAttribute("patient");
        if (launchPatient != null && !launchPatient.equals(id)) {
            if (scopeValidator.hasOnlyPatientScopes(identity)) {
                throw new ForbiddenException(
                    "Patient scope restricted to patient: " + launchPatient);
            }
        }

        Patient patient = patientRepository.findById(id);
        if (patient == null) {
            return Response.status(404).entity(
                createOperationOutcome("Patient not found")).build();
        }

        return Response.ok(patient.toFhirJson()).build();
    }

    @GET
    @Path("/Patient")
    public Response searchPatients(
            @QueryParam("name") String name,
            @QueryParam("birthdate") String birthdate,
            @QueryParam("identifier") String identifier,
            @QueryParam("_count") @DefaultValue("20") int count) {

        scopeValidator.requireScope(identity,
            "patient/Patient.read", "user/Patient.read", "system/Patient.read");

        // Apply patient context restriction
        SearchParams params = SearchParams.builder()
            .name(name)
            .birthdate(birthdate)
            .identifier(identifier)
            .count(Math.min(count, 100))  // Cap results
            .patientContext(identity.getAttribute("patient"))
            .build();

        Bundle bundle = patientRepository.search(params);
        return Response.ok(bundle.toFhirJson()).build();
    }
}

3.2. SMART Scope Validator

@ApplicationScoped
public class SmartScopeValidator {

    public void requireScope(SecurityIdentity identity, String... anyOfScopes) {
        Set<String> grantedScopes = getSmartScopes(identity);

        boolean hasScope = Arrays.stream(anyOfScopes)
            .anyMatch(scope -> matchScope(grantedScopes, scope));

        if (!hasScope) {
            throw new ForbiddenException(
                "Insufficient SMART scope. Required one of: " +
                String.join(", ", anyOfScopes));
        }
    }

    public boolean hasOnlyPatientScopes(SecurityIdentity identity) {
        return getSmartScopes(identity).stream()
            .allMatch(s -> s.startsWith("patient/") || s.equals("openid") ||
                          s.equals("fhirUser") || s.startsWith("launch/"));
    }

    private boolean matchScope(Set<String> granted, String required) {
        // Direct match
        if (granted.contains(required)) return true;

        // Wildcard match: patient/*.read matches patient/Patient.read
        String[] parts = required.split("[/.]");
        if (parts.length == 3) {
            String wildcardScope = parts[0] + "/*." + parts[2];
            return granted.contains(wildcardScope);
        }

        return false;
    }

    private Set<String> getSmartScopes(SecurityIdentity identity) {
        // Extract scopes from JWT claim
        JsonArray scopeClaim = identity.getAttribute("scope");
        if (scopeClaim != null) {
            return scopeClaim.stream()
                .map(JsonValue::toString)
                .collect(Collectors.toSet());
        }
        return Set.of();
    }
}

3.3. SMART Well-Known Configuration

@Path("/.well-known/smart-configuration")
public class SmartConfigurationResource {

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public Response getSmartConfiguration() {
        return Response.ok(Map.of(
            "authorization_endpoint",
                "https://keycloak.hospital.vn/realms/healthcare/protocol/openid-connect/auth",
            "token_endpoint",
                "https://keycloak.hospital.vn/realms/healthcare/protocol/openid-connect/token",
            "introspection_endpoint",
                "https://keycloak.hospital.vn/realms/healthcare/protocol/openid-connect/token/introspect",
            "revocation_endpoint",
                "https://keycloak.hospital.vn/realms/healthcare/protocol/openid-connect/revoke",
            "capabilities", List.of(
                "launch-ehr",
                "launch-standalone",
                "client-public",
                "client-confidential-symmetric",
                "sso-openid-connect",
                "context-passthrough-banner",
                "context-passthrough-style",
                "context-ehr-patient",
                "context-ehr-encounter",
                "context-standalone-patient",
                "permission-offline",
                "permission-patient",
                "permission-user",
                "authorize-post"
            ),
            "scopes_supported", List.of(
                "openid", "fhirUser", "launch", "launch/patient",
                "patient/*.read", "patient/*.write",
                "user/*.read", "user/*.write",
                "system/*.read", "system/*.write"
            ),
            "response_types_supported", List.of("code"),
            "code_challenge_methods_supported", List.of("S256"),
            "token_endpoint_auth_methods_supported", List.of(
                "client_secret_basic", "client_secret_post", "private_key_jwt"
            )
        )).build();
    }
}

4. FHIR Consent Resource

4.1. Patient Consent Management

{
  "resourceType": "Consent",
  "id": "consent-001",
  "status": "active",
  "scope": {
    "coding": [{
      "system": "http://terminology.hl7.org/CodeSystem/consentscope",
      "code": "patient-privacy"
    }]
  },
  "category": [{
    "coding": [{
      "system": "http://loinc.org",
      "code": "59284-0",
      "display": "Patient Consent"
    }]
  }],
  "patient": {
    "reference": "Patient/P-001"
  },
  "dateTime": "2026-04-01T10:00:00+07:00",
  "provision": {
    "type": "permit",
    "period": {
      "start": "2026-04-01",
      "end": "2027-04-01"
    },
    "actor": [{
      "role": {
        "coding": [{
          "system": "http://terminology.hl7.org/CodeSystem/v3-ParticipationType",
          "code": "PRCP",
          "display": "Primary Care Provider"
        }]
      },
      "reference": {
        "reference": "Organization/BV-cho-ray"
      }
    }],
    "action": [{
      "coding": [{
        "system": "http://terminology.hl7.org/CodeSystem/consentaction",
        "code": "access"
      }]
    }],
    "purpose": [{
      "system": "http://terminology.hl7.org/CodeSystem/v3-ActReason",
      "code": "TREAT",
      "display": "Treatment"
    }]
  }
}

5. Backend Services Authorization

5.1. Service Authentication với Signed JWT

// Backend service authenticates with JWT assertion
@ApplicationScoped
public class FhirBackendServiceClient {

    @ConfigProperty(name = "fhir.service.private-key-path")
    String privateKeyPath;

    @ConfigProperty(name = "fhir.service.client-id")
    String clientId;

    @ConfigProperty(name = "fhir.keycloak.token-url")
    String tokenUrl;

    public String getAccessToken() {
        // Create signed JWT assertion (RFC 7523)
        PrivateKey key = loadPrivateKey(privateKeyPath);

        String jwt = Jwt.issuer(clientId)
            .subject(clientId)
            .audience(tokenUrl)
            .expiresIn(Duration.ofMinutes(5))
            .jws().keyId("service-key-1")
            .sign(key);

        // Exchange JWT for access token
        return WebClient.create(tokenUrl)
            .post()
            .sendForm(MultiMap.caseInsensitiveMultiMap()
                .add("grant_type", "client_credentials")
                .add("client_assertion_type",
                    "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
                .add("client_assertion", jwt)
                .add("scope", "system/*.read system/*.write"))
            .await()
            .bodyAsJsonObject()
            .getString("access_token");
    }
}

6. Tổng kết

Trong bài học này, chúng ta đã:

  • Hiểu SMART on FHIR framework và 3 launch flows
  • Thiết kế SMART scopes cho healthcare resources
  • Cấu hình Keycloak Client Scopes cho SMART
  • Implement FHIR Resource Server trên Quarkus với scope validation
  • Xây dựng SMART Well-Known Configuration endpoint
  • Triển khai Patient Consent management với FHIR Consent resource
  • Implement Backend Services Authorization với signed JWT

Bài tập

  1. Cấu hình Keycloak client cho SMART EHR Launch với patient context
  2. Implement FHIR Observation resource server với SMART scope validation
  3. Tạo SMART Well-Known configuration endpoint
  4. Viết integration test cho EHR Launch flow


◀ Bài trướcBài tiếp theo ▶
Bài 6: RBAC & ABAC - Phân quyền Bác sĩ, Y tá, Bệnh nhânBài 8: MFA, Passkeys & Emergency Access cho Nhân viên Y Tế