From eb2b58952127c5c091cf15af21fdc9be15402752 Mon Sep 17 00:00:00 2001 From: Stephen Chudleigh Date: Fri, 12 Jul 2024 19:42:37 -0700 Subject: [PATCH 01/13] connect login.gov oidc configuration --- Gemfile | 6 ++ Gemfile.lock | 12 ++++ app/controllers/sessions_controller.rb | 60 ++++++++++++++++++++ app/helpers/sessions_helper.rb | 2 + app/models/login_gov.rb | 57 +++++++++++++++++++ app/views/sessions/create.html.erb | 2 + app/views/sessions/delete.html.erb | 2 + app/views/sessions/new.html.erb | 14 +++++ app/views/sessions/result.html.erb | 2 + config/environments/development.rb | 13 +++++ config/routes.rb | 2 + test/controllers/sessions_controller_test.rb | 23 ++++++++ 12 files changed, 195 insertions(+) create mode 100644 app/controllers/sessions_controller.rb create mode 100644 app/helpers/sessions_helper.rb create mode 100644 app/models/login_gov.rb create mode 100644 app/views/sessions/create.html.erb create mode 100644 app/views/sessions/delete.html.erb create mode 100644 app/views/sessions/new.html.erb create mode 100644 app/views/sessions/result.html.erb create mode 100644 test/controllers/sessions_controller_test.rb diff --git a/Gemfile b/Gemfile index 3a3b4d2b..7302da5f 100644 --- a/Gemfile +++ b/Gemfile @@ -16,6 +16,12 @@ gem "pg" # Use the Puma web server [https://github.com/puma/puma] gem "puma", ">= 5.0" +# Use the popular Faraday HTTP library +gem "faraday" + +# Use the JWT gem for JSON Web Tokens +gem "jwt" + # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] gem "importmap-rails" diff --git a/Gemfile.lock b/Gemfile.lock index b5764566..87fdd75b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -112,6 +112,11 @@ GEM ffi (1.17.0-arm64-darwin) ffi (1.17.0-x86_64-darwin) ffi (1.17.0-x86_64-linux-gnu) + faraday (2.10.0) + faraday-net_http (>= 2.0, < 3.2) + logger + faraday-net_http (3.1.0) + net-http globalid (1.2.1) activesupport (>= 6.1) i18n (1.14.5) @@ -128,6 +133,8 @@ GEM actionview (>= 5.0.0) activesupport (>= 5.0.0) json (2.7.2) + jwt (2.8.2) + base64 language_server-protocol (3.17.0.3) logger (1.6.0) loofah (2.22.0) @@ -144,6 +151,8 @@ GEM minitest (5.24.1) msgpack (1.7.2) mutex_m (0.2.0) + net-http (0.4.1) + uri net-imap (0.4.14) date net-protocol @@ -293,6 +302,7 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) + uri (0.13.0) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -320,8 +330,10 @@ DEPENDENCIES codeclimate-test-reporter cssbundling-rails (~> 1.4) debug + faraday importmap-rails jbuilder + jwt pg puma (>= 5.0) rails (~> 7.1.3, >= 7.1.3.4) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 00000000..295f0a0d --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,60 @@ +class SessionsController < ApplicationController + + before_action :handle_result_error, :well_known_configuration, :decode_token, only: [:result] + + def new + # TODO: handle redirect to login page due to inactivity + end + + def create + login_gov = LoginGov.new + redirect_to(login_gov.authorization_url, allow_other_host: true) + end + + def delete + login_gov = LoginGov.new + # TODO: update user session status, clear out JWT + # TODO: add session duration to the security log + # TODO: delete session locally and Phoenix + redirect_to(login_gov.logout_url) + end + + def result + + end + + private + + def handle_result_error + if params[:error] + Rails.logger.error("Login.gov authentication error: #{params[:error]}") + flash[:error] = "There was an issue with logging in. Please try again." + redirect_to new_session_path + end + + if params[:code].nil? && params[:state].nil? + Rails.logger.error("Login.gov unknown error") + flash[:error] = "Please try again." + redirect_to new_session_path + end + end + + def well_known_configuration + @login_gov = LoginGov.new + status, body = @login_gov.get_well_known_configuration + + if status != 200 + Rails.logger.error("well-known/openid-configuration error: code=#{status} - body:\n#{body}") + flash[:error] = "There was an issue logging in." + redirect_to new_session_path + return + end + + Rails.logger.debug("well_known_config response body=#{body}") + @openid_config = body + end + + def decode_token + private_key = @login_gov.private_key + end +end diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb new file mode 100644 index 00000000..309f8b2e --- /dev/null +++ b/app/helpers/sessions_helper.rb @@ -0,0 +1,2 @@ +module SessionsHelper +end diff --git a/app/models/login_gov.rb b/app/models/login_gov.rb new file mode 100644 index 00000000..0c67c451 --- /dev/null +++ b/app/models/login_gov.rb @@ -0,0 +1,57 @@ +class LoginGov + + # :acr_value, + # :client_id, + # :idp_host, + # :login_redirect_uri, + # :logout_redirect_uri, + # :logout_uri, + # :private_key_password, + # :private_key_path, + # :token_endpoint, + attr_reader :config + + def initialize(config = Rails.configuration.login_gov_oidc) + @config = config.freeze.dup + end + + def authorization_url + query = { + client_id: config[:client_id], + response_type: "code", + acr_values: config[:acr_value], + scope: "openid email", + redirect_uri: config[:login_redirect_uri], + state: SecureRandom.hex(16), + nonce: SecureRandom.hex(16), + prompt: "select_account" + }.to_query + + "#{config[:idp_host]}/openid_connect/authorize?#{query}" + end + + def logout_url + query = { + client_id: config[:client_id], + post_logout_redirect_uri: config[:logout_redirect_uri] + }.to_query + + "#{config[:logout_uri]}?#{query}" + end + + def well_known_configuration_url + config[:idp_host] + "/.well-known/openid-configuration" + end + + def get_well_known_configuration + response = Faraday.get(well_known_configuration_url) + [response.status, response.body] + end + + def private_key + key_path = config[:private_key_path] + key_pass = config[:private_key_password] + Rails.logger.info("Get private key from .pem file=#{key_path} pass=#{key_pass}") + "🔐" + end +end \ No newline at end of file diff --git a/app/views/sessions/create.html.erb b/app/views/sessions/create.html.erb new file mode 100644 index 00000000..6e13ad38 --- /dev/null +++ b/app/views/sessions/create.html.erb @@ -0,0 +1,2 @@ +

Session#create

+

Find me in app/views/session/create.html.erb

diff --git a/app/views/sessions/delete.html.erb b/app/views/sessions/delete.html.erb new file mode 100644 index 00000000..bea2c927 --- /dev/null +++ b/app/views/sessions/delete.html.erb @@ -0,0 +1,2 @@ +

Session#delete

+

Find me in app/views/session/delete.html.erb

diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 00000000..90386bdc --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,14 @@ +
+
+
+

You must accept the U.S. Government System terms to sign into this website

+

+ This is a U.S. General Services Administration Federal Government + computer system that is "FOR OFFICIAL USE ONLY." This System is subject + to monitoring. Individuals found performing unauthorized activities are + subject to disciplinary action including criminal prosecution. +

+ <%= button_to("Accept and Sign-in", session_path, class: "usa-button center-btn") %> +
+
+
diff --git a/app/views/sessions/result.html.erb b/app/views/sessions/result.html.erb new file mode 100644 index 00000000..d15b313c --- /dev/null +++ b/app/views/sessions/result.html.erb @@ -0,0 +1,2 @@ +

Session#result

+

Find me in app/views/session/result.html.erb

diff --git a/config/environments/development.rb b/config/environments/development.rb index 2e7fb486..498de12e 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -73,4 +73,17 @@ # Raise error when a before_action's only/except options reference missing actions config.action_controller.raise_on_missing_callback_actions = true + + config.login_gov_oidc = { + idp_host: "https://idp.int.identitysandbox.gov", + login_redirect_uri: "http://localhost:3000/auth/result", + logout_uri: "https://idp.int.identitysandbox.gov/openid_connect/logout", + logout_redirect_uri: "https://www.challenge.gov/", + acr_value: "http://idmanagement.gov/ns/assurance/loa/1", + client_id: "urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:challenge_gov_platform_dev", + private_key_password: nil, + private_key_path: "config/private.pem", + public_key_path: "config/public.crt", + token_endpoint: "https://idp.int.identitysandbox.gov/api/openid_connect/token" + } end diff --git a/config/routes.rb b/config/routes.rb index 683062bf..f633459c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,7 @@ Rails.application.routes.draw do # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + get 'auth/result', to: 'sessions#result' + resource 'session', only: [:new, :create, :destroy] # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb new file mode 100644 index 00000000..3e7ff661 --- /dev/null +++ b/test/controllers/sessions_controller_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class SessionsControllerTest < ActionDispatch::IntegrationTest + test "should get new" do + get session_new_url + assert_response :success + end + + test "should get create" do + post session_create_url + assert_response :success + end + + test "should get delete" do + get session_delete_url + assert_response :success + end + + test "should get result" do + get session_result_url + assert_response :success + end +end From e7eab0e2fae9f7890e50b2db86382fe88aa40679 Mon Sep 17 00:00:00 2001 From: Stephen Chudleigh Date: Fri, 19 Jul 2024 13:40:32 -0700 Subject: [PATCH 02/13] refactor move login code to LoginGov class --- Gemfile | 1 + Gemfile.lock | 9 ++ app/controllers/sessions_controller.rb | 32 +++---- app/models/login_gov.rb | 110 ++++++++++++++++++++++--- app/views/sessions/result.html.erb | 4 +- config/environments/development.rb | 2 - shell.nix | 2 +- 7 files changed, 124 insertions(+), 36 deletions(-) diff --git a/Gemfile b/Gemfile index 7302da5f..23d13d17 100644 --- a/Gemfile +++ b/Gemfile @@ -74,6 +74,7 @@ end group :test do # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] + gem "webmock" gem "capybara" gem "selenium-webdriver" gem 'rspec_junit_formatter' diff --git a/Gemfile.lock b/Gemfile.lock index 87fdd75b..61053b94 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -97,6 +97,9 @@ GEM simplecov (<= 0.13) concurrent-ruby (1.3.3) connection_pool (2.4.1) + crack (1.0.0) + bigdecimal + rexml crass (1.0.6) cssbundling-rails (1.4.0) railties (>= 6.0.0) @@ -119,6 +122,7 @@ GEM net-http globalid (1.2.1) activesupport (>= 6.1) + hashdiff (1.1.0) i18n (1.14.5) concurrent-ruby (~> 1.0) importmap-rails (2.0.1) @@ -308,6 +312,10 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webmock (3.23.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) webrick (1.8.1) websocket (1.2.11) websocket-driver (0.7.6) @@ -348,6 +356,7 @@ DEPENDENCIES turbo-rails tzinfo-data web-console + webmock RUBY VERSION ruby 3.2.2p53 diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 295f0a0d..89b2a20b 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,6 +1,6 @@ class SessionsController < ApplicationController - before_action :handle_result_error, :well_known_configuration, :decode_token, only: [:result] + before_action :check_result_error, :exchange_token, only: [:result] def new # TODO: handle redirect to login page due to inactivity @@ -25,36 +25,28 @@ def result private - def handle_result_error + def check_result_error if params[:error] Rails.logger.error("Login.gov authentication error: #{params[:error]}") flash[:error] = "There was an issue with logging in. Please try again." redirect_to new_session_path end - if params[:code].nil? && params[:state].nil? + if params[:code].nil? Rails.logger.error("Login.gov unknown error") flash[:error] = "Please try again." redirect_to new_session_path end end - def well_known_configuration - @login_gov = LoginGov.new - status, body = @login_gov.get_well_known_configuration - - if status != 200 - Rails.logger.error("well-known/openid-configuration error: code=#{status} - body:\n#{body}") - flash[:error] = "There was an issue logging in." - redirect_to new_session_path - return - end - - Rails.logger.debug("well_known_config response body=#{body}") - @openid_config = body - end - - def decode_token - private_key = @login_gov.private_key + # Authenticates a user with login.gov using JWT + def exchange_token + login_gov = LoginGov.new + @login_gov_user = login_gov.exchange_token_from_auth_result params[:code] + Rails.logger.debug("GOT userinfo=#{@login_gov_user}") + rescue LoginGov::LoginApiError => error + Rails.logger.error("LoginGov::LoginApiError(#{error.message}) status_code(#{error.status_code}) response_body:\n#{error.response_body}") + flash[:error] = "There was an issue logging in." + redirect_to new_session_path end end diff --git a/app/models/login_gov.rb b/app/models/login_gov.rb index 0c67c451..fab9ee75 100644 --- a/app/models/login_gov.rb +++ b/app/models/login_gov.rb @@ -1,29 +1,41 @@ class LoginGov + class LoginApiError < StandardError + attr_reader :status_code, :response_body + + def initialize(msg, code:, body:) + @status_code = code + @response_body = body + super(msg) + end + end + # :acr_value, # :client_id, # :idp_host, # :login_redirect_uri, # :logout_redirect_uri, - # :logout_uri, # :private_key_password, # :private_key_path, - # :token_endpoint, attr_reader :config def initialize(config = Rails.configuration.login_gov_oidc) @config = config.freeze.dup end + def client_id + config[:client_id] + end + def authorization_url query = { - client_id: config[:client_id], + client_id: client_id, response_type: "code", acr_values: config[:acr_value], scope: "openid email", redirect_uri: config[:login_redirect_uri], - state: SecureRandom.hex(16), - nonce: SecureRandom.hex(16), + state: random_value(), + nonce: random_value(), prompt: "select_account" }.to_query @@ -32,26 +44,102 @@ def authorization_url def logout_url query = { - client_id: config[:client_id], + client_id: client_id, post_logout_redirect_uri: config[:logout_redirect_uri] }.to_query - "#{config[:logout_uri]}?#{query}" + "#{config[:idp_host]}/openid_connect/logout?#{query}" end def well_known_configuration_url config[:idp_host] + "/.well-known/openid-configuration" end + def token_endpoint_url + config[:idp_host] + "/api/openid_connect/token" + end + + def exchange_token_from_auth_result code_param + # fetch the well-known configuration template data from login.gov + openid_config = get_well_known_configuration + puts "WELL KNOWN CONFIGURATION" + pp openid_config + + # read the private key from local file system + private_key = read_private_key + + # fetch the public_key from the well-known configuration jwks_uri + jwks_uri = openid_config.fetch("jwks_uri") + public_key = get_public_key(jwks_uri) + + # build the client assertion + claims = { + iss: client_id, + sub: client_id, + aud: token_endpoint_url, + jti: random_value(), + nonce: random_value(), + exp: DateTime.now.utc.to_i + 1000 + } + jwt_assertion = JWT.encode(claims, private_key, 'RS256') + jwks = JWT::JWK::Set.new(public_key) + + # send the assertion for validation against the code param and return the user_info + assertion_json = send_assertion(code_param, jwt_assertion) + id_token = assertion_json.fetch("id_token") + JWT.decode(id_token, nil, true, algorithms: ["RS256"], jwks: jwks) + end + def get_well_known_configuration response = Faraday.get(well_known_configuration_url) - [response.status, response.body] + if response.status != 200 + raise LoginApiError.new("well-known/openid-configuration error", code: response.status, body: response.body) + end + + JSON.parse(response.body) + end + + def get_public_key jwks_uri + response = Faraday.get(jwks_uri) + if response.status != 200 + raise LoginApiError.new("jwks_uri error", code: response.status, body: response.body) + end + + JSON.parse(response.body) end - def private_key + def read_private_key key_path = config[:private_key_path] key_pass = config[:private_key_password] - Rails.logger.info("Get private key from .pem file=#{key_path} pass=#{key_pass}") - "🔐" + decrypted_key = if key_pass.nil? + # private key is not password protected + OpenSSL::PKey.read File.read(key_path) + else + OpenSSL::PKey.read File.read(key_path), key_pass + end + + decrypted_key + end + + def send_assertion code, jwt_assertion + json_body = JSON.generate({ + grant_type: "authorization_code", + code: code, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: jwt_assertion + }) + + response = Faraday.post(token_endpoint_url, json_body, content_type: "application/json") + if response.status != 200 + raise LoginApiError.new("client assertion failed", code: response.status, body: response.body) + end + + JSON.parse(response.body) + end + + private + + def random_value + SecureRandom.hex(16) end end \ No newline at end of file diff --git a/app/views/sessions/result.html.erb b/app/views/sessions/result.html.erb index d15b313c..6ef1ecdf 100644 --- a/app/views/sessions/result.html.erb +++ b/app/views/sessions/result.html.erb @@ -1,2 +1,2 @@ -

Session#result

-

Find me in app/views/session/result.html.erb

+

Session Created!

+

Your login.gov session has been authenticated

diff --git a/config/environments/development.rb b/config/environments/development.rb index 498de12e..b4a06499 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -77,13 +77,11 @@ config.login_gov_oidc = { idp_host: "https://idp.int.identitysandbox.gov", login_redirect_uri: "http://localhost:3000/auth/result", - logout_uri: "https://idp.int.identitysandbox.gov/openid_connect/logout", logout_redirect_uri: "https://www.challenge.gov/", acr_value: "http://idmanagement.gov/ns/assurance/loa/1", client_id: "urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:challenge_gov_platform_dev", private_key_password: nil, private_key_path: "config/private.pem", public_key_path: "config/public.crt", - token_endpoint: "https://idp.int.identitysandbox.gov/api/openid_connect/token" } end diff --git a/shell.nix b/shell.nix index d35a5e3e..a93bb9f7 100644 --- a/shell.nix +++ b/shell.nix @@ -19,7 +19,7 @@ let pkgs.git pkgs.imagemagick - pkgs.postgresql + pkgs.postgresql_15 pkgs.redis pkgs.ruby_3_2 From 67cf73dbef01f94d5c3b4723ce5cb71ba6fc157d Mon Sep 17 00:00:00 2001 From: Stephen Chudleigh Date: Mon, 22 Jul 2024 17:02:11 -0700 Subject: [PATCH 03/13] add spec setup --- .../controllers/sessions_controller_test.rb | 0 spec/models/login_gov_spec.rb | 49 +++++++++++++++++++ spec/spec_helper.rb | 2 + 3 files changed, 51 insertions(+) rename {test => spec}/controllers/sessions_controller_test.rb (100%) create mode 100644 spec/models/login_gov_spec.rb diff --git a/test/controllers/sessions_controller_test.rb b/spec/controllers/sessions_controller_test.rb similarity index 100% rename from test/controllers/sessions_controller_test.rb rename to spec/controllers/sessions_controller_test.rb diff --git a/spec/models/login_gov_spec.rb b/spec/models/login_gov_spec.rb new file mode 100644 index 00000000..f8f3aa45 --- /dev/null +++ b/spec/models/login_gov_spec.rb @@ -0,0 +1,49 @@ +require 'rails_helper' + +RSpec.describe LoginGov do + let(:login_gov_config) do + { + idp_host: 'http://localhost:3003', + login_redirect_uri: "http://localhost:3000/auth/result", + logout_redirect_uri: "https://www.challenge.gov/", + acr_value: "http://idmanagement.gov/ns/assurance/loa/1", + client_id: "urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:challenge_gov_platform_dev", + private_key_password: nil, + private_key_path: "config/private.pem", + public_key_path: "config/public.crt", + } + end + let(:jwks_uri) { "#{host}/api/openid/certs" } + let(:end_session_endpoint) { "#{host}/openid/logout" } + + subject(:login_gov) { LoginGov.new(login_gov_config) } + + before do + stub_request(:get, login_gov.well_known_configuration_url). + to_return(body: { + authorization_endpoint: authorization_endpoint, + token_endpoint: token_endpoint, + jwks_uri: jwks_uri, + end_session_endpoint: end_session_endpoint, + }.to_json) + end + + describe '#exchange_token_from_auth_result' do + it 'raises error if request fails' do + stub_request(:get, login_gov.well_known_configuration_url). + to_return(body: '', status: 401) + + expect { LoginGov::OidcSinatra::OpenidConfiguration.live }. + to raise_error(LoginGov::OidcSinatra::AppError) + end + end + + describe '#cached' do + it 'does not make more than one HTTP request' do + oidc_config = LoginGov::OidcSinatra::OpenidConfiguration.cached + cached_oidc_config = LoginGov::OidcSinatra::OpenidConfiguration.cached + expect(oidc_config).to eq cached_oidc_config + expect(a_request(:get, configuration_uri)).to have_been_made.once + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9ef7f55e..f528642d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,4 +1,6 @@ require 'simplecov' +require 'webmock/rspec' + SimpleCov.command_name 'RSpec' # See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration From 385a4580b6e4ad0a56cc0b86685b0a87529e52e5 Mon Sep 17 00:00:00 2001 From: Stephen Chudleigh Date: Mon, 22 Jul 2024 19:55:34 -0700 Subject: [PATCH 04/13] add login_gov_spec --- app/models/login_gov.rb | 2 - spec/models/login_gov_spec.rb | 93 +++++++++++++++++++++++++++++------ 2 files changed, 78 insertions(+), 17 deletions(-) diff --git a/app/models/login_gov.rb b/app/models/login_gov.rb index fab9ee75..99e1e6ca 100644 --- a/app/models/login_gov.rb +++ b/app/models/login_gov.rb @@ -62,8 +62,6 @@ def token_endpoint_url def exchange_token_from_auth_result code_param # fetch the well-known configuration template data from login.gov openid_config = get_well_known_configuration - puts "WELL KNOWN CONFIGURATION" - pp openid_config # read the private key from local file system private_key = read_private_key diff --git a/spec/models/login_gov_spec.rb b/spec/models/login_gov_spec.rb index f8f3aa45..130778ba 100644 --- a/spec/models/login_gov_spec.rb +++ b/spec/models/login_gov_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' RSpec.describe LoginGov do + subject(:login_gov) { LoginGov.new(login_gov_config) } + let(:login_gov_config) do { idp_host: 'http://localhost:3003', @@ -13,16 +15,74 @@ public_key_path: "config/public.crt", } end - let(:jwks_uri) { "#{host}/api/openid/certs" } - let(:end_session_endpoint) { "#{host}/openid/logout" } - - subject(:login_gov) { LoginGov.new(login_gov_config) } + let(:jwks_uri) { login_gov_config[:idp_host] + "/api/openid_connect/certs" } + let(:end_session_endpoint) { login_gov_config[:idp_host] + "/openid_connect/logout" } + let(:code_param) { "ABC123" } + let(:public_key) do + {"keys": [{"alg":"RS256", "use":"sig", "kty":"RSA", "n":"a-key-here", "e":"AQAB", "kid":"a-kid-here"}]} + end + let(:private_key) do + key = <<~EOKEY + -----BEGIN PRIVATE KEY----- + MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDXqeVJEPMpNfPv + CAW6e1mL6UXaH3/1EyLD6G872+yucwV+Rm7ee5FSjM3y0O/u491mmNWtdTvJSNez + 2IiJ1MrnRXbbTJGAZXKai1D4EdTmeLbum4f5OBtNt+I6QgBV6IuFnJNLN7N5dwLC + 87bdOxyVtXMB/pM5lbzz3AYiaYqi0VBL73qnQsIQhx4YApADrfiDEJEWStPkk0XV + teCuh1TdzJieIswh1Vz7WbvoGAaQZIGJAGx4kLGw8EBH2p4Rlz92M9trYYTYkKTk + kz847JiIXpqDX13J6gPQBqRKy4znLXnG/NDkd+WkJtMu8M233OwVgzpHupuFMCpl + gF10ugchAgMBAAECggEAAM7E8BNpdFL+rvpdM36wOlZT46g0IsQOV/JhG/f4MXVY + 7exvg5q0YJjy07nXSgAbPsVRXwzl4Q16VSpJPso0G+sE78QySGPMm9eWyfIv2bca + TDADtNt+A+tFC9GRdAuUeZ+IQnBlAM0xDHGOyAnNw/kwF07CN8WHzkLiKZ1fZo9s + L8JdUxl8hLnwyx1JwCTXTy55VM3bFMQVdV9qH/m4bGYoB4ezKAfM+rLaLTAr2tpW + EzrMbqN6+EGu4uj2wCQsK56oaCatRVdtmwDugQQafh0WLkkqMx6pRzpBfUBUMDXW + S9nqLJIm01z28W6jYPkoMfarYNj80YtQ5c/Ug7JAEQKBgQDe8SAvdIr+1nbA6/kQ + snDl8Lv1uZ8Q+9WLrsR5cWnV+HshDNIlLZBTsNAO+rPKbnAAM5rJfE9EArI/NHzo + rONY7g3PLmxI2OWCsoQSd7vhsYa/8To5ui+3xq45YGzEbzryDFtz7hKu7hqitqh1 + rBFgAeqPkGn+KKJWWyw+VNxq8QKBgQD3pHzjNHq8rE1VMTOs6BHHxPAUKPBI0zay + x3Q7k+62xGpO/vyFs/lxgUMsQdZzXD8PKAKw/yd6vY7oxJEw+qhfMpSEaJKGu3P2 + RhkpjMI77g76UeIpH3zDGRJ7/LrY7KrWuydCBkvEo6hTA40hoO2wxwMKJDfL0xBK + OgaHnTx/MQKBgQCYtJ0RJEjYyVnKR1fwoelG9yAn7h8QaQ8agHk/nfmagHsGZlvC + 73S+fovk1sAz1nWNDcvmWumIcjhZpsAwN8v57AU1dlzhgP+kCFcCt1TQAOOFsdvq + EqgAv2wzDOMzoeTESsaRn+7YN2uzLF4zS8sS8f0SnR6c4oRflk+12jaoYQKBgEDP + y9+q3HSEo7ioJ94Y3o5p/GtKS5jDro0bpk/xZ4ht32TNV0mm0KHkMrBiir2mZtqQ + niO0o6B7++rvhxBKicZgdn4w4Chi5vaNYgh9zlfg9gqNY6Nfmkd1SGEqw7wCNLP+ + R0gAXdQZAPS4+TbT52FctG7zC6dMlfbXON5FSJABAoGAcCzhCkP19Sh7hs7tlqU1 + is7X2LBTs0CBkeeYFIYxiC4CpIGCLh9DcyOLGoAUdq8JtgXswzMiVG5I6dMXOaRc + wFJMAMO1uF/Re/5d/5Ne0ZsD47WuJQRrIgMcwAg8JZsTNZYI/iULYUih01dDIqlH + OlpC0cZTHY05PpP7uX4gq1k= + -----END PRIVATE KEY----- + EOKEY + OpenSSL::PKey.read key + end + let(:id_token) { "my-token-id" } + let(:user_info) do + [ + { + "sub"=>"sub", + "iss"=>"https://idp.int.identitysandbox.gov/", + "email"=>"test@example.gov", + "email_verified"=>true, + "ial"=>"http://idmanagement.gov/ns/assurance/ial/1", + "aal"=>"urn:gov:gsa:ac:classes:sp:PasswordProtectedTransport:duo", + "nonce"=>"nonce", + "aud"=>"urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:challenge_gov_platform_dev", + "jti"=>"jti", + "at_hash"=>"hash-EQg7x1gw", + "c_hash"=>"hash-KnQ", + "acr"=>"http://idmanagement.gov/ns/assurance/ial/1", + "exp"=>1721696128, + "iat"=>1721695228, + "nbf"=>1721695228 + }, { + "kid"=>"abc10930230928df", + "alg"=>"RS256" + } + ] + end before do stub_request(:get, login_gov.well_known_configuration_url). to_return(body: { - authorization_endpoint: authorization_endpoint, - token_endpoint: token_endpoint, jwks_uri: jwks_uri, end_session_endpoint: end_session_endpoint, }.to_json) @@ -33,17 +93,20 @@ stub_request(:get, login_gov.well_known_configuration_url). to_return(body: '', status: 401) - expect { LoginGov::OidcSinatra::OpenidConfiguration.live }. - to raise_error(LoginGov::OidcSinatra::AppError) + expect { login_gov.exchange_token_from_auth_result code_param }. + to raise_error(LoginGov::LoginApiError) end - end - describe '#cached' do - it 'does not make more than one HTTP request' do - oidc_config = LoginGov::OidcSinatra::OpenidConfiguration.cached - cached_oidc_config = LoginGov::OidcSinatra::OpenidConfiguration.cached - expect(oidc_config).to eq cached_oidc_config - expect(a_request(:get, configuration_uri)).to have_been_made.once + it 'returns userinfo on success' do + # mock the encryption and http requests + jwks = double("jwks double") + expect(login_gov).to receive(:get_public_key).with(jwks_uri) { public_key } + expect(login_gov).to receive(:read_private_key) { private_key } + expect(JWT::JWK::Set).to receive(:new).with(public_key) { jwks } + stub_request(:post, login_gov.token_endpoint_url).to_return(status: 200, body: "{\"id_token\": \"#{id_token}\"}", headers: {}) + expect(JWT).to receive(:decode).with(id_token, nil, true, algorithms: ["RS256"], jwks: jwks).and_return(user_info) + actual = login_gov.exchange_token_from_auth_result code_param + expect(actual).to eq(user_info) end end end From 4f626fd4453f871163ee9f4bf011366b4edfca2e Mon Sep 17 00:00:00 2001 From: Stephen Chudleigh Date: Tue, 23 Jul 2024 08:29:37 -0700 Subject: [PATCH 05/13] session controller --- app/controllers/sessions_controller.rb | 7 ++++--- app/helpers/sessions_helper.rb | 2 -- 2 files changed, 4 insertions(+), 5 deletions(-) delete mode 100644 app/helpers/sessions_helper.rb diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 89b2a20b..ca4961af 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -20,7 +20,8 @@ def delete end def result - + # TODO: store the user_info in the session + # session[:user_info] = @login_userinfo end private @@ -42,8 +43,8 @@ def check_result_error # Authenticates a user with login.gov using JWT def exchange_token login_gov = LoginGov.new - @login_gov_user = login_gov.exchange_token_from_auth_result params[:code] - Rails.logger.debug("GOT userinfo=#{@login_gov_user}") + @login_userinfo = login_gov.exchange_token_from_auth_result params[:code] + Rails.logger.debug("GOT userinfo=#{@login_userinfo}") rescue LoginGov::LoginApiError => error Rails.logger.error("LoginGov::LoginApiError(#{error.message}) status_code(#{error.status_code}) response_body:\n#{error.response_body}") flash[:error] = "There was an issue logging in." diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb deleted file mode 100644 index 309f8b2e..00000000 --- a/app/helpers/sessions_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module SessionsHelper -end From 38a23384d2f2f608ed0120552ed55514c60a4dd9 Mon Sep 17 00:00:00 2001 From: Stephen Chudleigh Date: Tue, 23 Jul 2024 11:13:18 -0700 Subject: [PATCH 06/13] add session request spec --- Gemfile | 7 ++-- Gemfile.lock | 5 +++ config/environments/test.rb | 11 ++++++ spec/controllers/sessions_controller_test.rb | 23 ------------ spec/models/login_gov_spec.rb | 18 ++------- spec/rails_helper.rb | 2 + spec/requests/sessions_request_spec.rb | 39 ++++++++++++++++++++ 7 files changed, 64 insertions(+), 41 deletions(-) delete mode 100644 spec/controllers/sessions_controller_test.rb create mode 100644 spec/requests/sessions_request_spec.rb diff --git a/Gemfile b/Gemfile index 23d13d17..b291342d 100644 --- a/Gemfile +++ b/Gemfile @@ -58,7 +58,7 @@ group :development, :test do gem "rubocop" gem "rspec-rails" - gem 'codeclimate-test-reporter' + gem "codeclimate-test-reporter" end group :development do @@ -77,8 +77,9 @@ group :test do gem "webmock" gem "capybara" gem "selenium-webdriver" - gem 'rspec_junit_formatter' - gem 'simplecov' + gem "rspec_junit_formatter" + gem "simplecov" + gem "rails-controller-testing" end gem "cssbundling-rails", "~> 1.4" diff --git a/Gemfile.lock b/Gemfile.lock index 61053b94..dedb3cec 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -208,6 +208,10 @@ GEM activesupport (= 7.1.3.4) bundler (>= 1.15.0) railties (= 7.1.3.4) + rails-controller-testing (1.0.5) + actionpack (>= 5.0.1.rc1) + actionview (>= 5.0.1.rc1) + activesupport (>= 5.0.1.rc1) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -345,6 +349,7 @@ DEPENDENCIES pg puma (>= 5.0) rails (~> 7.1.3, >= 7.1.3.4) + rails-controller-testing rspec-rails rspec_junit_formatter rubocop diff --git a/config/environments/test.rb b/config/environments/test.rb index adbb4a6f..9fe139bb 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -61,4 +61,15 @@ # Raise error when a before_action's only/except options reference missing actions config.action_controller.raise_on_missing_callback_actions = true + + config.login_gov_oidc = { + idp_host: 'http://localhost:3003', + login_redirect_uri: "http://localhost:3000/auth/result", + logout_redirect_uri: "https://www.challenge.gov/", + acr_value: "http://idmanagement.gov/ns/assurance/loa/1", + client_id: "urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:challenge_gov_platform_dev", + private_key_password: nil, + private_key_path: "config/private.pem", + public_key_path: "config/public.crt", + } end diff --git a/spec/controllers/sessions_controller_test.rb b/spec/controllers/sessions_controller_test.rb deleted file mode 100644 index 3e7ff661..00000000 --- a/spec/controllers/sessions_controller_test.rb +++ /dev/null @@ -1,23 +0,0 @@ -require "test_helper" - -class SessionsControllerTest < ActionDispatch::IntegrationTest - test "should get new" do - get session_new_url - assert_response :success - end - - test "should get create" do - post session_create_url - assert_response :success - end - - test "should get delete" do - get session_delete_url - assert_response :success - end - - test "should get result" do - get session_result_url - assert_response :success - end -end diff --git a/spec/models/login_gov_spec.rb b/spec/models/login_gov_spec.rb index 130778ba..24b9f6eb 100644 --- a/spec/models/login_gov_spec.rb +++ b/spec/models/login_gov_spec.rb @@ -1,22 +1,10 @@ require 'rails_helper' RSpec.describe LoginGov do - subject(:login_gov) { LoginGov.new(login_gov_config) } + subject(:login_gov) { LoginGov.new } - let(:login_gov_config) do - { - idp_host: 'http://localhost:3003', - login_redirect_uri: "http://localhost:3000/auth/result", - logout_redirect_uri: "https://www.challenge.gov/", - acr_value: "http://idmanagement.gov/ns/assurance/loa/1", - client_id: "urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:challenge_gov_platform_dev", - private_key_password: nil, - private_key_path: "config/private.pem", - public_key_path: "config/public.crt", - } - end - let(:jwks_uri) { login_gov_config[:idp_host] + "/api/openid_connect/certs" } - let(:end_session_endpoint) { login_gov_config[:idp_host] + "/openid_connect/logout" } + let(:jwks_uri) { login_gov.config[:idp_host] + "/api/openid_connect/certs" } + let(:end_session_endpoint) { login_gov.config[:idp_host] + "/openid_connect/logout" } let(:code_param) { "ABC123" } let(:public_key) do {"keys": [{"alg":"RS256", "use":"sig", "kty":"RSA", "n":"a-key-here", "e":"AQAB", "kid":"a-kid-here"}]} diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index a15455f3..8f051a69 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -35,6 +35,8 @@ Rails.root.join('spec/fixtures') ] + # config.include Rails.application.routes.url_helpers + # If you're not using ActiveRecord, or you'd prefer not to run each of your # examples within a transaction, remove the following line or assign false # instead of true. diff --git a/spec/requests/sessions_request_spec.rb b/spec/requests/sessions_request_spec.rb new file mode 100644 index 00000000..3fe22957 --- /dev/null +++ b/spec/requests/sessions_request_spec.rb @@ -0,0 +1,39 @@ +require "rails_helper" + +RSpec.describe SessionsController, type: :request do + it "get new renders successfully" do + get "/session/new" + expect(response).to render_template(:new) + end + + it "post create redirects to login.gov" do + auth_host, _auth_query = LoginGov.new.authorization_url.split("?") + post "/session" + expect(response).to redirect_to(%r{\A#{auth_host}}) + end + + it "delete session logs the user out" do + skip "not implemented" + delete "/session/:id" + assert_response :success + end + + it "get /auth/result without params redirects to login" do + get "/auth/result" + expect(response).to redirect_to("/session/new") + end + + it "get /auth/result with error param redirects to login" do + get "/auth/result", params: { error: "there was an error" } + expect(response).to redirect_to("/session/new") + expect(flash[:error]).to include("Please try again.") + end + + it "get /auth/result successful" do + code = "ABC123" + login_gov = instance_double(LoginGov) + allow(LoginGov).to receive(:new).and_return(login_gov) + allow(login_gov).to receive(:exchange_token_from_auth_result).with(code).and_return({ email: "test@example.com" }) + get "/auth/result", params: { code: } + end +end From 8a323f710d07981bc4e648dcd609bcff71176fd1 Mon Sep 17 00:00:00 2001 From: Stephen Chudleigh Date: Tue, 23 Jul 2024 11:22:47 -0700 Subject: [PATCH 07/13] add rubocop gems for vscode, fix styling --- Gemfile | 12 +++- Gemfile.lock | 26 +++++++++ app/controllers/sessions_controller.rb | 41 +++++++------ app/models/login_gov.rb | 80 ++++++++++++++------------ config/locales/en.yml | 2 + spec/models/login_gov_spec.rb | 62 ++++++++++---------- spec/requests/sessions_request_spec.rb | 5 +- 7 files changed, 141 insertions(+), 87 deletions(-) diff --git a/Gemfile b/Gemfile index b291342d..3c41bebf 100644 --- a/Gemfile +++ b/Gemfile @@ -56,8 +56,18 @@ group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[mri windows] - gem "rubocop" gem "rspec-rails" + + # add the Ruby LSP package so it's bundled with the rest of the gems and available to VS Code + gem "ruby-lsp" + + # rubocop and specific extensions used by VS Code + gem "rubocop" + gem "rubocop-performance", require: false + gem "rubocop-rake", require: false + gem "rubocop-rails", require: false + gem "rubocop-rspec", require: false + gem "codeclimate-test-reporter" end diff --git a/Gemfile.lock b/Gemfile.lock index dedb3cec..3d26cb06 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -180,6 +180,7 @@ GEM ast (~> 2.4.1) racc pg (1.5.6) + prism (0.30.0) psych (5.1.2) stringio public_suffix (6.0.0) @@ -229,6 +230,8 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) + rbs (3.5.2) + logger rdoc (6.7.0) psych (>= 4.0.0) regexp_parser (2.9.2) @@ -268,6 +271,23 @@ GEM unicode-display_width (>= 2.4.0, < 3.0) rubocop-ast (1.31.3) parser (>= 3.3.1.0) + rubocop-performance (1.21.1) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rails (2.25.1) + activesupport (>= 4.2.0) + rack (>= 1.1) + rubocop (>= 1.33.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rake (0.6.0) + rubocop (~> 1.0) + rubocop-rspec (3.0.3) + rubocop (~> 1.61) + ruby-lsp (0.17.9) + language_server-protocol (~> 3.17.0) + prism (>= 0.29.0, < 0.31) + rbs (>= 3, < 4) + sorbet-runtime (>= 0.5.10782) ruby-progressbar (1.13.0) rubyzip (2.3.2) sassc (2.4.0) @@ -289,6 +309,7 @@ GEM json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.2) + sorbet-runtime (0.5.11492) sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) @@ -353,6 +374,11 @@ DEPENDENCIES rspec-rails rspec_junit_formatter rubocop + rubocop-performance + rubocop-rails + rubocop-rake + rubocop-rspec + ruby-lsp sassc-rails selenium-webdriver simplecov diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index ca4961af..8795a792 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,6 +1,7 @@ -class SessionsController < ApplicationController +# frozen_string_literal: true - before_action :check_result_error, :exchange_token, only: [:result] +class SessionsController < ApplicationController + before_action :check_error_result, :require_code_param, :exchange_token, only: [:result] def new # TODO: handle redirect to login page due to inactivity @@ -26,28 +27,32 @@ def result private - def check_result_error - if params[:error] - Rails.logger.error("Login.gov authentication error: #{params[:error]}") - flash[:error] = "There was an issue with logging in. Please try again." - redirect_to new_session_path - end + def check_error_result + return unless params[:error] - if params[:code].nil? - Rails.logger.error("Login.gov unknown error") - flash[:error] = "Please try again." - redirect_to new_session_path - end + Rails.logger.error("Login.gov authentication error: #{params[:error]}") + flash[:error] = t("login_error") + redirect_to new_session_path + end + + def require_code_param + return if params[:code].present? + + Rails.logger.error("Login.gov unknown error") + flash[:error] = t("please_try_again") + redirect_to new_session_path end # Authenticates a user with login.gov using JWT def exchange_token login_gov = LoginGov.new - @login_userinfo = login_gov.exchange_token_from_auth_result params[:code] - Rails.logger.debug("GOT userinfo=#{@login_userinfo}") - rescue LoginGov::LoginApiError => error - Rails.logger.error("LoginGov::LoginApiError(#{error.message}) status_code(#{error.status_code}) response_body:\n#{error.response_body}") - flash[:error] = "There was an issue logging in." + @login_userinfo = login_gov.exchange_token_from_auth_result(params[:code]) + Rails.logger.debug do + "userinfo=#{@login_userinfo}" + end + rescue LoginGov::LoginApiError => e + Rails.logger.error("LoginGov::LoginApiError(#{e.message}) status(#{e.status_code}):\n#{e.response_body}") + flash[:error] = t("login_error") redirect_to new_session_path end end diff --git a/app/models/login_gov.rb b/app/models/login_gov.rb index 99e1e6ca..7d4e398e 100644 --- a/app/models/login_gov.rb +++ b/app/models/login_gov.rb @@ -1,5 +1,6 @@ -class LoginGov +# frozen_string_literal: true +class LoginGov class LoginApiError < StandardError attr_reader :status_code, :response_body @@ -29,22 +30,22 @@ def client_id def authorization_url query = { - client_id: client_id, + client_id:, response_type: "code", acr_values: config[:acr_value], scope: "openid email", redirect_uri: config[:login_redirect_uri], - state: random_value(), - nonce: random_value(), + state: random_value, + nonce: random_value, prompt: "select_account" }.to_query "#{config[:idp_host]}/openid_connect/authorize?#{query}" - end + end def logout_url query = { - client_id: client_id, + client_id:, post_logout_redirect_uri: config[:logout_redirect_uri] }.to_query @@ -52,16 +53,16 @@ def logout_url end def well_known_configuration_url - config[:idp_host] + "/.well-known/openid-configuration" + "#{config[:idp_host]}/.well-known/openid-configuration" end def token_endpoint_url - config[:idp_host] + "/api/openid_connect/token" + "#{config[:idp_host]}/api/openid_connect/token" end - def exchange_token_from_auth_result code_param + def exchange_token_from_auth_result(code_param) # fetch the well-known configuration template data from login.gov - openid_config = get_well_known_configuration + openid_config = well_known_configuration # read the private key from local file system private_key = read_private_key @@ -69,27 +70,19 @@ def exchange_token_from_auth_result code_param # fetch the public_key from the well-known configuration jwks_uri jwks_uri = openid_config.fetch("jwks_uri") public_key = get_public_key(jwks_uri) - + # build the client assertion - claims = { - iss: client_id, - sub: client_id, - aud: token_endpoint_url, - jti: random_value(), - nonce: random_value(), - exp: DateTime.now.utc.to_i + 1000 - } - jwt_assertion = JWT.encode(claims, private_key, 'RS256') + jwt_assertion = JWT.encode(assertion_claims, private_key, 'RS256') jwks = JWT::JWK::Set.new(public_key) # send the assertion for validation against the code param and return the user_info assertion_json = send_assertion(code_param, jwt_assertion) id_token = assertion_json.fetch("id_token") - JWT.decode(id_token, nil, true, algorithms: ["RS256"], jwks: jwks) + JWT.decode(id_token, nil, true, algorithms: ["RS256"], jwks:) end - def get_well_known_configuration - response = Faraday.get(well_known_configuration_url) + def well_known_configuration + response = Faraday.get(well_known_configuration_url) if response.status != 200 raise LoginApiError.new("well-known/openid-configuration error", code: response.status, body: response.body) end @@ -97,7 +90,7 @@ def get_well_known_configuration JSON.parse(response.body) end - def get_public_key jwks_uri + def get_public_key(jwks_uri) response = Faraday.get(jwks_uri) if response.status != 200 raise LoginApiError.new("jwks_uri error", code: response.status, body: response.body) @@ -109,24 +102,37 @@ def get_public_key jwks_uri def read_private_key key_path = config[:private_key_path] key_pass = config[:private_key_password] - decrypted_key = if key_pass.nil? + + # decrypt the key from the file + if key_pass.nil? # private key is not password protected - OpenSSL::PKey.read File.read(key_path) + OpenSSL::PKey.read(File.read(key_path)) else - OpenSSL::PKey.read File.read(key_path), key_pass + OpenSSL::PKey.read(File.read(key_path), key_pass) end + end - decrypted_key + def assertion_claims + { + iss: client_id, + sub: client_id, + aud: token_endpoint_url, + jti: random_value, + nonce: random_value, + exp: DateTime.now.utc.to_i + 1000 + } end - def send_assertion code, jwt_assertion - json_body = JSON.generate({ - grant_type: "authorization_code", - code: code, - client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - client_assertion: jwt_assertion - }) - + def send_assertion(code, jwt_assertion) + json_body = JSON.generate( + { + grant_type: "authorization_code", + code:, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: jwt_assertion + } + ) + response = Faraday.post(token_endpoint_url, json_body, content_type: "application/json") if response.status != 200 raise LoginApiError.new("client assertion failed", code: response.status, body: response.body) @@ -140,4 +146,4 @@ def send_assertion code, jwt_assertion def random_value SecureRandom.hex(16) end -end \ No newline at end of file +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 6c349ae5..e0a9572e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -29,3 +29,5 @@ en: hello: "Hello world" + please_try_again: "Please try again." + login_error: "There was an issue with logging in. Please try again." diff --git a/spec/models/login_gov_spec.rb b/spec/models/login_gov_spec.rb index 24b9f6eb..bebf7d07 100644 --- a/spec/models/login_gov_spec.rb +++ b/spec/models/login_gov_spec.rb @@ -1,13 +1,13 @@ require 'rails_helper' RSpec.describe LoginGov do - subject(:login_gov) { LoginGov.new } + subject(:login_gov) { described_class.new } - let(:jwks_uri) { login_gov.config[:idp_host] + "/api/openid_connect/certs" } - let(:end_session_endpoint) { login_gov.config[:idp_host] + "/openid_connect/logout" } + let(:jwks_uri) { "#{login_gov.config[:idp_host]}/api/openid_connect/certs" } + let(:end_session_endpoint) { "#{login_gov.config[:idp_host]}/openid_connect/logout" } let(:code_param) { "ABC123" } let(:public_key) do - {"keys": [{"alg":"RS256", "use":"sig", "kty":"RSA", "n":"a-key-here", "e":"AQAB", "kid":"a-kid-here"}]} + { keys: [{ alg: "RS256", use: "sig", kty: "RSA", n: "a-key-here", e: "AQAB", kid: "a-kid-here" }] } end let(:private_key) do key = <<~EOKEY @@ -46,24 +46,24 @@ let(:user_info) do [ { - "sub"=>"sub", - "iss"=>"https://idp.int.identitysandbox.gov/", - "email"=>"test@example.gov", - "email_verified"=>true, - "ial"=>"http://idmanagement.gov/ns/assurance/ial/1", - "aal"=>"urn:gov:gsa:ac:classes:sp:PasswordProtectedTransport:duo", - "nonce"=>"nonce", - "aud"=>"urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:challenge_gov_platform_dev", - "jti"=>"jti", - "at_hash"=>"hash-EQg7x1gw", - "c_hash"=>"hash-KnQ", - "acr"=>"http://idmanagement.gov/ns/assurance/ial/1", - "exp"=>1721696128, - "iat"=>1721695228, - "nbf"=>1721695228 + "sub" => "sub", + "iss" => "https://idp.int.identitysandbox.gov/", + "email" => "test@example.gov", + "email_verified" => true, + "ial" => "http://idmanagement.gov/ns/assurance/ial/1", + "aal" => "urn:gov:gsa:ac:classes:sp:PasswordProtectedTransport:duo", + "nonce" => "nonce", + "aud" => "urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:challenge_gov_platform_dev", + "jti" => "jti", + "at_hash" => "hash-EQg7x1gw", + "c_hash" => "hash-KnQ", + "acr" => "http://idmanagement.gov/ns/assurance/ial/1", + "exp" => 1_721_696_128, + "iat" => 1_721_695_228, + "nbf" => 1_721_695_228 }, { - "kid"=>"abc10930230928df", - "alg"=>"RS256" + "kid" => "abc10930230928df", + "alg" => "RS256" } ] end @@ -71,8 +71,8 @@ before do stub_request(:get, login_gov.well_known_configuration_url). to_return(body: { - jwks_uri: jwks_uri, - end_session_endpoint: end_session_endpoint, + jwks_uri:, + end_session_endpoint: }.to_json) end @@ -87,12 +87,16 @@ it 'returns userinfo on success' do # mock the encryption and http requests - jwks = double("jwks double") - expect(login_gov).to receive(:get_public_key).with(jwks_uri) { public_key } - expect(login_gov).to receive(:read_private_key) { private_key } - expect(JWT::JWK::Set).to receive(:new).with(public_key) { jwks } - stub_request(:post, login_gov.token_endpoint_url).to_return(status: 200, body: "{\"id_token\": \"#{id_token}\"}", headers: {}) - expect(JWT).to receive(:decode).with(id_token, nil, true, algorithms: ["RS256"], jwks: jwks).and_return(user_info) + jwks = class_double(JWT::JWK::Set, new: "jwks double") + allow(login_gov).to receive(:get_public_key).with(jwks_uri) { public_key } # rubocop:disable RSpec/SubjectStub + allow(login_gov).to receive(:read_private_key) { private_key } # rubocop:disable RSpec/SubjectStub + allow(JWT::JWK::Set).to receive(:new).with(public_key) { jwks } + stub_request(:post, login_gov.token_endpoint_url).to_return( + status: 200, + body: "{\"id_token\": \"#{id_token}\"}", + headers: {} + ) + allow(JWT).to receive(:decode).with(id_token, nil, true, algorithms: ["RS256"], jwks:).and_return(user_info) actual = login_gov.exchange_token_from_auth_result code_param expect(actual).to eq(user_info) end diff --git a/spec/requests/sessions_request_spec.rb b/spec/requests/sessions_request_spec.rb index 3fe22957..50b55f45 100644 --- a/spec/requests/sessions_request_spec.rb +++ b/spec/requests/sessions_request_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -RSpec.describe SessionsController, type: :request do +RSpec.describe "SessionsController" do it "get new renders successfully" do get "/session/new" expect(response).to render_template(:new) @@ -9,7 +9,7 @@ it "post create redirects to login.gov" do auth_host, _auth_query = LoginGov.new.authorization_url.split("?") post "/session" - expect(response).to redirect_to(%r{\A#{auth_host}}) + expect(response).to redirect_to(/\A#{auth_host}/) end it "delete session logs the user out" do @@ -21,6 +21,7 @@ it "get /auth/result without params redirects to login" do get "/auth/result" expect(response).to redirect_to("/session/new") + expect(flash[:error]).to include("Please try again.") end it "get /auth/result with error param redirects to login" do From 7e5753549b193c807c4172cddb5b539bf92e8b6e Mon Sep 17 00:00:00 2001 From: Stephen Chudleigh Date: Wed, 24 Jul 2024 10:51:55 -0700 Subject: [PATCH 08/13] ignore login keys --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 91e4b1da..22a4cd72 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,8 @@ # Ignore master key for decrypting credentials and more. /config/master.key +/config/private.pem +/config/public.crt /app/assets/builds/* !/app/assets/builds/.keep From 7832247a2475aecca260ae8d134bb85a9def96ae Mon Sep 17 00:00:00 2001 From: Stephen Chudleigh Date: Wed, 24 Jul 2024 15:00:43 -0700 Subject: [PATCH 09/13] Gemfile.lock --- Gemfile.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 3d26cb06..b8843721 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -111,15 +111,15 @@ GEM docile (1.1.5) drb (2.2.1) erubi (1.13.0) - ffi (1.17.0-aarch64-linux-gnu) - ffi (1.17.0-arm64-darwin) - ffi (1.17.0-x86_64-darwin) - ffi (1.17.0-x86_64-linux-gnu) faraday (2.10.0) faraday-net_http (>= 2.0, < 3.2) logger faraday-net_http (3.1.0) net-http + ffi (1.17.0-aarch64-linux-gnu) + ffi (1.17.0-arm64-darwin) + ffi (1.17.0-x86_64-darwin) + ffi (1.17.0-x86_64-linux-gnu) globalid (1.2.1) activesupport (>= 6.1) hashdiff (1.1.0) From c720691894ea6da76bb852ed592cba8ee64d271d Mon Sep 17 00:00:00 2001 From: Stephen Chudleigh Date: Wed, 24 Jul 2024 15:47:48 -0700 Subject: [PATCH 10/13] add spec expectation --- spec/requests/sessions_request_spec.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spec/requests/sessions_request_spec.rb b/spec/requests/sessions_request_spec.rb index 50b55f45..0e752824 100644 --- a/spec/requests/sessions_request_spec.rb +++ b/spec/requests/sessions_request_spec.rb @@ -36,5 +36,7 @@ allow(LoginGov).to receive(:new).and_return(login_gov) allow(login_gov).to receive(:exchange_token_from_auth_result).with(code).and_return({ email: "test@example.com" }) get "/auth/result", params: { code: } + expect(response).to have_http_status(:ok) + expect(response).to render_template(:result) end end From 98dca2430a2b828fcbb09657065efcce04743309 Mon Sep 17 00:00:00 2001 From: Stephen Chudleigh Date: Tue, 30 Jul 2024 20:15:32 -0700 Subject: [PATCH 11/13] reorder dep --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 4c33a245..094caef4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -353,10 +353,10 @@ DEPENDENCIES cssbundling-rails (~> 1.4) debug faraday - jwt foreman jbuilder jsbundling-rails (~> 1.3) + jwt pg propshaft (~> 0.9.0) puma (>= 5.0) From e2df25a7d82cf6a0fc941d8f975c14d81c7b745e Mon Sep 17 00:00:00 2001 From: Stephen Chudleigh Date: Wed, 31 Jul 2024 07:28:19 -0700 Subject: [PATCH 12/13] add asset build step for rspec --- .circleci/config.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 3a764e10..7f158c5d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -24,6 +24,15 @@ commands: description: 'Prepare the test database' steps: - run: bundle exec rake db:setup + build_assets: + description: 'Install yarn modules and build assets' + steps: + - node/install: + install-yarn: true + - node/install-packages: + pkg-manager: yarn + - run: npx gulp copyAssets + - run: npx gulp compile jobs: checkout_code: @@ -59,6 +68,8 @@ jobs: - prepare_database + - build_assets + - run: name: Run Tests command: | From 2115885f32f9287aa30aceb18f929474701b2678 Mon Sep 17 00:00:00 2001 From: Stephen Chudleigh Date: Wed, 31 Jul 2024 07:35:11 -0700 Subject: [PATCH 13/13] add yarn build step --- .circleci/config.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7f158c5d..e17b7670 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -24,6 +24,7 @@ commands: description: 'Prepare the test database' steps: - run: bundle exec rake db:setup + build_assets: description: 'Install yarn modules and build assets' steps: @@ -33,6 +34,8 @@ commands: pkg-manager: yarn - run: npx gulp copyAssets - run: npx gulp compile + - run: yarn build + - run: yarn build:css jobs: checkout_code: