Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FI-2247 backend services migration #59

Merged
merged 41 commits into from
Jan 31, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
0cc043b
First pass at backend services refactor, page loads but tests fail
alisawallace Dec 11, 2023
fcc900a
Included missing JSON file, all tests pass with ref server
alisawallace Dec 11, 2023
b35b418
Changed input name to use existing SMART token endpoint
alisawallace Dec 11, 2023
6293689
Moved Backend Services tests before Token Introspection tests
alisawallace Dec 19, 2023
5c002fd
WIP refactor
alisawallace Dec 27, 2023
261b182
Moved backend services tests to individual files
alisawallace Dec 29, 2023
39429fe
Renamed inputs to reflect general backend services instead of bulk data
alisawallace Dec 29, 2023
e3d251b
Updated test descriptions and links
alisawallace Dec 29, 2023
25d947a
Updated presets for backend services.
alisawallace Dec 29, 2023
927e21e
Added SMART on FHIR Discovery to Backend Services group
alisawallace Jan 2, 2024
b8c1941
Removed smart_token_url as a preset
alisawallace Jan 2, 2024
0525bc0
Added SMART to group title
alisawallace Jan 2, 2024
24af3f5
Revert "Added SMART to group title"
alisawallace Jan 2, 2024
1df356e
Made common input for asymmetric confidential client encryption method
alisawallace Jan 2, 2024
7482711
Added SMART toBackend Services group name
alisawallace Jan 3, 2024
bf35427
Added ClientAssertionBuilder into AuthorizationRequestBuilder to elim…
alisawallace Jan 4, 2024
bc931bc
Updated input used in token introspection test with new name
alisawallace Jan 4, 2024
4cec6be
Added kid as parameter for ClientAssertionBuilder and improved error …
alisawallace Jan 4, 2024
1b87257
Fixed exception so error is properly propogated to calling object
alisawallace Jan 4, 2024
b725194
Renamed AuthorizationRequestBuilder to include backend services
alisawallace Jan 4, 2024
00e4459
Fixed bug with kid in ClientAssertionBuilder
alisawallace Jan 12, 2024
ecf8270
WIP refactored ClientAssertionBuilder spec tests
alisawallace Jan 12, 2024
73f5a4b
Exception test for ClientAssertionBuilder
alisawallace Jan 12, 2024
d747c71
Fixing input name references for token exchange stu2 test
alisawallace Jan 12, 2024
f25c9e0
Stub for backend auth request builder spec
alisawallace Jan 12, 2024
afb00c0
Copy/paste of bulk data auth tests from g10
alisawallace Jan 12, 2024
a18707b
Updated c/p spec to work for backend services group
alisawallace Jan 12, 2024
670c5c9
Updated value of expected error message, all tests pass
alisawallace Jan 12, 2024
754a8f6
Spec test for BackendServicesAuthorizationRequestBuilder
alisawallace Jan 13, 2024
f6d9d1a
Correcting references to SMART App Launch IG in descriptions
alisawallace Jan 22, 2024
e94a0bc
Improved suite and group names for backend services
alisawallace Jan 22, 2024
5c76829
Fixed rspec test references
alisawallace Jan 22, 2024
871a10e
File names andclass names aligned
alisawallace Jan 22, 2024
a807acb
Reverted authentication method input name back to original
alisawallace Jan 22, 2024
f0c762e
Fixed bug in which exp and jti values not included in client assertion
alisawallace Jan 23, 2024
d6252f2
WIP spec refactor, individual test won't pass
alisawallace Jan 25, 2024
5632d71
Improved client assertion exception for bad inputs
alisawallace Jan 27, 2024
633c774
Individual backend services spec test working
alisawallace Jan 30, 2024
3913d4b
All backend services specs refactored to individual tests/files
alisawallace Jan 30, 2024
04c7092
Cleared up confusion from duplicated input
alisawallace Jan 30, 2024
e517050
Removed pry references
alisawallace Jan 30, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions lib/smart_app_launch/authorization_request_builder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
require 'json/jwt'

module SMARTAppLaunch
class AuthorizationRequestBuilder
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would hope that this and ClientAssertionBuilder could be combined, as backend services authorization is based on the asymmetric client credentials authorization method. If we need a specific class to handle this for backend services, the class name should reflect that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ClientAssertionBuilder and AuthorizationRequestBuilder address different scopes and use cases for the tests that use them. AuthorizationRequestBuilder, used for Backend Services, is too broad for use in standalone/EHR tests, and ClientAssertionBuilder is too narrow for Backend Services (tests would have duplicated code across them that AuthRequestBuilder addresses).

So I opted to keep both classes but had AuthRequestBuilder use ClientAssertionBuilder in building the requests, so there isn't redundant behavior between the two classes. I also renamed AuthRequestBuilder to be specific to Backend Services, since those are the only tests that use it.

def self.build(...)
new(...).authorization_request
end

def self.bulk_data_jwks
@bulk_data_jwks ||= JSON.parse(File.read(ENV.fetch('G10_BULK_DATA_JWKS',
File.join(__dir__, 'bulk_data_jwks.json'))))
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 bulk_private_key
@bulk_private_key ||=
self.class.bulk_data_jwks['keys']
.select { |key| key['key_ops']&.include?('sign') }
.select { |key| key['alg'] == encryption_method }
.find { |key| !kid || key['kid'] == kid }
end

def jwt_token
@jwt_token ||= JSON::JWT.new(iss:, sub:, aud:, exp:, jti:).compact
end

def jwk
@jwk ||= JSON::JWK.new(bulk_private_key)
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 ||=
begin
jwt_token.kid = jwk['kid']
jwk_private_key = jwk.to_key
jwt_token.sign(jwk_private_key, bulk_private_key['alg'])
end
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
237 changes: 237 additions & 0 deletions lib/smart_app_launch/backend_services_group.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
require_relative 'authorization_request_builder'

module SMARTAppLaunch
class SMARTBackendServices < Inferno::TestGroup
title 'Backend Services'
short_description 'Demonstrate SMART Backend Services Authorization'

id :smart_backend_services

input :bulk_token_endpoint,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The input ids should be updated to reflect that these are backend services inputs not bulk data inputs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All inputs were updated.

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 :bulk_client_id,
title: 'Bulk Data Client ID',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These titles should be updated as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All titles/inputs/descriptions have been updated so they no longer refer to bulk data.

description: 'Client ID provided at registration to the Inferno application.'
input :bulk_scope,
title: 'Bulk Data Scopes',
description: 'Bulk Data Scopes provided at registration to the Inferno application.',
default: 'system/*.read'
input :bulk_encryption_method,
title: 'Encryption Method',
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 :bulk_jwks_kid,
title: 'Bulk Data 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

http_client :token_endpoint do
url :bulk_token_endpoint
end

test from: :tls_version_test do
title 'Authorization service token endpoint secured by transport layer security'
description <<~DESCRIPTION
[§170.315(g)(10) Test
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't contain anything g10 specific. Is this a backend services requirement or just a g10 requirement?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a backend services requirement too, so I left it, but updated all naming so there isn't anything g10 specific.

Procedure](https://www.healthit.gov/test-method/standardized-api-patient-and-population-services)
requires that all exchanges described herein between a client and a
server SHALL be secured using Transport Layer Security (TLS) Protocol
Version 1.2 (RFC5246).
DESCRIPTION
id :g10_bulk_token_tls_version

config(
inputs: { url: { name: :bulk_token_endpoint } },
options: { minimum_allowed_version: OpenSSL::SSL::TLS1_2_VERSION }
)
end

test do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests should all be moved into their own files and given good ids.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each test was moved into its own file and given a descriptive ID.

title 'Authorization request fails when client supplies invalid grant_type'
description <<~DESCRIPTION
The Backend Service Authorization specification 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 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
# link 'http://hl7.org/fhir/uv/bulkdata/STU1.0.1/authorization/index.html#protocol-details'

run do
post_request_content = AuthorizationRequestBuilder.build(encryption_method: bulk_encryption_method,
scope: bulk_scope,
iss: bulk_client_id,
sub: bulk_client_id,
aud: bulk_token_endpoint,
grant_type: 'not_a_grant_type',
kid: bulk_jwks_kid)

post(**{ client: :token_endpoint }.merge(post_request_content))

assert_response_status(400)
end
end

test do
title 'Authorization request fails when supplied invalid client_assertion_type'
description <<~DESCRIPTION
The Backend Service Authorization specification 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 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
# link 'http://hl7.org/fhir/uv/bulkdata/STU1.0.1/authorization/index.html#protocol-details'

run do
post_request_content = AuthorizationRequestBuilder.build(encryption_method: bulk_encryption_method,
scope: bulk_scope,
iss: bulk_client_id,
sub: bulk_client_id,
aud: bulk_token_endpoint,
client_assertion_type: 'not_an_assertion_type',
kid: bulk_jwks_kid)

post(**{ client: :token_endpoint }.merge(post_request_content))

assert_response_status(400)
end
end

test do
title 'Authorization request fails when client supplies invalid JWT token'
description <<~DESCRIPTION
The Backend Service Authorization specification 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. 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 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
# link 'http://hl7.org/fhir/uv/bulkdata/STU1.0.1/authorization/index.html#protocol-details'

run do
post_request_content = AuthorizationRequestBuilder.build(encryption_method: bulk_encryption_method,
scope: bulk_scope,
iss: 'not_a_valid_iss',
sub: bulk_client_id,
aud: bulk_token_endpoint,
kid: bulk_jwks_kid)

post(**{ client: :token_endpoint }.merge(post_request_content))

assert_response_status([400, 401])
end
end

test do
title 'Authorization request succeeds when supplied correct information'
description <<~DESCRIPTION
If the access token request is valid and authorized, the authorization server SHALL issue an access token in response.
DESCRIPTION
# link 'http://hl7.org/fhir/uv/bulkdata/STU1.0.1/authorization/index.html#issuing-access-tokens'

output :authentication_response

run do
post_request_content = AuthorizationRequestBuilder.build(encryption_method: bulk_encryption_method,
scope: bulk_scope,
iss: bulk_client_id,
sub: bulk_client_id,
aud: bulk_token_endpoint,
kid: bulk_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

test do
title 'Authorization request response body contains required information encoded in JSON'
description <<~DESCRIPTION
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
# link 'http://hl7.org/fhir/uv/bulkdata/STU1.0.1/authorization/index.html#issuing-access-tokens'

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
end
Loading