From 55dafb680602587839cec208df19e768a6110e0d Mon Sep 17 00:00:00 2001 From: Vanessa Fotso <46642178+vanessuniq@users.noreply.github.com> Date: Tue, 30 Jul 2024 10:15:54 -0400 Subject: [PATCH] FI-2910: Host JWKS for Client Assertion (#515) * Added support for auth_info to fhir_client Signed-off-by: Vanessa Fotso * Added unit test for auth info Signed-off-by: Vanessa Fotso * WIP:perform refresh using auth_info Signed-off-by: Vanessa Fotso * support for backend services authentication using auth_info Signed-off-by: Vanessa Fotso * WIP auth info unit test Signed-off-by: Vanessa Fotso * Updated auth_info_spec Signed-off-by: Vanessa Fotso * Extracted the auth info sample data to its own fixture file" Signed-off-by: Vanessa Fotso * Updated fhir_client_spec Signed-off-by: Vanessa Fotso * Freeze constants Signed-off-by: Vanessa Fotso * Updated ffhir client spec Signed-off-by: Vanessa Fotso * Update based on PR comments Signed-off-by: Vanessa Fotso * Update to return for and Signed-off-by: Vanessa Fotso * Host jwks set for client assertion Signed-off-by: Vanessa Fotso * Update ENV name for the inferno core jwks path Co-authored-by: Stephen MacVicar * Added doc to JWKS class Signed-off-by: Vanessa Fotso * proofread JWKs doc Co-authored-by: Stephen MacVicar * extract reading content of jwks file into its own method Signed-off-by: Vanessa Fotso * Host jwks set for client assertion Signed-off-by: Vanessa Fotso * WIP auth info unit test Signed-off-by: Vanessa Fotso * Updated auth_info_spec Signed-off-by: Vanessa Fotso * Extracted the auth info sample data to its own fixture file" Signed-off-by: Vanessa Fotso * Freeze constants Signed-off-by: Vanessa Fotso * Update based on PR comments Signed-off-by: Vanessa Fotso * Host jwks set for client assertion Signed-off-by: Vanessa Fotso * Added jwks url to app property Signed-off-by: Vanessa Fotso * use short class name Signed-off-by: Vanessa Fotso --------- Signed-off-by: Vanessa Fotso Co-authored-by: Stephen MacVicar --- lib/inferno/apps/web/router.rb | 3 +++ lib/inferno/config/application.rb | 2 ++ lib/inferno/dsl/auth_info.rb | 3 ++- spec/inferno/dsl/auth_info_spec.rb | 3 +++ spec/requests/jwks_spec.rb | 15 +++++++++++++++ 5 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 spec/requests/jwks_spec.rb diff --git a/lib/inferno/apps/web/router.rb b/lib/inferno/apps/web/router.rb index 5cf28217f..6ae96939b 100644 --- a/lib/inferno/apps/web/router.rb +++ b/lib/inferno/apps/web/router.rb @@ -53,6 +53,9 @@ module Web # Should not need Content-Type header but GitHub Codespaces will not work without them. # This could be investigated and likely removed if addressed properly elsewhere. get '/', to: ->(_env) { [200, { 'Content-Type' => 'text/html' }, [client_page]] } + get '/jwks.json', to: lambda { |_env| + [200, { 'Content-Type' => 'application/json' }, [Inferno::JWKS.jwks_json]] + }, as: :jwks Inferno.routes.each do |route| cleaned_id = route[:suite].id.gsub(/[^a-zA-Z\d\-._~]/, '_') diff --git a/lib/inferno/config/application.rb b/lib/inferno/config/application.rb index 5c268cd86..9cf29ca20 100644 --- a/lib/inferno/config/application.rb +++ b/lib/inferno/config/application.rb @@ -13,6 +13,7 @@ class Application < Dry::System::Container raw_js_host = ENV.fetch('JS_HOST', '') base_path = ENV.fetch('BASE_PATH', '') public_path = base_path.blank? ? '/public' : "/#{base_path}/public" + jwks_path = base_path.blank? ? '/jwks.json' : "/#{base_path}/jwks.json" js_host = raw_js_host.present? ? "#{raw_js_host}/public" : public_path Application.register('js_host', js_host) @@ -21,6 +22,7 @@ class Application < Dry::System::Container Application.register('async_jobs', ENV['ASYNC_JOBS'] != 'false') Application.register('inferno_host', ENV.fetch('INFERNO_HOST', 'http://localhost:4567')) Application.register('base_url', URI.join(Application['inferno_host'], base_path).to_s) + Application.register('jwks_url', URI.join(Application['inferno_host'], jwks_path).to_s) Application.register('cache_bust_token', SecureRandom.uuid) configure do |config| diff --git a/lib/inferno/dsl/auth_info.rb b/lib/inferno/dsl/auth_info.rb index 08f061c45..d3bb1c2f6 100644 --- a/lib/inferno/dsl/auth_info.rb +++ b/lib/inferno/dsl/auth_info.rb @@ -270,7 +270,8 @@ def auth_jwt_header { 'alg' => encryption_algorithm, 'kid' => private_key['kid'], - 'typ' => 'JWT' + 'typ' => 'JWT', + 'jku' => Inferno::Application['jwks_url'] } end diff --git a/spec/inferno/dsl/auth_info_spec.rb b/spec/inferno/dsl/auth_info_spec.rb index 01f6977f5..a79a21755 100644 --- a/spec/inferno/dsl/auth_info_spec.rb +++ b/spec/inferno/dsl/auth_info_spec.rb @@ -27,6 +27,7 @@ let(:asymmetric_auth_info) { described_class.new(asymmetric_confidential_access_default) } let(:backend_services_auth_info) { described_class.new(backend_services_access_default) } let(:client) { FHIR::Client.new('http://example.com') } + let(:jwks_url) { Inferno::Application['jwks_url'] } describe '.new' do it 'raises an error if an invalid key is provided' do @@ -229,6 +230,7 @@ expect(header['alg']).to eq(asymmetric_auth_info.encryption_algorithm) expect(header['typ']).to eq('JWT') + expect(header['jku']).to eq(jwks_url) expect(header['kid']).to eq(asymmetric_auth_info.kid) expect(claims['iss']).to eq(asymmetric_auth_info.client_id) expect(claims['aud']).to eq(asymmetric_auth_info.token_url) @@ -246,6 +248,7 @@ expect(header['alg']).to eq(asymmetric_auth_info.encryption_algorithm) expect(header['typ']).to eq('JWT') + expect(header['jku']).to eq(jwks_url) expect(header['kid']).to be_present expect(claims['iss']).to eq(asymmetric_auth_info.client_id) expect(claims['aud']).to eq(asymmetric_auth_info.token_url) diff --git a/spec/requests/jwks_spec.rb b/spec/requests/jwks_spec.rb new file mode 100644 index 000000000..5a8330baf --- /dev/null +++ b/spec/requests/jwks_spec.rb @@ -0,0 +1,15 @@ +require 'request_helper' + +RSpec.describe '/jwks.json' do + let(:router) { Inferno::Web::Router } + let(:parsed_response) { JSON.parse(Inferno::JWKS.jwks_json) } + + describe '/jwks.json' do + it 'renders the Inferno Core JWKS as json' do + get router.path(:jwks) + + expect(last_response.status).to eq(200) + expect(parsed_body).to eq(parsed_response) + end + end +end