-
Notifications
You must be signed in to change notification settings - Fork 2
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
Changes from 2 commits
0cc043b
fcc900a
b35b418
6293689
5c002fd
261b182
39429fe
e3d251b
25d947a
927e21e
b8c1941
0525bc0
24af3f5
1df356e
7482711
bf35427
bc931bc
4cec6be
1b87257
b725194
00e4459
ecf8270
73f5a4b
d747c71
f25c9e0
afb00c0
a18707b
670c5c9
754a8f6
f6d9d1a
e94a0bc
5c76829
871a10e
a807acb
f0c762e
d6252f2
5632d71
633c774
3913d4b
04c7092
e517050
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
require 'json/jwt' | ||
|
||
module SMARTAppLaunch | ||
class AuthorizationRequestBuilder | ||
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 |
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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These titles should be updated as well. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ClientAssertionBuilder
andAuthorizationRequestBuilder
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, andClientAssertionBuilder
is too narrow for Backend Services (tests would have duplicated code across them thatAuthRequestBuilder
addresses).So I opted to keep both classes but had
AuthRequestBuilder
useClientAssertionBuilder
in building the requests, so there isn't redundant behavior between the two classes. I also renamedAuthRequestBuilder
to be specific to Backend Services, since those are the only tests that use it.