From c4340a82f645d45fc8a4d6cb7cb7821bfcda7b1d Mon Sep 17 00:00:00 2001 From: Alisa Wallace <140003092+alisawallace@users.noreply.github.com> Date: Wed, 31 Jan 2024 07:51:54 -0800 Subject: [PATCH] FI-2247 backend services migration (#59) --- Gemfile.lock | 12 ++- .../inferno_reference_server_stu2_preset.json | 5 ++ .../backend_services_authorization_group.rb | 88 +++++++++++++++++++ ..._services_authorization_request_builder.rb | 74 ++++++++++++++++ ...ices_authorization_request_success_test.rb | 40 +++++++++ ...rvices_authorization_response_body_test.rb | 40 +++++++++ ..._services_invalid_client_assertion_test.rb | 44 ++++++++++ ...ackend_services_invalid_grant_type_test.rb | 44 ++++++++++ .../backend_services_invalid_jwt_test.rb | 55 ++++++++++++ .../client_assertion_builder.rb | 28 +++--- lib/smart_app_launch/smart_stu2_suite.rb | 18 ++++ .../token_exchange_stu2_test.rb | 28 +++--- smart_app_launch_test_kit.gemspec | 1 + ...ices_authorization_request_builder_spec.rb | 64 ++++++++++++++ ...authorization_request_success_test_spec.rb | 73 +++++++++++++++ ...s_authorization_response_body_test_spec.rb | 66 ++++++++++++++ ...ices_invalid_client_assertion_test_spec.rb | 75 ++++++++++++++++ ...d_services_invalid_grant_type_test_spec.rb | 75 ++++++++++++++++ .../backend_services_invalid_jwt_test_spec.rb | 73 +++++++++++++++ .../client_assertion_builder_spec.rb | 42 +++++++-- 20 files changed, 915 insertions(+), 30 deletions(-) create mode 100644 lib/smart_app_launch/backend_services_authorization_group.rb create mode 100644 lib/smart_app_launch/backend_services_authorization_request_builder.rb create mode 100644 lib/smart_app_launch/backend_services_authorization_request_success_test.rb create mode 100644 lib/smart_app_launch/backend_services_authorization_response_body_test.rb create mode 100644 lib/smart_app_launch/backend_services_invalid_client_assertion_test.rb create mode 100644 lib/smart_app_launch/backend_services_invalid_grant_type_test.rb create mode 100644 lib/smart_app_launch/backend_services_invalid_jwt_test.rb create mode 100644 spec/smart_app_launch/backend_services_authorization_request_builder_spec.rb create mode 100644 spec/smart_app_launch/backend_services_authorization_request_success_test_spec.rb create mode 100644 spec/smart_app_launch/backend_services_authorization_response_body_test_spec.rb create mode 100644 spec/smart_app_launch/backend_services_invalid_client_assertion_test_spec.rb create mode 100644 spec/smart_app_launch/backend_services_invalid_grant_type_test_spec.rb create mode 100644 spec/smart_app_launch/backend_services_invalid_jwt_test_spec.rb diff --git a/Gemfile.lock b/Gemfile.lock index 1425225..edcaa4e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,7 @@ PATH specs: smart_app_launch_test_kit (0.4.0) inferno_core (>= 0.4.2) + json-jwt (~> 1.15.3) jwt (~> 2.6) tls_test_kit (~> 0.2.0) @@ -17,9 +18,11 @@ GEM zeitwerk (~> 2.3) addressable (2.8.5) public_suffix (>= 2.0.2, < 6.0) + aes_key_wrap (1.1.0) base62-rb (0.3.1) bcp47 (0.3.3) i18n + bindata (2.4.15) blueprinter (0.25.2) byebug (11.1.3) coderay (1.1.3) @@ -141,6 +144,7 @@ GEM http-accept (1.7.0) http-cookie (1.0.5) domain_name (~> 0.5) + httpclient (2.8.3) i18n (1.14.1) concurrent-ruby (~> 1.0) ice_nine (0.11.2) @@ -172,6 +176,11 @@ GEM io-console (0.5.11) irb (1.4.2) reline (>= 0.3.0) + json-jwt (1.15.3) + activesupport (>= 4.2) + aes_key_wrap + bindata + httpclient jwt (2.7.1) kramdown (2.4.0) rexml @@ -282,6 +291,7 @@ GEM PLATFORMS arm64-darwin-21 arm64-darwin-22 + arm64-darwin-23 x86_64-darwin-20 x86_64-darwin-22 x86_64-linux @@ -296,4 +306,4 @@ DEPENDENCIES webmock (~> 3.11) BUNDLED WITH - 2.3.23 + 2.5.3 diff --git a/config/presets/inferno_reference_server_stu2_preset.json b/config/presets/inferno_reference_server_stu2_preset.json index ed81647..d318208 100644 --- a/config/presets/inferno_reference_server_stu2_preset.json +++ b/config/presets/inferno_reference_server_stu2_preset.json @@ -79,6 +79,11 @@ } ] } + }, + { + "name": "backend_services_client_id", + "type": "text", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJlZ2lzdHJhdGlvbi10b2tlbiJ9.eyJqd2tzX3VybCI6Imh0dHA6Ly8xMC4xNS4yNTIuNzMvaW5mZXJuby8ud2VsbC1rbm93bi9qd2tzLmpzb24iLCJhY2Nlc3NUb2tlbnNFeHBpcmVJbiI6MTUsImlhdCI6MTU5NzQxMzE5NX0.q4v4Msc74kN506KTZ0q_minyapJw0gwlT6M_uiL73S4" } ] } diff --git a/lib/smart_app_launch/backend_services_authorization_group.rb b/lib/smart_app_launch/backend_services_authorization_group.rb new file mode 100644 index 0000000..cf61703 --- /dev/null +++ b/lib/smart_app_launch/backend_services_authorization_group.rb @@ -0,0 +1,88 @@ +require_relative 'backend_services_authorization_request_builder' +require_relative 'backend_services_invalid_grant_type_test' +require_relative 'backend_services_invalid_client_assertion_test' +require_relative 'backend_services_invalid_jwt_test' +require_relative 'backend_services_authorization_request_success_test' +require_relative 'backend_services_authorization_response_body_test' +require_relative 'token_exchange_stu2_test' + +module SMARTAppLaunch + class BackendServicesAuthorizationGroup < Inferno::TestGroup + title 'SMART Backend Services Authorization' + short_description 'Demonstrate SMART Backend Services Authorization' + + id :backend_services_authorization + + input :smart_token_url, + title: 'Backend Services Token Endpoint', + description: <<~DESCRIPTION + The OAuth 2.0 Token Endpoint used by the Backend Services specification to provide bearer tokens. + DESCRIPTION + + input :backend_services_client_id, + title: 'Backend Services Client ID', + description: 'Client ID provided at registration to the Inferno application.' + input :backend_services_requested_scope, + title: 'Backend Services Requested Scopes', + description: 'Backend Services Scopes provided at registration to the Inferno application; will be `system/` scopes', + default: 'system/*.read' + + input :client_auth_encryption_method, + title: 'Encryption Method for Asymmetric Confidential Client Authorization', + description: <<~DESCRIPTION, + The server is required to suport either ES384 or RS384 encryption methods for JWT signature verification. + Select which method to use. + DESCRIPTION + type: 'radio', + default: 'ES384', + options: { + list_options: [ + { + label: 'ES384', + value: 'ES384' + }, + { + label: 'RS384', + value: 'RS384' + } + ] + } + input :backend_services_jwks_kid, + title: 'Backend Services JWKS kid', + description: <<~DESCRIPTION, + The key ID of the JWKS private key to use for signing the client assertion when fetching an auth token. + Defaults to the first JWK in the list if no kid is supplied. + DESCRIPTION + optional: true + + output :bearer_token + + test from: :tls_version_test do + title 'Authorization service token endpoint secured by transport layer security' + description <<~DESCRIPTION + The [SMART App Launch 2.0.0 IG specification for Backend Services](https://hl7.org/fhir/smart-app-launch/STU2/backend-services.html#request-1) + states "the client SHALL use the Transport Layer Security (TLS) Protocol Version 1.2 (RFC5246) + or a more recent version of TLS to authenticate the identity of the FHIR authorization server and to + establish an encrypted, integrity-protected link for securing all exchanges between the client and the + FHIR authorization server’s token endpoint. All exchanges described herein between the client and the + FHIR server SHALL be secured using TLS V1.2 or a more recent version of TLS." + DESCRIPTION + id :smart_backend_services_token_tls_version + + config( + inputs: { url: { name: :smart_token_url } }, + options: { minimum_allowed_version: OpenSSL::SSL::TLS1_2_VERSION } + ) + end + + test from: :smart_backend_services_invalid_grant_type + + test from: :smart_backend_services_invalid_client_assertion + + test from: :smart_backend_services_invalid_jwt + + test from: :smart_backend_services_auth_request_success + + test from: :smart_backend_services_auth_response_body + end +end diff --git a/lib/smart_app_launch/backend_services_authorization_request_builder.rb b/lib/smart_app_launch/backend_services_authorization_request_builder.rb new file mode 100644 index 0000000..8213c52 --- /dev/null +++ b/lib/smart_app_launch/backend_services_authorization_request_builder.rb @@ -0,0 +1,74 @@ +require 'json/jwt' +require_relative 'client_assertion_builder' + +module SMARTAppLaunch + class BackendServicesAuthorizationRequestBuilder + def self.build(...) + new(...).authorization_request + end + + attr_reader :encryption_method, :scope, :iss, :sub, :aud, :content_type, :grant_type, :client_assertion_type, :exp, + :jti, :kid + + def initialize( + encryption_method:, + scope:, + iss:, + sub:, + aud:, + content_type: 'application/x-www-form-urlencoded', + grant_type: 'client_credentials', + client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + exp: 5.minutes.from_now, + jti: SecureRandom.hex(32), + kid: nil + ) + @encryption_method = encryption_method + @scope = scope + @iss = iss + @sub = sub + @aud = aud + @content_type = content_type + @grant_type = grant_type + @client_assertion_type = client_assertion_type + @exp = exp + @jti = jti + @kid = kid + end + + def authorization_request_headers + { + content_type:, + accept: 'application/json' + }.compact + end + + def authorization_request_query_values + { + 'scope' => scope, + 'grant_type' => grant_type, + 'client_assertion_type' => client_assertion_type, + 'client_assertion' => client_assertion.to_s + }.compact + end + + def client_assertion + @client_assertion ||= ClientAssertionBuilder.build( + client_auth_encryption_method: encryption_method, + iss: iss, + sub: sub, + aud: aud, + exp: exp.to_i, + jti: jti, + kid: kid + ) + end + + def authorization_request + uri = Addressable::URI.new + uri.query_values = authorization_request_query_values + + { body: uri.query, headers: authorization_request_headers } + end + end +end diff --git a/lib/smart_app_launch/backend_services_authorization_request_success_test.rb b/lib/smart_app_launch/backend_services_authorization_request_success_test.rb new file mode 100644 index 0000000..b026019 --- /dev/null +++ b/lib/smart_app_launch/backend_services_authorization_request_success_test.rb @@ -0,0 +1,40 @@ +require_relative 'backend_services_authorization_request_builder' +require_relative 'backend_services_authorization_group' + +module SMARTAppLaunch + class BackendServicesAuthorizationRequestSuccessTest < Inferno::Test + id :smart_backend_services_auth_request_success + title 'Authorization request succeeds when supplied correct information' + description <<~DESCRIPTION + The [SMART App Launch 2.0.0 IG specification for Backend Services](https://hl7.org/fhir/smart-app-launch/STU2/backend-services.html#issue-access-token) + states "If the access token request is valid and authorized, the authorization server SHALL issue an access token in response." + DESCRIPTION + + input :client_auth_encryption_method, + :backend_services_requested_scope, + :backend_services_client_id, + :smart_token_url, + :backend_services_jwks_kid + + output :authentication_response + + http_client :token_endpoint do + url :smart_token_url + end + + run do + post_request_content = BackendServicesAuthorizationRequestBuilder.build(encryption_method: client_auth_encryption_method, + scope: backend_services_requested_scope, + iss: backend_services_client_id, + sub: backend_services_client_id, + aud: smart_token_url, + kid: backend_services_jwks_kid) + + authentication_response = post(**{ client: :token_endpoint }.merge(post_request_content)) + + assert_response_status([200, 201]) + + output authentication_response: authentication_response.response_body + end + end +end \ No newline at end of file diff --git a/lib/smart_app_launch/backend_services_authorization_response_body_test.rb b/lib/smart_app_launch/backend_services_authorization_response_body_test.rb new file mode 100644 index 0000000..1a8c8f7 --- /dev/null +++ b/lib/smart_app_launch/backend_services_authorization_response_body_test.rb @@ -0,0 +1,40 @@ +require_relative 'backend_services_authorization_request_builder' + +module SMARTAppLaunch + class BackendServicesAuthorizationResponseBodyTest < Inferno::Test + id :smart_backend_services_auth_response_body + title 'Authorization request response body contains required information encoded in JSON' + description <<~DESCRIPTION + The [SMART App Launch 2.0.0 IG specification for Backend Services](https://hl7.org/fhir/smart-app-launch/STU2/backend-services.html#issue-access-token) + states The access token response SHALL be a JSON object with the following properties: + + | Token Property | Required? | Description | + | --- | --- | --- | + | `access_token` | required | The access token issued by the authorization server. | + | `token_type` | required | Fixed value: `bearer`. | + | `expires_in` | required | The lifetime in seconds of the access token. The recommended value is `300`, for a five-minute token lifetime. | + | `scope` | required | Scope of access authorized. Note that this can be different from the scopes requested by the app. | + DESCRIPTION + + input :authentication_response + output :bearer_token + + run do + skip_if authentication_response.blank?, 'No authentication response received.' + + assert_valid_json(authentication_response) + response_body = JSON.parse(authentication_response) + + access_token = response_body['access_token'] + assert access_token.present?, 'Token response did not contain access_token as required' + + output bearer_token: access_token + + required_keys = ['token_type', 'expires_in', 'scope'] + + required_keys.each do |key| + assert response_body[key].present?, "Token response did not contain #{key} as required" + end + end + end +end \ No newline at end of file diff --git a/lib/smart_app_launch/backend_services_invalid_client_assertion_test.rb b/lib/smart_app_launch/backend_services_invalid_client_assertion_test.rb new file mode 100644 index 0000000..48156b5 --- /dev/null +++ b/lib/smart_app_launch/backend_services_invalid_client_assertion_test.rb @@ -0,0 +1,44 @@ +require_relative 'backend_services_authorization_request_builder' + +module SMARTAppLaunch + class BackendServicesInvalidClientAssertionTest < Inferno::Test + id :smart_backend_services_invalid_client_assertion + title 'Authorization request fails when supplied invalid client_assertion_type' + description <<~DESCRIPTION + The [SMART App Launch 2.0.0 IG specification for Backend Services](https://hl7.org/fhir/smart-app-launch/STU2/backend-services.html#request-1) + defines the required fields for the authorization request, made via HTTP POST to authorization + token endpoint. + This includes the `client_assertion_type` parameter, where the value must be `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`. + + The [OAuth 2.0 Authorization Framework Section 4.3.3](https://datatracker.ietf.org/doc/html/rfc6749#section-4.3.3) + describes the proper response for an invalid request in the client credentials grant flow: + + "If the request failed client authentication or is invalid, the authorization server returns an + error response as described in [Section 5.2](https://tools.ietf.org/html/rfc6749#section-5.2)." + DESCRIPTION + + input :client_auth_encryption_method, + :backend_services_requested_scope, + :backend_services_client_id, + :smart_token_url, + :backend_services_jwks_kid + + http_client :token_endpoint do + url :smart_token_url + end + + run do + post_request_content = BackendServicesAuthorizationRequestBuilder.build(encryption_method: client_auth_encryption_method, + scope: backend_services_requested_scope, + iss: backend_services_client_id, + sub: backend_services_client_id, + aud: smart_token_url, + client_assertion_type: 'not_an_assertion_type', + kid: backend_services_jwks_kid) + + post(**{ client: :token_endpoint }.merge(post_request_content)) + + assert_response_status(400) + end + end +end \ No newline at end of file diff --git a/lib/smart_app_launch/backend_services_invalid_grant_type_test.rb b/lib/smart_app_launch/backend_services_invalid_grant_type_test.rb new file mode 100644 index 0000000..64de73b --- /dev/null +++ b/lib/smart_app_launch/backend_services_invalid_grant_type_test.rb @@ -0,0 +1,44 @@ +require_relative 'backend_services_authorization_request_builder' + +module SMARTAppLaunch + class BackendServicesInvalidGrantTypeTest < Inferno::Test + id :smart_backend_services_invalid_grant_type + title 'Authorization request fails when client supplies invalid grant_type' + description <<~DESCRIPTION + The [SMART App Launch 2.0.0 IG section on Backend Services](https://hl7.org/fhir/smart-app-launch/STU2/backend-services.html#request-1) + defines the required fields for the authorization request, made via HTTP POST to authorization + token endpoint. + This includes the `grant_type` parameter, where the value must be `client_credentials`. + + The [OAuth 2.0 Authorization Framework Section 4.3.3](https://datatracker.ietf.org/doc/html/rfc6749#section-4.3.3) + describes the proper response for an invalid request in the client credentials grant flow: + + "If the request failed client authentication or is invalid, the authorization server returns an + error response as described in [Section 5.2](https://tools.ietf.org/html/rfc6749#section-5.2)." + DESCRIPTION + + input :client_auth_encryption_method, + :backend_services_requested_scope, + :backend_services_client_id, + :smart_token_url, + :backend_services_jwks_kid + + http_client :token_endpoint do + url :smart_token_url + end + + run do + post_request_content = BackendServicesAuthorizationRequestBuilder.build(encryption_method: client_auth_encryption_method, + scope: backend_services_requested_scope, + iss: backend_services_client_id, + sub: backend_services_client_id, + aud: smart_token_url, + grant_type: 'not_a_grant_type', + kid: backend_services_jwks_kid) + + post(**{ client: :token_endpoint }.merge(post_request_content)) + + assert_response_status(400) + end + end +end \ No newline at end of file diff --git a/lib/smart_app_launch/backend_services_invalid_jwt_test.rb b/lib/smart_app_launch/backend_services_invalid_jwt_test.rb new file mode 100644 index 0000000..1a88fa6 --- /dev/null +++ b/lib/smart_app_launch/backend_services_invalid_jwt_test.rb @@ -0,0 +1,55 @@ +require_relative 'backend_services_authorization_request_builder' + +module SMARTAppLaunch + class BackendServicesInvalidJWTTest < Inferno::Test + id :smart_backend_services_invalid_jwt + title 'Authorization request fails when client supplies invalid JWT token' + description <<~DESCRIPTION + The [SMART App Launch 2.0.0 IG section on Backend Services](https://hl7.org/fhir/smart-app-launch/STU2/backend-services.html#request-1) + defines the required fields for the authorization request, made via HTTP POST to authorization + token endpoint. + This includes the `client_assertion` parameter, where the value must be + a valid JWT as specified in + [Asymmetric (public key) Client Authentication](https://hl7.org/fhir/smart-app-launch/STU2/client-confidential-asymmetric.html#authenticating-to-the-token-endpoint) + The JWT SHALL include the following claims, and SHALL be signed with the client’s private key. + + | JWT Claim | Required? | Description | + | --- | --- | --- | + | `iss` | required | Issuer of the JWT -- the client's `client_id`, as determined during registration with the FHIR authorization server (note that this is the same as the value for the sub claim) | + | `sub` | required | The service's `client_id`, as determined during registration with the FHIR authorization server (note that this is the same as the value for the `iss` claim) | + | `aud` | required | The FHIR authorization server's "token URL" (the same URL to which this authentication JWT will be posted) | + | `exp` | required | Expiration time integer for this authentication JWT, expressed in seconds since the "Epoch" (1970-01-01T00:00:00Z UTC). This time SHALL be no more than five minutes in the future. | + | `jti` | required | A nonce string value that uniquely identifies this authentication JWT. | + + The [OAuth 2.0 Authorization Framework Section 4.3.3](https://datatracker.ietf.org/doc/html/rfc6749#section-4.3.3) + describes the proper response for an invalid request in the client credentials grant flow: + + "If the request failed client authentication or is invalid, the authorization server returns an + error response as described in [Section 5.2](https://tools.ietf.org/html/rfc6749#section-5.2)." + DESCRIPTION + + input :client_auth_encryption_method, + :backend_services_requested_scope, + :backend_services_client_id, + :smart_token_url, + :backend_services_jwks_kid + + http_client :token_endpoint do + url :smart_token_url + end + + run do + post_request_content = BackendServicesAuthorizationRequestBuilder.build(encryption_method: client_auth_encryption_method, + scope: backend_services_requested_scope, + iss: backend_services_client_id, + sub: backend_services_client_id, + aud: smart_token_url, + client_assertion_type: 'not_an_assertion_type', + kid: backend_services_jwks_kid) + + post(**{ client: :token_endpoint }.merge(post_request_content)) + + assert_response_status(400) + end + end +end \ No newline at end of file diff --git a/lib/smart_app_launch/client_assertion_builder.rb b/lib/smart_app_launch/client_assertion_builder.rb index 78ad957..427df3e 100644 --- a/lib/smart_app_launch/client_assertion_builder.rb +++ b/lib/smart_app_launch/client_assertion_builder.rb @@ -16,7 +16,8 @@ def self.build(...) :grant_type, :iss, :jti, - :sub + :sub, + :kid def initialize( client_auth_encryption_method:, @@ -24,7 +25,8 @@ def initialize( sub:, aud:, exp: 5.minutes.from_now.to_i, - jti: SecureRandom.hex(32) + jti: SecureRandom.hex(32), + kid: nil ) @client_auth_encryption_method = client_auth_encryption_method @iss = iss @@ -35,29 +37,35 @@ def initialize( @client_assertion_type = client_assertion_type @exp = exp @jti = jti + @kid = kid end def private_key - @private_key ||= - JWKS.jwks - .find { |key| key[:key_ops]&.include?('sign') && key[:alg] == client_auth_encryption_method } + @private_key ||= JWKS.jwks + .select { |key| key[:key_ops]&.include?('sign') } + .select { |key| key[:alg] == client_auth_encryption_method } + .find { |key| !kid || key[:kid] == kid } end def jwt_payload { iss:, sub:, aud:, exp:, jti: }.compact end - def kid - private_key.kid + def signing_key + private_key() + if @private_key.nil? + raise Inferno::Exceptions::AssertionException, "No signing key found for inputs: encryption method = '#{client_auth_encryption_method}' and kid = '#{kid}'" + end + return @private_key.signing_key end - def signing_key - private_key.signing_key + def key_id + @private_key['kid'] end def client_assertion @client_assertion ||= - JWT.encode jwt_payload, signing_key, client_auth_encryption_method, { alg: client_auth_encryption_method, kid:, typ: 'JWT' } + JWT.encode jwt_payload, signing_key, client_auth_encryption_method, { alg: client_auth_encryption_method, kid: key_id, typ: 'JWT' } end end end diff --git a/lib/smart_app_launch/smart_stu2_suite.rb b/lib/smart_app_launch/smart_stu2_suite.rb index 679547e..ae801f3 100644 --- a/lib/smart_app_launch/smart_stu2_suite.rb +++ b/lib/smart_app_launch/smart_stu2_suite.rb @@ -8,6 +8,7 @@ require_relative 'openid_connect_group' require_relative 'token_introspection_group' require_relative 'token_refresh_group' +require_relative 'backend_services_authorization_group' module SMARTAppLaunch class SMARTSTU2Suite < Inferno::TestSuite @@ -220,6 +221,23 @@ class SMARTSTU2Suite < Inferno::TestSuite } end + group do + title 'Backend Services' + id :smart_backend_services + + input_instructions <<~INSTRUCTIONS + Please register the Inferno client with the authorization services with the + following JWK Set URL: + + * `#{Inferno::Application[:base_url]}/custom/smart_stu2/.well-known/jwks.json` + INSTRUCTIONS + + run_as_group + + group from: :smart_discovery_stu2 + group from: :backend_services_authorization + end + group from: :smart_token_introspection end diff --git a/lib/smart_app_launch/token_exchange_stu2_test.rb b/lib/smart_app_launch/token_exchange_stu2_test.rb index 1e4e214..776f238 100644 --- a/lib/smart_app_launch/token_exchange_stu2_test.rb +++ b/lib/smart_app_launch/token_exchange_stu2_test.rb @@ -14,21 +14,21 @@ class TokenExchangeSTU2Test < TokenExchangeTest id :smart_token_exchange_stu2 input :client_auth_encryption_method, - title: 'Encryption Method (Confidential Asymmetric Client Auth Only)', - type: 'radio', - default: 'ES384', - options: { - list_options: [ - { - label: 'ES384', - value: 'ES384' - }, - { - label: 'RS384', - value: 'RS384' - } - ] + title: 'Encryption Method (Confidential Asymmetric Client Auth Only)', + type: 'radio', + default: 'ES384', + options: { + list_options: [ + { + label: 'ES384', + value: 'ES384' + }, + { + label: 'RS384', + value: 'RS384' } + ] + } input :client_auth_type, title: 'Client Authentication Method', diff --git a/smart_app_launch_test_kit.gemspec b/smart_app_launch_test_kit.gemspec index 0ffddc7..94b290d 100644 --- a/smart_app_launch_test_kit.gemspec +++ b/smart_app_launch_test_kit.gemspec @@ -10,6 +10,7 @@ Gem::Specification.new do |spec| spec.homepage = 'https://github.com/inferno_framework/smart-app-launch-test-kit' spec.license = 'Apache-2.0' spec.add_runtime_dependency 'inferno_core', '>= 0.4.2' + spec.add_runtime_dependency 'json-jwt', '~> 1.15.3' spec.add_runtime_dependency 'jwt', '~> 2.6' spec.add_runtime_dependency 'tls_test_kit', '~> 0.2.0' spec.add_development_dependency 'database_cleaner-sequel', '~> 1.8' diff --git a/spec/smart_app_launch/backend_services_authorization_request_builder_spec.rb b/spec/smart_app_launch/backend_services_authorization_request_builder_spec.rb new file mode 100644 index 0000000..f334e8e --- /dev/null +++ b/spec/smart_app_launch/backend_services_authorization_request_builder_spec.rb @@ -0,0 +1,64 @@ +require_relative '../../lib/smart_app_launch/backend_services_authorization_request_builder' + +RSpec.describe SMARTAppLaunch::BackendServicesAuthorizationRequestBuilder do + let(:encryption_method) { 'RS384' } + let(:scope) { 'system/*' } + let(:iss) { 'ISS'} + let(:sub) { 'SUB' } + let(:aud) { 'AUD' } + let(:kid) { nil } + + describe '.build' do + context 'when using default argument values' do + it 'creates a valid request query' do + request_query = described_class.build(encryption_method: encryption_method, + scope: scope, + iss: iss, + sub: sub, + aud: aud + ) + + request_body = Rack::Utils.parse_nested_query(request_query[:body]) + request_headers = request_query[:headers] + + expect(request_body['client_assertion']).to be_present + expect(request_body['client_assertion_type']).to eq('urn:ietf:params:oauth:client-assertion-type:jwt-bearer') + expect(request_body['grant_type']).to eq('client_credentials') + expect(request_body['scope']).to eq(scope) + + expect(request_headers[:content_type]).to eq('application/x-www-form-urlencoded') + expect(request_headers[:accept]).to eq('application/json') + end + end + + context 'when specifying non-default argument values' do + it 'creates request query that uses provided values' do + + specific_content_type = 'specifc_content_type' + specific_grant_type = 'specific_grant_type' + specific_client_assertion_type = 'specific_client_assertion_type' + + request_query = described_class.build(encryption_method: encryption_method, + scope: scope, + iss: iss, + sub: sub, + aud: aud, + content_type: specific_content_type, + grant_type: specific_grant_type, + client_assertion_type: specific_client_assertion_type + ) + + request_body = Rack::Utils.parse_nested_query(request_query[:body]) + request_headers = request_query[:headers] + + expect(request_body['client_assertion']).to be_present + expect(request_body['client_assertion_type']).to eq(specific_client_assertion_type) + expect(request_body['grant_type']).to eq(specific_grant_type) + expect(request_body['scope']).to eq(scope) + + expect(request_headers[:content_type]).to eq(specific_content_type) + expect(request_headers[:accept]).to eq('application/json') + end + end + end +end \ No newline at end of file diff --git a/spec/smart_app_launch/backend_services_authorization_request_success_test_spec.rb b/spec/smart_app_launch/backend_services_authorization_request_success_test_spec.rb new file mode 100644 index 0000000..acfffd5 --- /dev/null +++ b/spec/smart_app_launch/backend_services_authorization_request_success_test_spec.rb @@ -0,0 +1,73 @@ +require_relative '../../lib/smart_app_launch/backend_services_authorization_request_success_test' +require_relative '../../lib/smart_app_launch/backend_services_authorization_request_builder' + +RSpec.describe SMARTAppLaunch::BackendServicesAuthorizationRequestSuccessTest do + + let(:test) { Inferno::Repositories::Tests.new.find('smart_backend_services_auth_request_success') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'smart_stu2') } + let(:smart_token_url) { 'http://example.com/fhir/token' } + let(:client_auth_encryption_method) { 'ES384' } + let(:backend_services_requested_scope) { 'system/Patient.read' } + let(:backend_services_client_id) { 'clientID' } + let(:backend_services_jwks_kid) { nil } + let(:exp) { 5.minutes.from_now } + let(:jti) { SecureRandom.hex(32) } + let(:request_builder) { BackendServicesAuthorizationRequestBuilder.new(builder_input) } + let(:client_assertion) { create_client_assertion(client_assertion_input) } + let(:body) { request_builder.authorization_request_query_values } + let(:input) do + { + smart_token_url:, + client_auth_encryption_method:, + backend_services_requested_scope:, + backend_services_client_id:, + backend_services_jwks_kid: + } + end + let(:builder_input) do + { + encryption_method: client_auth_encryption_method, + scope: backend_services_requested_scope, + iss: backend_services_client_id, + sub: backend_services_client_id, + aud: smart_token_url, + exp:, + jti:, + kid: + } + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name: name, + value: value, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + it 'fails if the access token request is rejected' do + stub_request(:post, smart_token_url) + .to_return(status: 400) + + result = run(test, input) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Unexpected response status: expected 200, 201, but received 400') + end + + it 'passes if the access token request is valid and authorized' do + stub_request(:post, smart_token_url) + .to_return(status: 200) + + result = run(test, input) + + expect(result.result).to eq('pass') + end +end \ No newline at end of file diff --git a/spec/smart_app_launch/backend_services_authorization_response_body_test_spec.rb b/spec/smart_app_launch/backend_services_authorization_response_body_test_spec.rb new file mode 100644 index 0000000..4cfa146 --- /dev/null +++ b/spec/smart_app_launch/backend_services_authorization_response_body_test_spec.rb @@ -0,0 +1,66 @@ +require_relative '../../lib/smart_app_launch/backend_services_authorization_response_body_test' + +RSpec.describe SMARTAppLaunch::BackendServicesAuthorizationResponseBodyTest do + + let(:test) { Inferno::Repositories::Tests.new.find('smart_backend_services_auth_response_body') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'smart_stu2') } + + let(:response_body) do + { + 'access_token' => 'this_is_the_token', + 'token_type' => 'its_a_token', + 'expires_in' => 'a_couple_minutes', + 'scope' => 'system' + } + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name: name, + value: value, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + it 'skips when no authentication response received' do + result = run(test) + + expect(result.result).to eq('skip') + expect(result.result_message).to eq('No authentication response received.') + end + + it 'fails when authentication response is invalid JSON' do + result = run(test, { authentication_response: '{/}' }) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Invalid JSON. ') + end + + it 'fails when authentication response does not contain access_token' do + result = run(test, { authentication_response: '{"response_body":"post"}' }) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Token response did not contain access_token as required') + end + + it 'fails when access_token is present but does not contain required keys' do + missing_key_auth_response = { 'access_token' => 'its_the_token' } + result = run(test, { authentication_response: missing_key_auth_response.to_json }) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Token response did not contain token_type as required') + end + + it 'passes when access_token is present and contains the required keys' do + result = run(test, { authentication_response: response_body.to_json }) + + expect(result.result).to eq('pass') + end +end \ No newline at end of file diff --git a/spec/smart_app_launch/backend_services_invalid_client_assertion_test_spec.rb b/spec/smart_app_launch/backend_services_invalid_client_assertion_test_spec.rb new file mode 100644 index 0000000..67880f4 --- /dev/null +++ b/spec/smart_app_launch/backend_services_invalid_client_assertion_test_spec.rb @@ -0,0 +1,75 @@ +require_relative '../../lib/smart_app_launch/backend_services_invalid_client_assertion_test' +require_relative '../../lib/smart_app_launch/backend_services_authorization_request_builder' + +RSpec.describe SMARTAppLaunch::BackendServicesInvalidClientAssertionTest do + + let(:test) { Inferno::Repositories::Tests.new.find('smart_backend_services_invalid_client_assertion') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'smart_stu2') } + let(:smart_token_url) { 'http://example.com/fhir/token' } + let(:client_auth_encryption_method) { 'ES384' } + let(:backend_services_requested_scope) { 'system/Patient.read' } + let(:backend_services_client_id) { 'clientID' } + let(:backend_services_jwks_kid) { nil } + let(:exp) { 5.minutes.from_now } + let(:jti) { SecureRandom.hex(32) } + let(:request_builder) { BackendServicesAuthorizationRequestBuilder.new(builder_input) } + let(:client_assertion) { create_client_assertion(client_assertion_input) } + let(:body) { request_builder.authorization_request_query_values } + let(:input) do + { + smart_token_url:, + client_auth_encryption_method:, + backend_services_requested_scope:, + backend_services_client_id:, + backend_services_jwks_kid: + } + end + let(:builder_input) do + { + encryption_method: client_auth_encryption_method, + scope: backend_services_requested_scope, + iss: backend_services_client_id, + sub: backend_services_client_id, + aud: smart_token_url, + exp:, + jti:, + kid: + } + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name: name, + value: value, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + it 'fails when token endpoint allows invalid client_assertion_type' do + stub_request(:post, smart_token_url) + .with(body: hash_including(client_assertion_type: 'not_an_assertion_type')) + .to_return(status: 200) + + result = run(test, input) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Unexpected response status: expected 400, but received 200') + end + + it 'passes when token endpoint requires valid client_assertion_type' do + stub_request(:post, smart_token_url) + .with(body: hash_including(client_assertion_type: 'not_an_assertion_type')) + .to_return(status: 400) + + result = run(test, input) + + expect(result.result).to eq('pass') + end +end \ No newline at end of file diff --git a/spec/smart_app_launch/backend_services_invalid_grant_type_test_spec.rb b/spec/smart_app_launch/backend_services_invalid_grant_type_test_spec.rb new file mode 100644 index 0000000..8115719 --- /dev/null +++ b/spec/smart_app_launch/backend_services_invalid_grant_type_test_spec.rb @@ -0,0 +1,75 @@ +require_relative '../../lib/smart_app_launch/backend_services_invalid_grant_type_test' +require_relative '../../lib/smart_app_launch/backend_services_authorization_request_builder' + +RSpec.describe SMARTAppLaunch::BackendServicesInvalidGrantTypeTest do + + let(:test) { Inferno::Repositories::Tests.new.find('smart_backend_services_invalid_grant_type') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'smart_stu2') } + let(:smart_token_url) { 'http://example.com/fhir/token' } + let(:client_auth_encryption_method) { 'ES384' } + let(:backend_services_requested_scope) { 'system/Patient.read' } + let(:backend_services_client_id) { 'clientID' } + let(:backend_services_jwks_kid) { nil } + let(:exp) { 5.minutes.from_now } + let(:jti) { SecureRandom.hex(32) } + let(:request_builder) { BackendServicesAuthorizationRequestBuilder.new(builder_input) } + let(:client_assertion) { create_client_assertion(client_assertion_input) } + let(:body) { request_builder.authorization_request_query_values } + let(:input) do + { + smart_token_url:, + client_auth_encryption_method:, + backend_services_requested_scope:, + backend_services_client_id:, + backend_services_jwks_kid: + } + end + let(:builder_input) do + { + encryption_method: client_auth_encryption_method, + scope: backend_services_requested_scope, + iss: backend_services_client_id, + sub: backend_services_client_id, + aud: smart_token_url, + exp:, + jti:, + kid: + } + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name: name, + value: value, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + it 'fails when token endpoint allows invalid grant_type' do + stub_request(:post, smart_token_url) + .with(body: hash_including(grant_type: 'not_a_grant_type')) + .to_return(status: 200) + + result = run(test, input) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Unexpected response status: expected 400, but received 200') + end + + it 'passes when token endpoint requires valid grant_type' do + stub_request(:post, smart_token_url) + .with(body: hash_including(grant_type: 'not_a_grant_type')) + .to_return(status: 400) + + result = run(test, input) + + expect(result.result).to eq('pass') + end +end \ No newline at end of file diff --git a/spec/smart_app_launch/backend_services_invalid_jwt_test_spec.rb b/spec/smart_app_launch/backend_services_invalid_jwt_test_spec.rb new file mode 100644 index 0000000..3950092 --- /dev/null +++ b/spec/smart_app_launch/backend_services_invalid_jwt_test_spec.rb @@ -0,0 +1,73 @@ +require_relative '../../lib/smart_app_launch/backend_services_invalid_jwt_test' +require_relative '../../lib/smart_app_launch/backend_services_authorization_request_builder' + +RSpec.describe SMARTAppLaunch::BackendServicesInvalidJWTTest do + + let(:test) { Inferno::Repositories::Tests.new.find('smart_backend_services_invalid_jwt') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'smart_stu2') } + let(:smart_token_url) { 'http://example.com/fhir/token' } + let(:client_auth_encryption_method) { 'ES384' } + let(:backend_services_requested_scope) { 'system/Patient.read' } + let(:backend_services_client_id) { 'clientID' } + let(:backend_services_jwks_kid) { nil } + let(:exp) { 5.minutes.from_now } + let(:jti) { SecureRandom.hex(32) } + let(:request_builder) { BackendServicesAuthorizationRequestBuilder.new(builder_input) } + let(:client_assertion) { create_client_assertion(client_assertion_input) } + let(:body) { request_builder.authorization_request_query_values } + let(:input) do + { + smart_token_url:, + client_auth_encryption_method:, + backend_services_requested_scope:, + backend_services_client_id:, + backend_services_jwks_kid: + } + end + let(:builder_input) do + { + encryption_method: client_auth_encryption_method, + scope: backend_services_requested_scope, + iss: backend_services_client_id, + sub: backend_services_client_id, + aud: smart_token_url, + exp:, + jti:, + kid: + } + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name: name, + value: value, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + it 'fails when token endpoint allows invalid JWT token' do + stub_request(:post, smart_token_url) + .to_return(status: 200) + + result = run(test, input) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Unexpected response status: expected 400, but received 200') + end + + it 'passes when token endpoint requires valid JWT token' do + stub_request(:post, smart_token_url) + .to_return(status: 400) + + result = run(test, input) + + expect(result.result).to eq('pass') + end +end \ No newline at end of file diff --git a/spec/smart_app_launch/client_assertion_builder_spec.rb b/spec/smart_app_launch/client_assertion_builder_spec.rb index e365df2..2f30035 100644 --- a/spec/smart_app_launch/client_assertion_builder_spec.rb +++ b/spec/smart_app_launch/client_assertion_builder_spec.rb @@ -1,4 +1,5 @@ require_relative '../../lib/smart_app_launch/client_assertion_builder' +require_relative '../../lib/smart_app_launch/jwks' RSpec.describe SMARTAppLaunch::ClientAssertionBuilder do let(:client_auth_encryption_methods) { ['ES384', 'RS384'] } @@ -7,21 +8,52 @@ let(:aud) { 'AUD' } let(:jwks) { SMARTAppLaunch::JWKS.jwks } + def build_and_decode_jwt(encryption_method, kid) + jwt = described_class.build(client_auth_encryption_method: encryption_method, iss:, sub:, aud:, kid: kid) + return JWT.decode(jwt, kid, true, algorithms: [encryption_method], jwks:) + end + describe '.build' do - it 'creates a valid JWT' do - client_auth_encryption_methods.each do |client_auth_encryption_method| - jwt = described_class.build(client_auth_encryption_method:, iss:, sub:, aud:) + context 'with unspecified key id' do + it 'creates a valid JWT' do + client_auth_encryption_methods.each do |client_auth_encryption_method| + payload, header = build_and_decode_jwt(client_auth_encryption_method, nil) + + expect(header['alg']).to eq(client_auth_encryption_method) + expect(header['typ']).to eq('JWT') + expect(payload['iss']).to eq(iss) + expect(payload['sub']).to eq(sub) + expect(payload['aud']).to eq(aud) + expect(payload['exp']).to be_present + expect(payload['jti']).to be_present + end + end + end - payload, header = JWT.decode(jwt, nil, true, algorithms: [client_auth_encryption_method], jwks:) + context 'with specified key id' do + it 'creates a valid JWT with correct algorithm and kid' do + encryption_method = 'ES384' + kid = '4b49a739d1eb115b3225f4cf9beb6d1b' + payload, header = build_and_decode_jwt(encryption_method, kid) - expect(header['alg']).to eq(client_auth_encryption_method) + expect(header['alg']).to eq(encryption_method) expect(header['typ']).to eq('JWT') + expect(header['kid']).to eq(kid) expect(payload['iss']).to eq(iss) expect(payload['sub']).to eq(sub) expect(payload['aud']).to eq(aud) expect(payload['exp']).to be_present expect(payload['jti']).to be_present end + + it 'throws exception when key id not found for algorithm' do + encryption_method = 'RS384' + kid = '4b49a739d1eb115b3225f4cf9beb6d1b' + + expect { + build_and_decode_jwt(encryption_method, kid) + }.to raise_error(Inferno::Exceptions::AssertionException) + end end end end