Skip to content

Commit

Permalink
Merge branch 'master' into compathelper/new_version/2023-08-29-11-46-…
Browse files Browse the repository at this point in the history
…30-681-03517010126
  • Loading branch information
devmotion authored Feb 14, 2024
2 parents e03e089 + 84ead00 commit 962f546
Show file tree
Hide file tree
Showing 13 changed files with 222 additions and 121 deletions.
12 changes: 5 additions & 7 deletions Project.toml
Original file line number Diff line number Diff line change
@@ -1,30 +1,28 @@
name = "SMARTBackendServices"
uuid = "78af60b6-7677-4c75-8291-bd270d1b4390"
authors = ["Dilum Aluthge", "contributors"]
version = "1.0.1"
version = "2.0.0"

[deps]
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
HealthBase = "94e1309d-ccf4-42de-905f-515f1d7b1cae"
JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
JSONWebTokens = "9b8beb19-0777-58c6-920b-28f749fee4d3"
JWTs = "d850fbd6-035d-5a70-a269-1ca2e636ac6c"
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
TimeZones = "f269a46b-ccf7-5d73-abea-4c690281aa53"
URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4"

[compat]
HTTP = "0.9.3, 1"
HealthBase = "1.0.1"
JSON3 = "1.5.1"
JSONWebTokens = "0.3.4, 1"
TimeZones = "1.5.3"
JWTs = "0.2.4"
URIs = "1.2"
julia = "1.5"

[extras]
JSONWebTokens = "9b8beb19-0777-58c6-920b-28f749fee4d3"
MbedTLS = "739be429-bea8-5141-9913-cc70e7f3736d"
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"

[targets]
test = ["JSONWebTokens", "Test"]
test = ["MbedTLS", "Test"]
3 changes: 3 additions & 0 deletions docs/Project.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[deps]
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
SMARTBackendServices = "78af60b6-7677-4c75-8291-bd270d1b4390"

[compat]
Documenter = "1"
1 change: 0 additions & 1 deletion docs/make.jl
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ makedocs(;
"Home" => "index.md",
"API" => "api.md",
],
strict=true,
)

deploydocs(;
Expand Down
4 changes: 1 addition & 3 deletions src/SMARTBackendServices.jl
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@ import Dates
import HTTP
import HealthBase
import JSON3
import JSONWebTokens
import JWTs
import Random
import TimeZones
import URIs

const get_fhir_access_token = HealthBase.get_fhir_access_token
Expand All @@ -19,6 +18,5 @@ include("types.jl")

include("backend_services.jl")
include("jwt.jl")
include("timestamps.jl")

end # module
142 changes: 124 additions & 18 deletions src/backend_services.jl
Original file line number Diff line number Diff line change
@@ -1,45 +1,151 @@
function _backend_services_create_jwt(config::BackendServicesConfig)
function _backend_services_create_jwt(config::BackendServicesConfig, token_endpoint::AbstractString)
# Random string that uniquely identifies the JWT
jti = Random.randstring(150)

now = TimeZones.now(TimeZones.localzone())
expiration_time = now + Dates.Minute(4)
expiration_time_seconds_since_epoch_utc = integer_seconds_since_the_epoch_utc(expiration_time)

# Expiration time (integer) in seconds since "epoch"
# SHALL be no more than 5 minutes in the future
expiration_time = Dates.now(Dates.UTC) + Dates.Minute(4)
expiration_time_seconds_since_epoch_utc = round(Int, Dates.datetime2unix(expiration_time))

jwt_payload_claims_dict = Dict(
"iss" => config.iss,
"sub" => config.sub,
"aud" => config.token_endpoint,
"iss" => config.client_id,
"sub" => config.client_id,
"aud" => token_endpoint,
"jti" => jti,
"exp" => expiration_time_seconds_since_epoch_utc,
)
jwt = JSONWebTokens.encode(config.private_key, jwt_payload_claims_dict)
jwt = JWTs.JWT(; payload = jwt_payload_claims_dict)

# Sign
JWTs.sign!(jwt, config.key, config.keyid)
@assert JWTs.issigned(jwt)
@assert JWTs.kid(jwt) == config.keyid

return jwt
return string(jwt)
end

# Obtain the token endpoint from the well-known URIs
# Ref: https://www.hl7.org/fhir/smart-app-launch/backend-services.html#retrieve-well-knownsmart-configuration
function _token_endpoint_wellknown(config::BackendServicesConfig)
# Request the SMART configuration file
_config_response = HTTP.request(
"GET",
joinpath(config.base_url, ".well-known/smart-configuration");
# In principle, it should be possible to omit the header
# (and servers may ignore it anyway)
# Ref: https://www.hl7.org/fhir/smart-app-launch/conformance.html#using-well-known
headers = ("Accept" => "application/json",),
# Old servers might still only support the /metadata endpoint (even though its use for SMART capabilities is deprecated)
# Hence we do not throw an exception if the request fails but try the /metadata endpoint first
# Ref: https://hl7.org/fhir/smart-app-launch/1.0.0/conformance/index.html#declaring-support-for-oauth2-endpoints
# Ref: https://www.hl7.org/fhir/smart-app-launch/conformance.html#smart-on-fhir-oauth-authorization-endpoints-and-capabilities
status_exception = false,
)

# Exit gracefully (return `nothing`) if the server does not convey its SMART capabilities using well-known URIs
if _config_response.status != 200
return nothing
end

# Extract the token endpoint from the JSON response
config_response = JSON3.read(_config_response.body)
get(config_response, :token_endpoint) do
error(
"SMART configuration: Violation of the FHIR specification. The mandatory `token_endpoint` is missing from the Well-Known Uniform Resource Identifiers (URIs) JSON document.",
)
end::String
end

# Obtain the token endpoint from the CapabilityStatement at the /metadata endpoint
# Note: Declaring SMART capabilities using the /metadata endpoint is deprecated but old servers might still not support the well-known URIs
# Ref: https://hl7.org/fhir/smart-app-launch/1.0.0/conformance/index.html#declaring-support-for-oauth2-endpoints
function _token_endpoint_metadata(config::BackendServicesConfig)
# Request the CapabilityStatement
_metadata_response = HTTP.request(
"GET",
joinpath(config.base_url, "metadata");
# We only support FHIR version R4
# Ref: https://hl7.org/fhir/R4/versioning.html#mt-version
headers = ("Accept" => "application/fhir+json; fhirVersion=4.0"),
# We throw our own, hopefully more descriptive, exception if necessary
status_exception = false,
)

# Exit gracefully (return `nothing`) if the server does not convey its SMART capabilities at the /metadata endpoint
if _metadata_response.status != 200
return nothing
end

# Extract the token endpoint from the JSON response
# Ref: https://hl7.org/fhir/smart-app-launch/1.0.0/conformance/index.html#declaring-support-for-oauth2-endpoints
# Ref: https://hl7.org/fhir/R4/capabilitystatement.html
compat_statement = JSON3.read(_metadata_response.body)
rest = get(compat_statement, :rest, nothing)
if rest !== nothing
for rest in compat_statement.rest
security = get(rest, :security, nothing)
if security !== nothing
extensions = get(security, :extension, nothing)
if extensions !== nothing
for extension in extensions
if get(extension, :url, nothing) ===
"http://fhir-registry.smarthealthit.org/StructureDefinition/oauth-uris"
for url_value in extension.extension
if url_value.url === "token"
return url_value.valueUri::String
end
end
end
end
end
end
end
end

error(
"SMART configuration: Violation of the FHIR specification. The mandatory `token` url of the OAuth2 token endpoint is missing from the FHIR CompatibilityStatement.",
)
end

# Ref: https://www.hl7.org/fhir/smart-app-launch/backend-services.html
"""
backend_services(config::BackendServicesConfig)
"""
function backend_services(config::BackendServicesConfig)
jwt = _backend_services_create_jwt(config)
# Obtain the token endpoint: Try first the well-known URI and then the /metadata endpoint (deprecated)
# On Julia >= 1.7 this can be simplified to
# token_endpoint = @something _token_endpoint_wellknown(config) _token_endpoint_metadata(config) error("...")
token_endpoint = _token_endpoint_wellknown(config)
if token_endpoint === nothing
token_endpoint = _token_endpoint_metadata(config)
if token_endpoint === nothing
# Ref: https://www.hl7.org/fhir/smart-app-launch/conformance.html#smart-on-fhir-oauth-authorization-endpoints-and-capabilities
# Ref: https://hl7.org/fhir/smart-app-launch/1.0.0/conformance/index.html#smart-on-fhir-oauth-authorization-endpoints
error(
"SMART configuration: Violation of the FHIR specification. The FHIR server does neither convey its SMART capabilities using a Well-Known Uniform Resource Identifiers (URIs) JSON file nor its CapabilityStatement.",
)
end
end

# Obtain the access token
# Ref: https://www.hl7.org/fhir/smart-app-launch/backend-services.html#obtain-access-token
# Create JWT
jwt = _backend_services_create_jwt(config, token_endpoint)

body_params = Dict{String, String}()
body_params["grant_type"] = "client_credentials"
body_params["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
body_params["client_assertion"] = jwt

if config.scope !== nothing
body_params["scope"] = config.scope
end
body_params["scope"] = config.scope

_response = HTTP.request(
"POST",
config.token_endpoint;
headers = Dict("Content-Type" => "application/x-www-form-urlencoded"),
token_endpoint;
headers = ("Content-Type" => "application/x-www-form-urlencoded",),
body = URIs.escapeuri(body_params),
)

access_token_response = JSON3.read(String(_response.body))
access_token_response = JSON3.read(_response.body)
access_token = access_token_response.access_token

access_token_is_jwt, access_token_jwt_decoded = try_decode_jwt(access_token)
Expand Down
2 changes: 1 addition & 1 deletion src/jwt.jl
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
function try_decode_jwt(contents::AbstractString)
try
jwt_decoded = JSONWebTokens.decode(JSONWebTokens.None(), contents)
jwt_decoded = JWTs.claims(JWTs.JWT(; jwt = contents))
return true, jwt_decoded
catch
end
Expand Down
23 changes: 0 additions & 23 deletions src/timestamps.jl

This file was deleted.

26 changes: 12 additions & 14 deletions src/types.jl
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
"""
BackendServicesConfig{PK}(; kwargs...)
BackendServicesConfig{T <: JWTs.JWK}(; kwargs...)
## Required Keyword Arguments:
- `iss::String`
- `private_key::PK`
- `sub::String`
- `token_endpoint::String`
## Optional Keyword Arguments:
- `scope::Union{String, Nothing}`. Default value: `nothing`.
- `base_url`::String
- `client_id::String`
- `scope::String`
- `key::T`
- `keyid::String`
"""
Base.@kwdef struct BackendServicesConfig{PK <: JSONWebTokens.Encoding}
iss::String
private_key::PK
scope::Union{String, Nothing} = nothing
sub::String
token_endpoint::String
Base.@kwdef struct BackendServicesConfig{T <: JWTs.JWK}
base_url::String
client_id::String
scope::String
key::T
keyid::String
end

Base.@kwdef struct BackendServicesResult
Expand Down
58 changes: 46 additions & 12 deletions test/basic.jl
Original file line number Diff line number Diff line change
@@ -1,21 +1,55 @@
token_endpoint = "https://launch.smarthealthit.org/v/r4/auth/token"
# This test uses the public https://launch.smarthealthit.org test server
#
# In the webinterface, select "Launch Type": "Backend Service"
# and then switch to the "Client Registration & Validation" tab
#
# There you can register a client (with randomly generated ID),
# possibly restricted to some scope,
# with a JWK set of public keys for authentication.
#
# Use the base URL at the bottom of the page to connect to the
# server with the stated client ID, scope, and keys.
#
# A private key together with a public JWK set can be generated
# e.g. with https://mkjwk.org/ (alternatively, you can e.g.
# generate the key with openssl and create the JWK set manually):
# 1. Select "Key Use": "Signature"
# 2. Select "Algorithm": "RS384"
# 3. Specify a key id (or let it be generated automatically)
# 4. Check "Show X.509"
# 5. Press "Generate"
# 6. Update the `keyid` below
# 7. Save the private key (in X.509 format) as ./key/private.pem

client_id = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJwdWJfa2V5IjoiTFMwdExTMUNSVWRKVGlCUVZVSk1TVU1nUzBWWkxTMHRMUzBLVFVsSlFrbHFRVTVDWjJ0eGFHdHBSemwzTUVKQlVVVkdRVUZQUTBGUk9FRk5TVWxDUTJkTFEwRlJSVUYzWVdvMVoza3hkRXRvVGtOWVdYTjNOV0YzVkFwd1p5OVRja2xuYm1sU1YybElVVmwyZDFseWFrSk5WSHBYUkhkcGQyRTNXbkZLTDNSalRFTk5lR1Y1T0dRMlRHdDRWbkpoYldOb1lqWkdSMnhaZERaUkNtRnZNbkpRWlRSNGJVZ3hkak4zZW1kbVZqaEljbTFUTTI5R2NqbDRjRFJPTm5rNGNtdFdkekZ2Vmtoc2RqZHpVRTV0VlRkell5OHhhU3RJY1ZOUlRFb0thM3BWY1dOQ2FubzFVME14YkhwMlpYaG5jVzkxWjNKNGRUVk5abWwwTmtGd1pHRjFSVGc0U3k5dVNGVk9TM1l2T1ROWmFqTkNaM3BNSzBGV1UwUkpRUW92Ynpsc2VFVlplVmxHV1RBek5HaFJSVmhwVFVFME4yY3ZVRk5ZU20xU2NHWkRXV2hhVUc4MFNtTkdjRXBoU0V4amVGbGhiRmxVZUdSdVZDODVlREJuQ21sQlJETnJjMlZaY20wNFprd3JjRU5EY1V4bFdHbEZXVm94Y0d0R1pqWjFjMkZ5WVZScVMyeGlaSGxMWjJadEwyNWtWemR5V2xkemJVSkZSVVVyUWtVS01GRkpSRUZSUVVJS0xTMHRMUzFGVGtRZ1VGVkNURWxESUV0RldTMHRMUzB0Q2c9PSIsImlzcyI6Imh0dHBzOi8vd2hhdGV2ZXIuc21hcnQvb3VyLXNhbXBsZS1iYWNrZW5kLXNlcnZpY2UiLCJhY2Nlc3NUb2tlbnNFeHBpcmVJbiI6MSwiaWF0IjoxNTEwNzY2MTQzfQ.7YooXIb64Y3_j38n-Gqwa1PqXc-hz-4xJAJF5oqxJVo"
# Settings of the registered client
base_url = "https://launch.smarthealthit.org/v/r4/sim/WzQsIiIsIiIsIiIsMCwwLDAsInN5c3RlbS8qLnJzIiwiIiwiM240M3JpV29zZjdnZUJrWkJ1eWJndkgzTVo3WEhDbnNRMnc2MFRJbDUxa3ZDb2hoeXBWaTc2R0tqUGNZWnFlS3d3NXhnaGZrWE9OOUNXWmUyNGRjRDUxdmdGc3hRcFd5S3UyMzF1YXplcjk1NjJSQVV2VEZZOVFtc3dhZDMzenZYb21OVDlXcEFmTnZ0TmN2aE96T3dkclpaMW1ZQ1Y1cEhmWllZWEpIdlB6N21uR3Zybm52SFVXMVZBTWF5WmYzOGRNOGNZM003djVoaGIyT1hhYmd0WEJUYWJ4OEhvMnJHT2NhS1pHY0RwZHM4a2ZjYUlmREpJb0pENkY3Mk1DUzJQcEI4VW9NMnRzSkZBTk13VUNpVEVvTW5sdXUzSmJQT2tmeTVIdmNlcG1YYVZmQzVQZXlGN0xMVEhnOGVQVFAxV1FYT0R1ZHQ4YmllcFVtZHN2OWVUM0ptUFlJdzRLQ25HbEx2TzhXRktyUXF3ejE4S080RGpTY0hJTFVqUXpEZkdHampYQlVaNXY2bUtvSHV2RXlJMWlqQkNQcDROdlAyOWNFVVFqY3hISTIyU0tua1ZmTFhFSzd3MW1kUmx2akFGT3VNdkpvRVJNYjlIZzYzT1AzaUdCbjExMnIwWFVoSGpHdXpFYjloTmM2M2trVVhJSGtEcUQxUEthVHhvUnExYWZHc3RhNEl0cjM2bUpRVDRPd2N3ZWxLdVgyRjMyZ3VQem92R0E1d0liRzVJNmlvcHA2YTdpbkllSWdnODR3SDVEVlB3UkdyNVRyNEdCdHhwaHRuU3I5dFd6REEwbm9YOVZoRjRBZWhZTWRHMnh0YlZHbWtFUlJzMFBLR0hwUVVZWFo3WURreGt6S3dudmVvazRsSlM3M2ZSaXRXY2dCWmkxVGFWV1pQT1ZzMnJsWFZEVzU3azg3aDFyemFvZmd2WUZwZkdPZGZIbVA4N28yVlhBUThYWW5XOFRIb0x4d2NkSUNuVjltWTB4WjhZbnVLUjM4WHZFaVBseGF4NVpGNzdZUTVzUGJGeTUzUnU2Q2kyRGx5NnZVOXF1dVRWSzNNSmFRSTFJVWZScUxRRndVaXZ0WGw3aUZESDBtUFdRVXM1c2tSelhLUUVjbkxWUjJmVTBUVnQzOU1YNVduU1Bnb2VOVnd4dDg0aHNzZU9LYkZKelptR1hTalNjYkZMRHFVTjNES0gwN0tIS09zMGVNSVlHdzBkbm5sdmpsUU00TlRsZ1R5b2dJZmdCd0xmQ2VYdVRkRUhCWGtJbU5DRERxTktzeFZTOWlyVkNXdlpjRFR4anJZN250MWZQVzNXb0dmR1ZqeG0ycTVhbzcxYm1NSElyeEh1dU93azFHMUMxeiIsIiIsIiIsIiIsIntcImtleXNcIjogW3tcbiAgICBcImt0eVwiOiBcIlJTQVwiLFxuICAgIFwiZVwiOiBcIkFRQUJcIixcbiAgICBcInVzZVwiOiBcInNpZ1wiLFxuICAgIFwia2lkXCI6IFwiWWIwOWhURENxbW8wVXR0U2NGT2YzN1Z6eDE5amlEbGJuellRWUF2NnVYa1wiLFxuICAgIFwiYWxnXCI6IFwiUlMzODRcIixcbiAgICBcIm5cIjogXCJ2NHBUS0dxeng1b3JELVc4YzBkRkt5Nm15TEh0NEtlekVfeE5WenZXUFdvMUR3V0ozTXRtS1BuYnJiclB0MHBOaHVPVHVBLXp4RWR1U1o5MldsTGlNLWE5TEhVXzVMdm1jTTV6UHFjd2pwOGE1SWFyaVdieC03NE9rd1k1Nk04MEpLWlVReVZ0czNsTE5Kdi05aHpUS0J0aGVRTl92RkZOdk00ck9ueUphTE1tUENWY1Q4MXE5VUlhWHRnQWhLQ3BHdFpiZlZFbEFMR1lqeUZtYjdpTzBMWDROb1FheU1vSlhLY3FGbmY2N0dqRnB3ZzhqTVkxaGliT1J1eVJ5YVlNdUowWkpWcUFhdXp1dnVsaUxyMUx0R1BWZ292ZXdVRFV0LWtnTkZ6SGRDNmNjVF9Ed3BHbXpsR2twQjJ5ZEJ1T2NjbGxJa1NTbndYM3NvZ1NkX0dzbndcIlxufV19IiwyLDFd/fhir"
client_id = "3n43riWosf7geBkZBuybgvH3MZ7XHCnsQ2w60TIl51kvCohhypVi76GKjPcYZqeKww5xghfkXON9CWZe24dcD51vgFsxQpWyKu231uazer9562RAUvTFY9Qmswad33zvXomNT9WpAfNvtNcvhOzOwdrZZ1mYCV5pHfZYYXJHvPz7mnGvrnnvHUW1VAMayZf38dM8cY3M7v5hhb2OXabgtXBTabx8Ho2rGOcaKZGcDpds8kfcaIfDJIoJD6F72MCS2PpB8UoM2tsJFANMwUCiTEoMnluu3JbPOkfy5HvcepmXaVfC5PeyF7LLTHg8ePTP1WQXODudt8biepUmdsv9eT3JmPYIw4KCnGlLvO8WFKrQqwz18KO4DjScHILUjQzDfGGjjXBUZ5v6mKoHuvEyI1ijBCPp4NvP29cEUQjcxHI22SKnkVfLXEK7w1mdRlvjAFOuMvJoERMb9Hg63OP3iGBn112r0XUhHjGuzEb9hNc63kkUXIHkDqD1PKaTxoRq1afGsta4Itr36mJQT4OwcwelKuX2F32guPzovGA5wIbG5I6iopp6a7inIeIgg84wH5DVPwRGr5Tr4GBtxphtnSr9tWzDA0noX9VhF4AehYMdG2xtbVGmkERRs0PKGHpQUYXZ7YDkxkzKwnveok4lJS73fRitWcgBZi1TaVWZPOVs2rlXVDW57k87h1rzaofgvYFpfGOdfHmP87o2VXAQ8XYnW8THoLxwcdICnV9mY0xZ8YnuKR38XvEiPlxax5ZF77YQ5sPbFy53Ru6Ci2Dly6vU9quuTVK3MJaQI1IUfRqLQFwUivtXl7iFDH0mPWQUs5skRzXKQEcnLVR2fU0TVt39MX5WnSPgoeNVwxt84hsseOKbFJzZmGXSjScbFLDqUN3DKH07KHKOs0eMIYGw0dnnlvjlQM4NTlgTyogIfgBwLfCeXuTdEHBXkImNCDDqNKsxVS9irVCWvZcDTxjrY7nt1fPW3WoGfGVjxm2q5ao71bmMHIrxHuuOwk1G1C1z"
scope = "system/*.rs"

smart_config = BackendServicesConfig(;
iss = "https://whatever.smart/our-sample-backend-service",
sub = client_id,
private_key = JSONWebTokens.RS384(test_private_key),
scope = "system/*.*",
token_endpoint = token_endpoint,
)
# Signing key (RS384 algorithm, i.e., SHA384 hash function)
key = JWTs.JWKRSA(MbedTLS.MD_SHA384, MbedTLS.parse_keyfile(joinpath(@__DIR__, "key", "private.pem")))
keyid = "Yb09hTDCqmo0UttScFOf37Vzx19jiDlbnzYQYAv6uXk"

smart_result = backend_services(smart_config)
smart_config = BackendServicesConfig(; base_url, client_id, key, keyid, scope)

smart_result = backend_services(smart_config)
@test smart_result isa SMARTBackendServices.BackendServicesResult

access_token = get_fhir_access_token(smart_result)

@test access_token isa AbstractString

@test length(access_token) > 1

@testset "token_endpoint" begin
# Correct settings
token_endpoint_wellknown = SMARTBackendServices._token_endpoint_wellknown(smart_config)
@test token_endpoint_wellknown isa String
token_endpoint_metadata = SMARTBackendServices._token_endpoint_metadata(smart_config)
@test token_endpoint_metadata isa String
@test token_endpoint_metadata === token_endpoint_wellknown

# Incorrect base url
config = BackendServicesConfig(; base_url = "https://google.com", client_id, key, keyid, scope)
@test SMARTBackendServices._token_endpoint_wellknown(config) === nothing
@test SMARTBackendServices._token_endpoint_metadata(config) === nothing
@test_throws ErrorException("SMART configuration: Violation of the FHIR specification. The FHIR server does neither convey its SMART capabilities using a Well-Known Uniform Resource Identifiers (URIs) JSON file nor its CapabilityStatement.") backend_services(config)
end
Loading

0 comments on commit 962f546

Please sign in to comment.