Module: SMARTAppLaunch::MockSMARTServer
- Defined in:
- lib/smart_app_launch/endpoints/mock_smart_server.rb,
lib/smart_app_launch/endpoints/mock_smart_server/token_endpoint.rb,
lib/smart_app_launch/endpoints/mock_smart_server/authorization_endpoint.rb,
lib/smart_app_launch/endpoints/mock_smart_server/introspection_endpoint.rb,
lib/smart_app_launch/endpoints/mock_smart_server/smart_token_response_creation.rb,
lib/smart_app_launch/endpoints/mock_smart_server/smart_authorization_response_creation.rb,
lib/smart_app_launch/endpoints/mock_smart_server/smart_introspection_response_creation.rb
Defined Under Namespace
Modules: SMARTAuthorizationResponseCreation, SMARTIntrospectionResponseCreation, SMARTTokenResponseCreation Classes: AuthorizationEndpoint, IntrospectionEndpoint, TokenEndpoint
Constant Summary collapse
- SUPPORTED_SCOPES =
['system/*.read', 'user/*.read', 'patient/*.read'].freeze
Class Method Summary collapse
- .authorization_code_request_details(inferno_request) ⇒ Object
- .authorization_code_to_refresh_token(code) ⇒ Object
- .authorization_request_for_code(code, test_session_id) ⇒ Object
- .client_id_from_client_assertion(client_assertion_jwt) ⇒ Object
- .client_id_to_token(client_id, exp_min) ⇒ Object
- .confidential_symmetric_header_value_error(authorization_header_value, client_id, client_secret) ⇒ Object
- .decode_token(token) ⇒ Object
- .identify_smart_signing_key(kid, jku, key_set_input) ⇒ Object
- .issued_token_is_refresh_token(token) ⇒ Object
- .issued_token_to_client_id(token) ⇒ Object
-
.jwk_set(jku, warning_messages = []) ⇒ Object
rubocop:disable Metrics/CyclomaticComplexity.
- .openid_connect_metadata(suite_id) ⇒ Object
- .pkce_error(verifier, challenge, method) ⇒ Object
- .pkce_valid?(verifier, challenge, method, response) ⇒ Boolean
- .refresh_token_to_authorization_code(refresh_token) ⇒ Object
- .request_has_expired_token?(request) ⇒ Boolean
-
.smart_assertion_signature_verification(token, key_set_input) ⇒ Object
rubocop:disable Metrics/CyclomaticComplexity.
- .smart_server_metadata(suite_id) ⇒ Object
- .token_expired?(token, check_time = nil) ⇒ Boolean
- .update_response_for_error(response, error_message) ⇒ Object
- .update_response_for_expired_token(response, type) ⇒ Object
Class Method Details
.authorization_code_request_details(inferno_request) ⇒ Object
270 271 272 273 274 275 276 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 270 def (inferno_request) if inferno_request.verb.downcase == 'get' Rack::Utils.parse_query(URI(inferno_request.url)&.query) elsif inferno_request.verb.downcase == 'post' Rack::Utils.parse_query(inferno_request.request_body) end end |
.authorization_code_to_refresh_token(code) ⇒ Object
93 94 95 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 93 def (code) "#{code}_rt" end |
.authorization_request_for_code(code, test_session_id) ⇒ Object
258 259 260 261 262 263 264 265 266 267 268 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 258 def (code, test_session_id) = Inferno::Repositories::Requests.new.tagged_requests(test_session_id, [AUTHORIZATION_TAG]) .find do |request| location_header = request.response_headers.find { |header| header.name.downcase == 'location' } if location_header.present? && location_header.value.present? Rack::Utils.parse_query(URI(location_header.value)&.query)&.dig('code') == code else false end end end |
.client_id_from_client_assertion(client_assertion_jwt) ⇒ Object
54 55 56 57 58 59 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 54 def client_id_from_client_assertion(client_assertion_jwt) return unless client_assertion_jwt.present? claims, _header = JWT.decode(client_assertion_jwt, nil, false)[0] claims&.dig('iss') end |
.client_id_to_token(client_id, exp_min) ⇒ Object
61 62 63 64 65 66 67 68 69 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 61 def client_id_to_token(client_id, exp_min) token_structure = { client_id:, expiration: exp_min.minutes.from_now.to_i, nonce: SecureRandom.hex(8) }.to_json Base64.urlsafe_encode64(token_structure, padding: false) end |
.confidential_symmetric_header_value_error(authorization_header_value, client_id, client_secret) ⇒ Object
209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 209 def confidential_symmetric_header_value_error(, client_id, client_secret) unless .present? return 'authorization header missing from confidential symmetric client request' end unless .start_with?('Basic ') return 'authorization header for confidential symmetric client request does not use Basic auth' end client_and_secret = begin Base64.strict_decode64(.delete_prefix('Basic ')) rescue return 'Basic authorization header could not be decoded' end expected_client_and_secret = "#{client_id}:#{client_secret}" unless client_and_secret == expected_client_and_secret return 'basic authorization header has the wrong decoded value - ' \ "expected '#{expected_client_and_secret}', got '#{client_and_secret}'" end nil end |
.decode_token(token) ⇒ Object
71 72 73 74 75 76 77 78 79 80 81 82 83 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 71 def decode_token(token) token_to_decode = if issued_token_is_refresh_token(token) (token) else token end return unless token_to_decode.present? JSON.parse(Base64.urlsafe_decode64(token_to_decode)) rescue StandardError nil end |
.identify_smart_signing_key(kid, jku, key_set_input) ⇒ Object
197 198 199 200 201 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 197 def identify_smart_signing_key(kid, jku, key_set_input) key_set = jku.present? ? jku : key_set_input parsed_key_set = jwk_set(key_set) parsed_key_set&.find { |key| key.kid == kid } end |
.issued_token_is_refresh_token(token) ⇒ Object
89 90 91 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 89 def issued_token_is_refresh_token(token) token.end_with?('_rt') end |
.issued_token_to_client_id(token) ⇒ Object
85 86 87 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 85 def issued_token_to_client_id(token) decode_token(token)&.dig('client_id') end |
.jwk_set(jku, warning_messages = []) ⇒ Object
rubocop:disable Metrics/CyclomaticComplexity
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 101 def jwk_set(jku, = []) # rubocop:disable Metrics/CyclomaticComplexity jwk_set = JWT::JWK::Set.new if jku.blank? << 'No key set input.' return jwk_set end jwk_body = # try as raw jwk set begin JSON.parse(jku) rescue JSON::ParserError nil end if jwk_body.blank? retrieved = Faraday.get(jku) # try as url pointing to a jwk set jwk_body = begin JSON.parse(retrieved.body) rescue JSON::ParserError << "Failed to fetch valid json from jwks uri #{jku}." nil end else << 'Providing the JWK Set directly is strongly discouraged.' end return jwk_set if jwk_body.blank? jwk_body['keys']&.each_with_index do |key_hash, index| parsed_key = begin JWT::JWK.new(key_hash) rescue JWT::JWKError => e id = key_hash['kid'] | index << "Key #{id} invalid: #{e}" nil end jwk_set << parsed_key unless parsed_key.blank? end jwk_set end |
.openid_connect_metadata(suite_id) ⇒ Object
39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 39 def (suite_id) base_url = "#{Inferno::Application['base_url']}/custom/#{suite_id}" response_body = { issuer: base_url + FHIR_PATH, authorization_endpoint: base_url + AUTHORIZATION_PATH, token_endpoint: base_url + TOKEN_PATH, jwks_uri: base_url + OIDC_JWKS_PATH, response_types_supported: ['code', 'id_token', 'token id_token'], subject_types_supported: ['pairwise', 'public'], id_token_signing_alg_values_supported: ['RS256'] }.to_json [200, { 'Content-Type' => 'application/json', 'Access-Control-Allow-Origin' => '*' }, [response_body]] end |
.pkce_error(verifier, challenge, method) ⇒ Object
232 233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 232 def pkce_error(verifier, challenge, method) if verifier.blank? 'pkce check failed: no verifier provided' elsif challenge.blank? 'pkce check failed: no challenge code provided' elsif method == 'S256' return nil unless challenge != AppRedirectTest.calculate_s256_challenge(verifier) "invalid S256 pkce verifier: got '#{AppRedirectTest.calculate_s256_challenge(verifier)}' " \ "expected '#{challenge}'" else "invalid pkce challenge method '#{method}'" end end |
.pkce_valid?(verifier, challenge, method, response) ⇒ Boolean
247 248 249 250 251 252 253 254 255 256 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 247 def pkce_valid?(verifier, challenge, method, response) pkce_error = pkce_error(verifier, challenge, method) if pkce_error.present? update_response_for_error(response, pkce_error) false else true end end |
.refresh_token_to_authorization_code(refresh_token) ⇒ Object
97 98 99 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 97 def (refresh_token) refresh_token[..-4] end |
.request_has_expired_token?(request) ⇒ Boolean
146 147 148 149 150 151 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 146 def request_has_expired_token?(request) return false if request.params[:session_path].present? token = request.headers['authorization']&.delete_prefix('Bearer ') token_expired?(token) end |
.smart_assertion_signature_verification(token, key_set_input) ⇒ Object
rubocop:disable Metrics/CyclomaticComplexity
170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 170 def smart_assertion_signature_verification(token, key_set_input) # rubocop:disable Metrics/CyclomaticComplexity encoded_token = nil if token.is_a?(JWT::EncodedToken) encoded_token = token else begin encoded_token = JWT::EncodedToken.new(token) rescue StandardError => e return "invalid token structure: #{e}" end end return 'invalid token' unless encoded_token.present? return 'missing `alg` header' if encoded_token.header['alg'].blank? return 'missing `kid` header' if encoded_token.header['kid'].blank? jwk = identify_smart_signing_key(encoded_token.header['kid'], encoded_token.header['jku'], key_set_input) return "no key found with `kid` '#{encoded_token.header['kid']}'" if jwk.blank? begin encoded_token.verify_signature!(algorithm: encoded_token.header['alg'], key: jwk.verify_key) rescue StandardError => e return e end nil end |
.smart_server_metadata(suite_id) ⇒ Object
16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 16 def (suite_id) base_url = "#{Inferno::Application['base_url']}/custom/#{suite_id}" response_body = { token_endpoint_auth_signing_alg_values_supported: ['RS384', 'ES384'], capabilities: ['client-confidential-asymmetric', 'launch-ehr' ,'launch-standalone', 'authorize-post', 'client-public', 'client-confidential-symmetric', 'permission-offline', 'permission-online', 'permission-patient', 'permission-user', 'permission-v1', 'permission-v2', 'context-ehr-patient', 'context-ehr-encounter', 'context-standalone-patient', 'context-standalone-encounter', 'context-banner', 'context-style'], code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['private_key_jwt', 'client_secret_basic', 'client_secret_post'], issuer: base_url + FHIR_PATH, grant_types_supported: ['client_credentials', 'authorization_code'], scopes_supported: SUPPORTED_SCOPES, authorization_endpoint: base_url + AUTHORIZATION_PATH, token_endpoint: base_url + TOKEN_PATH, introspection_endpoint: base_url + INTROSPECTION_PATH }.to_json [200, { 'Content-Type' => 'application/json', 'Access-Control-Allow-Origin' => '*' }, [response_body]] end |
.token_expired?(token, check_time = nil) ⇒ Boolean
153 154 155 156 157 158 159 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 153 def token_expired?(token, check_time = nil) decoded_token = decode_token(token) return false unless decoded_token&.dig('expiration').present? check_time = Time.now.to_i unless check_time.present? decoded_token['expiration'] < check_time end |
.update_response_for_error(response, error_message) ⇒ Object
203 204 205 206 207 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 203 def update_response_for_error(response, ) response.status = 401 response.format = :json response.body = { error: 'invalid_client', error_description: }.to_json end |
.update_response_for_expired_token(response, type) ⇒ Object
161 162 163 164 165 166 167 168 |
# File 'lib/smart_app_launch/endpoints/mock_smart_server.rb', line 161 def update_response_for_expired_token(response, type) response.status = 401 response.format = :json response.body = FHIR::OperationOutcome.new( issue: FHIR::OperationOutcome::Issue.new(severity: 'fatal', code: 'expired', details: FHIR::CodeableConcept.new(text: "#{type} has expired")) ).to_json end |