Skip to content

Commit

Permalink
Follow the specs more closely and fix tests
Browse files Browse the repository at this point in the history
  • Loading branch information
David Widmann committed Jan 18, 2024
1 parent 5e10337 commit 5df875a
Show file tree
Hide file tree
Showing 11 changed files with 113 additions and 108 deletions.
4 changes: 1 addition & 3 deletions Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,13 @@ HealthBase = "94e1309d-ccf4-42de-905f-515f1d7b1cae"
JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
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"
HealthBase = "1.0.1"
JSON3 = "1.5.1"
JWTs = "0.2.2"
TimeZones = "1.5.3"
JWTs = "0.2.3"
URIs = "1.2"
julia = "1.5"

Expand Down
2 changes: 0 additions & 2 deletions src/SMARTBackendServices.jl
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import HealthBase
import JSON3
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
50 changes: 34 additions & 16 deletions src/backend_services.jl
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
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,
)
Expand All @@ -26,29 +28,45 @@ function _backend_services_create_jwt(config::BackendServicesConfig)
return string(jwt)
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)
# Retrieve the server configuration
# Ref: https://www.hl7.org/fhir/smart-app-launch/backend-services.html#retrieve-well-knownsmart-configuration
_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",),
)
config_response = JSON3.read(_config_response.body)
token_endpoint = get(config_response, :token_endpoint) do
throw(ArgumentError("SMART configuration: `token_endpoint` is not specified"))
end::String

# 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
23 changes: 0 additions & 23 deletions src/timestamps.jl

This file was deleted.

16 changes: 7 additions & 9 deletions src/types.jl
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,20 @@
BackendServicesConfig{T <: JWTs.JWK}(; kwargs...)
## Required Keyword Arguments:
- `iss::String`
- `base_url`::String
- `client_id::String`
- `scope::String`
- `key::T`
- `sub::String`
- `token_endpoint::String`
## Optional Keyword Arguments:
- `scope::Union{String, Nothing}`. Default value: `nothing`.
## Optional Keyword Argument:
- `keyid::Union{String, Nothing}`. Default value: `nothing`.
"""
Base.@kwdef struct BackendServicesConfig{T <: JWTs.JWK}
iss::String
base_url::String
client_id::String
scope::String
key::T
keyid::Union{String, Nothing} = nothing
scope::Union{String, Nothing} = nothing
sub::String
token_endpoint::String
end

Base.@kwdef struct BackendServicesResult
Expand Down
45 changes: 33 additions & 12 deletions test/basic.jl
Original file line number Diff line number Diff line change
@@ -1,22 +1,43 @@
server = "https://launch.smarthealthit.org/v/r4/"
token_endpoint = server * "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. Insert the public key (in JSON format) in the "keys" array in ./key/public_jwkset.json
# 7. Save the private key (in X.509 format) as ./key/private.pem

# Settings of the registered client
base_url = "https://launch.smarthealthit.org/v/r4/sim/WzQsIiIsIiIsIiIsMCwwLDAsInN5c3RlbS8qLnJzIiwiIiwiM240M3JpV29zZjdnZUJrWkJ1eWJndkgzTVo3WEhDbnNRMnc2MFRJbDUxa3ZDb2hoeXBWaTc2R0tqUGNZWnFlS3d3NXhnaGZrWE9OOUNXWmUyNGRjRDUxdmdGc3hRcFd5S3UyMzF1YXplcjk1NjJSQVV2VEZZOVFtc3dhZDMzenZYb21OVDlXcEFmTnZ0TmN2aE96T3dkclpaMW1ZQ1Y1cEhmWllZWEpIdlB6N21uR3Zybm52SFVXMVZBTWF5WmYzOGRNOGNZM003djVoaGIyT1hhYmd0WEJUYWJ4OEhvMnJHT2NhS1pHY0RwZHM4a2ZjYUlmREpJb0pENkY3Mk1DUzJQcEI4VW9NMnRzSkZBTk13VUNpVEVvTW5sdXUzSmJQT2tmeTVIdmNlcG1YYVZmQzVQZXlGN0xMVEhnOGVQVFAxV1FYT0R1ZHQ4YmllcFVtZHN2OWVUM0ptUFlJdzRLQ25HbEx2TzhXRktyUXF3ejE4S080RGpTY0hJTFVqUXpEZkdHampYQlVaNXY2bUtvSHV2RXlJMWlqQkNQcDROdlAyOWNFVVFqY3hISTIyU0tua1ZmTFhFSzd3MW1kUmx2akFGT3VNdkpvRVJNYjlIZzYzT1AzaUdCbjExMnIwWFVoSGpHdXpFYjloTmM2M2trVVhJSGtEcUQxUEthVHhvUnExYWZHc3RhNEl0cjM2bUpRVDRPd2N3ZWxLdVgyRjMyZ3VQem92R0E1d0liRzVJNmlvcHA2YTdpbkllSWdnODR3SDVEVlB3UkdyNVRyNEdCdHhwaHRuU3I5dFd6REEwbm9YOVZoRjRBZWhZTWRHMnh0YlZHbWtFUlJzMFBLR0hwUVVZWFo3WURreGt6S3dudmVvazRsSlM3M2ZSaXRXY2dCWmkxVGFWV1pQT1ZzMnJsWFZEVzU3azg3aDFyemFvZmd2WUZwZkdPZGZIbVA4N28yVlhBUThYWW5XOFRIb0x4d2NkSUNuVjltWTB4WjhZbnVLUjM4WHZFaVBseGF4NVpGNzdZUTVzUGJGeTUzUnU2Q2kyRGx5NnZVOXF1dVRWSzNNSmFRSTFJVWZScUxRRndVaXZ0WGw3aUZESDBtUFdRVXM1c2tSelhLUUVjbkxWUjJmVTBUVnQzOU1YNVduU1Bnb2VOVnd4dDg0aHNzZU9LYkZKelptR1hTalNjYkZMRHFVTjNES0gwN0tIS09zMGVNSVlHdzBkbm5sdmpsUU00TlRsZ1R5b2dJZmdCd0xmQ2VYdVRkRUhCWGtJbU5DRERxTktzeFZTOWlyVkNXdlpjRFR4anJZN250MWZQVzNXb0dmR1ZqeG0ycTVhbzcxYm1NSElyeEh1dU93azFHMUMxeiIsIiIsIiIsIiIsIntcImtleXNcIjogW3tcbiAgICBcImt0eVwiOiBcIlJTQVwiLFxuICAgIFwiZVwiOiBcIkFRQUJcIixcbiAgICBcInVzZVwiOiBcInNpZ1wiLFxuICAgIFwia2lkXCI6IFwiWWIwOWhURENxbW8wVXR0U2NGT2YzN1Z6eDE5amlEbGJuellRWUF2NnVYa1wiLFxuICAgIFwiYWxnXCI6IFwiUlMzODRcIixcbiAgICBcIm5cIjogXCJ2NHBUS0dxeng1b3JELVc4YzBkRkt5Nm15TEh0NEtlekVfeE5WenZXUFdvMUR3V0ozTXRtS1BuYnJiclB0MHBOaHVPVHVBLXp4RWR1U1o5MldsTGlNLWE5TEhVXzVMdm1jTTV6UHFjd2pwOGE1SWFyaVdieC03NE9rd1k1Nk04MEpLWlVReVZ0czNsTE5Kdi05aHpUS0J0aGVRTl92RkZOdk00ck9ueUphTE1tUENWY1Q4MXE5VUlhWHRnQWhLQ3BHdFpiZlZFbEFMR1lqeUZtYjdpTzBMWDROb1FheU1vSlhLY3FGbmY2N0dqRnB3ZzhqTVkxaGliT1J1eVJ5YVlNdUowWkpWcUFhdXp1dnVsaUxyMUx0R1BWZ292ZXdVRFV0LWtnTkZ6SGRDNmNjVF9Ed3BHbXpsR2twQjJ5ZEJ1T2NjbGxJa1NTbndYM3NvZ1NkX0dzbndcIlxufV19IiwyLDFd/fhir"
client_id = "3n43riWosf7geBkZBuybgvH3MZ7XHCnsQ2w60TIl51kvCohhypVi76GKjPcYZqeKww5xghfkXON9CWZe24dcD51vgFsxQpWyKu231uazer9562RAUvTFY9Qmswad33zvXomNT9WpAfNvtNcvhOzOwdrZZ1mYCV5pHfZYYXJHvPz7mnGvrnnvHUW1VAMayZf38dM8cY3M7v5hhb2OXabgtXBTabx8Ho2rGOcaKZGcDpds8kfcaIfDJIoJD6F72MCS2PpB8UoM2tsJFANMwUCiTEoMnluu3JbPOkfy5HvcepmXaVfC5PeyF7LLTHg8ePTP1WQXODudt8biepUmdsv9eT3JmPYIw4KCnGlLvO8WFKrQqwz18KO4DjScHILUjQzDfGGjjXBUZ5v6mKoHuvEyI1ijBCPp4NvP29cEUQjcxHI22SKnkVfLXEK7w1mdRlvjAFOuMvJoERMb9Hg63OP3iGBn112r0XUhHjGuzEb9hNc63kkUXIHkDqD1PKaTxoRq1afGsta4Itr36mJQT4OwcwelKuX2F32guPzovGA5wIbG5I6iopp6a7inIeIgg84wH5DVPwRGr5Tr4GBtxphtnSr9tWzDA0noX9VhF4AehYMdG2xtbVGmkERRs0PKGHpQUYXZ7YDkxkzKwnveok4lJS73fRitWcgBZi1TaVWZPOVs2rlXVDW57k87h1rzaofgvYFpfGOdfHmP87o2VXAQ8XYnW8THoLxwcdICnV9mY0xZ8YnuKR38XvEiPlxax5ZF77YQ5sPbFy53Ru6Ci2Dly6vU9quuTVK3MJaQI1IUfRqLQFwUivtXl7iFDH0mPWQUs5skRzXKQEcnLVR2fU0TVt39MX5WnSPgoeNVwxt84hsseOKbFJzZmGXSjScbFLDqUN3DKH07KHKOs0eMIYGw0dnnlvjlQM4NTlgTyogIfgBwLfCeXuTdEHBXkImNCDDqNKsxVS9irVCWvZcDTxjrY7nt1fPW3WoGfGVjxm2q5ao71bmMHIrxHuuOwk1G1C1z"
scope = "system/*.rs"

# Signing key
openid_config = String(HTTP.get(server * "fhir/.well-known/openid-configuration").body)
keyset = JWTs.JWKSet(JSON3.read(openid_config)["jwks_uri"])
keyset = JWTs.JWKSet("file://$(@__DIR__)/key/public_jwkset.json")
JWTs.refresh!(keyset)
keyid, key = first(keyset.keys)
key = JWTs.JWKRSA(key.kind, MbedTLS.parse_keyfile(test_private_key))

client_id = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJwdWJfa2V5IjoiTFMwdExTMUNSVWRKVGlCUVZVSk1TVU1nUzBWWkxTMHRMUzBLVFVsSlFrbHFRVTVDWjJ0eGFHdHBSemwzTUVKQlVVVkdRVUZQUTBGUk9FRk5TVWxDUTJkTFEwRlJSVUYzWVdvMVoza3hkRXRvVGtOWVdYTjNOV0YzVkFwd1p5OVRja2xuYm1sU1YybElVVmwyZDFseWFrSk5WSHBYUkhkcGQyRTNXbkZLTDNSalRFTk5lR1Y1T0dRMlRHdDRWbkpoYldOb1lqWkdSMnhaZERaUkNtRnZNbkpRWlRSNGJVZ3hkak4zZW1kbVZqaEljbTFUTTI5R2NqbDRjRFJPTm5rNGNtdFdkekZ2Vmtoc2RqZHpVRTV0VlRkell5OHhhU3RJY1ZOUlRFb0thM3BWY1dOQ2FubzFVME14YkhwMlpYaG5jVzkxWjNKNGRUVk5abWwwTmtGd1pHRjFSVGc0U3k5dVNGVk9TM1l2T1ROWmFqTkNaM3BNSzBGV1UwUkpRUW92Ynpsc2VFVlplVmxHV1RBek5HaFJSVmhwVFVFME4yY3ZVRk5ZU20xU2NHWkRXV2hhVUc4MFNtTkdjRXBoU0V4amVGbGhiRmxVZUdSdVZDODVlREJuQ21sQlJETnJjMlZaY20wNFprd3JjRU5EY1V4bFdHbEZXVm94Y0d0R1pqWjFjMkZ5WVZScVMyeGlaSGxMWjJadEwyNWtWemR5V2xkemJVSkZSVVVyUWtVS01GRkpSRUZSUVVJS0xTMHRMUzFGVGtRZ1VGVkNURWxESUV0RldTMHRMUzB0Q2c9PSIsImlzcyI6Imh0dHBzOi8vd2hhdGV2ZXIuc21hcnQvb3VyLXNhbXBsZS1iYWNrZW5kLXNlcnZpY2UiLCJhY2Nlc3NUb2tlbnNFeHBpcmVJbiI6MSwiaWF0IjoxNTEwNzY2MTQzfQ.7YooXIb64Y3_j38n-Gqwa1PqXc-hz-4xJAJF5oqxJVo"
keyid, key = only(keyset.keys)
key = JWTs.JWKRSA(key.kind, MbedTLS.parse_keyfile(joinpath(@__DIR__, "key", "private.pem")))

smart_config = BackendServicesConfig(;
iss = "https://whatever.smart/our-sample-backend-service",
sub = client_id,
base_url,
client_id,
key,
keyid,
scope = "system/*.*",
token_endpoint = token_endpoint,
scope,
)

smart_result = backend_services(smart_config)
Expand Down
28 changes: 28 additions & 0 deletions test/key/private.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC/ilMoarPHmisP
5bxzR0UrLqbIse3gp7MT/E1XO9Y9ajUPBYncy2Yo+dutus+3Sk2G45O4D7PER25J
n3ZaUuIz5r0sdT/ku+ZwznM+pzCOnxrkhquJZvH7vg6TBjnozzQkplRDJW2zeUs0
m/72HNMoG2F5A3+8UU28zis6fIlosyY8JVxPzWr1Qhpe2ACEoKka1lt9USUAsZiP
IWZvuI7Qtfg2hBrIyglcpyoWd/rsaMWnCDyMxjWGJs5G7JHJpgy4nRklWoBq7O6+
6WIuvUu0Y9WCi97BQNS36SA0XMd0LpxxP8PCkabOUaSkHbJ0G45xyWUiRJKfBfey
iBJ38ayfAgMBAAECggEACGrLvLrzq5Ha0pgF8ArxvzQNoEQxb+3usLIls++tOoWw
TzivFkTZ+HMCdGABQMzDG2pk03HdNILvFc0sJkv+JMm/BnUgmayoM8zew6IVQC89
v00rvJ4JhEaV3WAoq1JvuCyXB1xdtcVeyLESQz6BCQIrBmZg9qWcBPAKdkeCwlcJ
e4QQMNTXCVp5mw1SGbOJNuFcCIEdE18TlRUMXH/iu2uytuF4ta9KiG0ncd1HlAvg
JTmEdSs0u3g7jqdHpDOpPBV1yR3Z7dXW++Htut21ibS+l4nUMlY6PUqX/ekAiOJK
7keXmHh2Hu+XJmiFbhEeMCakJ8j0H0rGujBoUimUAQKBgQD6OXAfn29Yv3yuorai
brkLLruxlcuakww5XT2EkHB2W2vIsvQqbeIiA1TsrTBuwbA1LKCnT13go+btObYL
QeC/QwZ4zOUANWarLt4/CAMS8YJQceyvMbtY5y5uaa/m3efRg7wgXuo4loOsImDo
KcDLrqFCQ4xGbD9+ot8aub/SNwKBgQDD9iBJDcl17Rp9u4PBBFZKget4rAq7zrL5
2PH56ZIekHOYh3Tz/FP5Jtw09xTuLq48eB+cd7+hFyQ7djSLMtrdIF7+Vram/hQO
yY/4xP/f7KI0VCPU4RVus9rvnRNqX/sTaQX132Hd6n81IQ46D3Ydq/jR8Xc9PkA/
ux1d6h1k2QKBgCXhBhEzcIatzjEdnqouOsLvmyhB9eV7dzFD5SkpOG0iX8mtFXtK
0R23BWcivJ83oPYbwGIziJGWHkIxJ1bC7UPm4Jbu5YfHjbhCSxCdpOF8P+7voBXR
YHwP2x6Jz0ASvaIg4BzCYZCZcGthdOwQTghHck/2q8iJYH9KJp4EGn63AoGAecV3
eI4Vs688OAePLyFX0gL75Ufbf6tJcpLKg0cOoumWu6DpHzicogXw7wTfP8dIRJFM
63lKXns3669fpWRbtu+HuDsAU7MtmabNTYR1kJvGjAgBICcin2EqWp3cU80DA6PO
rWQ7t6Ahnk1FPvUeq/+SpjuLokYNvMy0yghSbbkCgYAnt4z+h2JWNGQP6o+DDni2
xce2FnfQqY8JSEgzZ6N5W3WBOMpqalKb6sSOLBSgBF+TOTPnlAjvskDTuq8uNKs6
ztPQ3fw1Fk1hpmoJ4zCTwCHcrhx4k5wTr5zG5AjNSWHML4qRJvpAVEoHBe6sQNcE
8LXWXT5beHIGvy3g+aP5OA==
-----END PRIVATE KEY-----
10 changes: 10 additions & 0 deletions test/key/public_jwkset.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"keys": [{
"kty": "RSA",
"e": "AQAB",
"use": "sig",
"kid": "Yb09hTDCqmo0UttScFOf37Vzx19jiDlbnzYQYAv6uXk",
"alg": "RS384",
"n": "v4pTKGqzx5orD-W8c0dFKy6myLHt4KezE_xNVzvWPWo1DwWJ3MtmKPnbrbrPt0pNhuOTuA-zxEduSZ92WlLiM-a9LHU_5LvmcM5zPqcwjp8a5IariWbx-74OkwY56M80JKZUQyVts3lLNJv-9hzTKBtheQN_vFFNvM4rOnyJaLMmPCVcT81q9UIaXtgAhKCpGtZbfVElALGYjyFmb7iO0LX4NoQayMoJXKcqFnf67GjFpwg8jMY1hibORuyRyaYMuJ0ZJVqAauzuvuliLr1LtGPVgovewUDUt-kgNFzHdC6ccT_DwpGmzlGkpB2ydBuOccllIkSSnwX3sogSd_Gsnw"
}]
}
6 changes: 0 additions & 6 deletions test/runtests.jl
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
using SMARTBackendServices
using Test

import JSON3
import JWTs
import HTTP
import MbedTLS

include("test_private_key/create.jl")

@testset "SMARTBackendServices.jl" begin
include("basic.jl")
include("jwt.jl")
end

include("test_private_key/delete.jl")
36 changes: 0 additions & 36 deletions test/test_private_key/create.jl

This file was deleted.

1 change: 0 additions & 1 deletion test/test_private_key/delete.jl

This file was deleted.

0 comments on commit 5df875a

Please sign in to comment.