diff --git a/Gemfile.lock b/Gemfile.lock index e55b3e8..5b7ccf6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -286,6 +286,7 @@ GEM PLATFORMS arm64-darwin-21 x86_64-darwin-20 + x86_64-darwin-22 x86_64-linux DEPENDENCIES diff --git a/lib/smart_app_launch/token_payload_validation.rb b/lib/smart_app_launch/token_payload_validation.rb index 004ab73..5f20495 100644 --- a/lib/smart_app_launch/token_payload_validation.rb +++ b/lib/smart_app_launch/token_payload_validation.rb @@ -3,6 +3,69 @@ module TokenPayloadValidation STRING_FIELDS = ['access_token', 'token_type', 'scope', 'refresh_token'].freeze NUMERIC_FIELDS = ['expires_in'].freeze + # All resource types from DSTU3, STU3, R4, R4B, and R5 + FHIR_RESOURCE_TYPES = [ + "Account", "ActivityDefinition", "ActorDefinition", + "AdministrableProductDefinition", "AdverseEvent", "AllergyIntolerance", + "Appointment", "AppointmentResponse", "ArtifactAssessment", "AuditEvent", + "Basic", "Binary", "BiologicallyDerivedProduct", + "BiologicallyDerivedProductDispense", "BodySite", "BodyStructure", + "Bundle", "CapabilityStatement", "CarePlan", "CareTeam", "CatalogEntry", + "ChargeItem", "ChargeItemDefinition", "Citation", "Claim", + "ClaimResponse", "ClinicalImpression", "ClinicalUseDefinition", + "CodeSystem", "Communication", "CommunicationRequest", + "CompartmentDefinition", "Composition", "ConceptMap", "Condition", + "ConditionDefinition", "Conformance", "Consent", "Contract", "Coverage", + "CoverageEligibilityRequest", "CoverageEligibilityResponse", + "DataElement", "DetectedIssue", "Device", "DeviceAssociation", + "DeviceComponent", "DeviceDefinition", "DeviceDispense", "DeviceMetric", + "DeviceRequest", "DeviceUsage", "DeviceUseRequest", "DeviceUseStatement", + "DiagnosticOrder", "DiagnosticReport", "DocumentManifest", + "DocumentReference", "EffectEvidenceSynthesis", "EligibilityRequest", + "EligibilityResponse", "Encounter", "EncounterHistory", "Endpoint", + "EnrollmentRequest", "EnrollmentResponse", "EpisodeOfCare", + "EventDefinition", "Evidence", "EvidenceReport", "EvidenceVariable", + "ExampleScenario", "ExpansionProfile", "ExplanationOfBenefit", + "FamilyMemberHistory", "Flag", "FormularyItem", "GenomicStudy", "Goal", + "GraphDefinition", "Group", "GuidanceResponse", "HealthcareService", + "ImagingManifest", "ImagingObjectSelection", "ImagingSelection", + "ImagingStudy", "Immunization", "ImmunizationEvaluation", + "ImmunizationRecommendation", "ImplementationGuide", "Ingredient", + "InsurancePlan", "InventoryItem", "InventoryReport", "Invoice", "Library", + "Linkage", "List", "Location", "ManufacturedItemDefinition", "Measure", + "MeasureReport", "Media", "Medication", "MedicationAdministration", + "MedicationDispense", "MedicationKnowledge", "MedicationOrder", + "MedicationRequest", "MedicationStatement", "MedicinalProduct", + "MedicinalProductAuthorization", "MedicinalProductContraindication", + "MedicinalProductDefinition", "MedicinalProductIndication", + "MedicinalProductIngredient", "MedicinalProductInteraction", + "MedicinalProductManufactured", "MedicinalProductPackaged", + "MedicinalProductPharmaceutical", "MedicinalProductUndesirableEffect", + "MessageDefinition", "MessageHeader", "MolecularSequence", "NamingSystem", + "NutritionIntake", "NutritionOrder", "NutritionProduct", "Observation", + "ObservationDefinition", "OperationDefinition", "OperationOutcome", + "Order", "OrderResponse", "Organization", "OrganizationAffiliation", + "PackagedProductDefinition", "Patient", "PaymentNotice", + "PaymentReconciliation", "Permission", "Person", "PlanDefinition", + "Practitioner", "PractitionerRole", "Procedure", "ProcedureRequest", + "ProcessRequest", "ProcessResponse", "Provenance", "Questionnaire", + "QuestionnaireResponse", "ReferralRequest", "RegulatedAuthorization", + "RelatedPerson", "RequestGroup", "RequestOrchestration", "Requirements", + "ResearchDefinition", "ResearchElementDefinition", "ResearchStudy", + "ResearchSubject", "RiskAssessment", "RiskEvidenceSynthesis", "Schedule", + "SearchParameter", "Sequence", "ServiceDefinition", "ServiceRequest", + "Slot", "Specimen", "SpecimenDefinition", "StructureDefinition", + "StructureMap", "Subscription", "SubscriptionStatus", "SubscriptionTopic", + "Substance", "SubstanceDefinition", "SubstanceNucleicAcid", + "SubstancePolymer", "SubstanceProtein", "SubstanceReferenceInformation", + "SubstanceSourceMaterial", "SubstanceSpecification", "SupplyDelivery", + "SupplyRequest", "Task", "TerminologyCapabilities", "TestPlan", + "TestReport", "TestScript", "Transport", "ValueSet", "VerificationResult", + "VisionPrescription" + ].to_set.freeze + + FHIR_ID_REGEX = /[A-Za-z0-9\-\.]{1,64}(\/_history\/[A-Za-z0-9\-\.]{1,64})?(#[A-Za-z0-9\-\.]{1,64})?/.freeze + def validate_required_fields_present(body, required_fields) missing_fields = required_fields.select { |field| body[field].blank? } missing_fields_string = missing_fields.map { |field| "`#{field}`" }.join(', ') @@ -49,5 +112,25 @@ def validate_token_field_types(body) "Expected `#{field}` to be a Numeric, but found #{body[field].class.name}" end end + + def validate_fhir_context(fhir_context) + return if fhir_context.nil? + + assert fhir_context.is_a?(Array), "`fhirContext` field is a #{fhir_context.class.name}, but should be an Array" + + fhir_context.each do |reference| + assert reference.is_a?(String), "`#{reference.inspect}` is not a string" + end + + fhir_context.each do |reference| + assert !reference.start_with?('http'), "`#{reference}` is not a relative reference" + + resource_type, id = reference.split('/') + assert FHIR_RESOURCE_TYPES.include?(resource_type), + "`#{resource_type}` is not a valid FHIR resource type" + + assert id.match?(FHIR_ID_REGEX), "`#{id}` is not a valid FHIR id" + end + end end end diff --git a/lib/smart_app_launch/token_response_body_test.rb b/lib/smart_app_launch/token_response_body_test.rb index 4028fdd..b481aef 100644 --- a/lib/smart_app_launch/token_response_body_test.rb +++ b/lib/smart_app_launch/token_response_body_test.rb @@ -11,6 +11,8 @@ class TokenResponseBodyTest < Inferno::Test has been denied. `access_token`, `token_type`, and `scope` are required. `token_type` must be Bearer. `expires_in` is required for token refreshes. + + The format of the optional `fhirContext` field is validated if present. ) id :smart_token_response_body @@ -48,6 +50,8 @@ class TokenResponseBodyTest < Inferno::Test assert access_token.present?, 'Token response did not contain an access token' assert token_response_body['token_type']&.casecmp('Bearer')&.zero?, '`token_type` field must have a value of `Bearer`' + + validate_fhir_context(token_response_body['fhirContext']) end end end diff --git a/spec/smart_app_launch/token_response_body_test_spec.rb b/spec/smart_app_launch/token_response_body_test_spec.rb index 3aa381b..1e720c8 100644 --- a/spec/smart_app_launch/token_response_body_test_spec.rb +++ b/spec/smart_app_launch/token_response_body_test_spec.rb @@ -116,6 +116,66 @@ def create_token_request(body: nil, status: 200, headers: nil) end end + context 'when the fhirContext field is present' do + it 'passes if fhirContext valid' do + numericalElement = 123 + body = valid_body.merge(fhirContext: ["Organization/123", "DiagnosticReport/123", "Observation/123/_history/2"]) + create_token_request(body: body) + + result = run(test, requested_scopes: 'patient/*.*') + expect(result.result).to eq('pass') + end + + it 'fails if fhirContext is not an Array' do + body = valid_body.merge(fhirContext: "Organization/123") + create_token_request(body: body) + + result = run(test, requested_scopes: 'patient/*.*') + expect(result.result).to eq('fail') + expect(result.result_message).to match("`fhirContext` field is a String, but should be an Array") + end + + it 'fails if fhirContext contains a non-string element' do + numericalElement = 123 + body = valid_body.merge(fhirContext: ["Organization/123", numericalElement]) + create_token_request(body: body) + + result = run(test, requested_scopes: 'patient/*.*') + expect(result.result).to eq('fail') + expect(result.result_message).to match("`#{numericalElement}` is not a string") + end + + it 'fails if fhirContext contains an absolute reference' do + canonicalURL = "https://example.org/Organization/123/|v2023-05-03" + body = valid_body.merge(fhirContext: [canonicalURL]) + create_token_request(body: body) + + result = run(test, requested_scopes: 'patient/*.*') + expect(result.result).to eq('fail') + expect(result.result_message).to match("`#{canonicalURL}` is not a relative reference") + end + + it 'fails if fhirContext contains a reference with an invalid resource type' do + invalidResourceType = "Hospital" + body = valid_body.merge(fhirContext: ["#{invalidResourceType}/123"]) + create_token_request(body: body) + + result = run(test, requested_scopes: 'patient/*.*') + expect(result.result).to eq('fail') + expect(result.result_message).to match("`#{invalidResourceType}` is not a valid FHIR resource type") + end + + it 'fails if fhirContext contains a reference with an invalid id' do + invalidFhirID = '@#' + body = valid_body.merge(fhirContext: ["Organization/#{invalidFhirID}"]) + create_token_request(body: body) + + result = run(test, requested_scopes: 'patient/*.*') + expect(result.result).to eq('fail') + expect(result.result_message).to match("`#{invalidFhirID}` is not a valid FHIR id") + end + end + it 'persists outputs' do inputs = { access_token: 'ACCESS_TOKEN',