diff --git a/.circleci/config.yml b/.circleci/config.yml index 3a764e10..e17b7670 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -25,6 +25,18 @@ commands: 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 + - run: yarn build + - run: yarn build:css + jobs: checkout_code: executor: @@ -59,6 +71,8 @@ jobs: - prepare_database + - build_assets + - run: name: Run Tests command: | 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 diff --git a/Gemfile b/Gemfile index 53270a54..80828a89 100644 --- a/Gemfile +++ b/Gemfile @@ -13,6 +13,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 simple asset pipeline gem "propshaft", "~> 0.9.0" gem "cssbundling-rails", "~> 1.4" @@ -49,8 +55,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 @@ -70,8 +86,10 @@ 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" gem "simplecov" + gem "rails-controller-testing" end diff --git a/Gemfile.lock b/Gemfile.lock index 1b08788b..094caef4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -100,6 +100,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.1) railties (>= 6.0.0) @@ -111,9 +114,15 @@ GEM docile (1.1.5) drb (2.2.1) erubi (1.13.0) + faraday (2.10.0) + faraday-net_http (>= 2.0, < 3.2) + logger + faraday-net_http (3.1.0) + net-http foreman (0.88.1) globalid (1.2.1) activesupport (>= 6.1) + hashdiff (1.1.0) i18n (1.14.5) concurrent-ruby (~> 1.0) io-console (0.7.2) @@ -126,6 +135,8 @@ GEM jsbundling-rails (1.3.1) railties (>= 6.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) @@ -142,6 +153,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 @@ -165,6 +178,7 @@ GEM ast (~> 2.4.1) racc pg (1.5.6) + prism (0.30.0) propshaft (0.9.0) actionpack (>= 7.0.0) activesupport (>= 7.0.0) @@ -198,6 +212,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 @@ -215,6 +233,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) @@ -254,6 +274,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) selenium-webdriver (4.23.0) @@ -267,6 +304,7 @@ GEM json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.2) + sorbet-runtime (0.5.11492) stimulus-rails (1.3.3) railties (>= 6.0.0) stringio (3.1.1) @@ -280,11 +318,16 @@ 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) 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) @@ -309,22 +352,31 @@ DEPENDENCIES codeclimate-test-reporter cssbundling-rails (~> 1.4) debug + faraday foreman jbuilder jsbundling-rails (~> 1.3) + jwt pg propshaft (~> 0.9.0) puma (>= 5.0) rails (~> 7.1.3, >= 7.1.3.4) + rails-controller-testing rspec-rails rspec_junit_formatter rubocop + rubocop-performance + rubocop-rails + rubocop-rake + rubocop-rspec + ruby-lsp selenium-webdriver simplecov stimulus-rails turbo-rails tzinfo-data web-console + webmock RUBY VERSION ruby 3.2.4p170 diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 00000000..8795a792 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +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 + 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 + # TODO: store the user_info in the session + # session[:user_info] = @login_userinfo + end + + private + + def check_error_result + return unless params[:error] + + 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 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 new file mode 100644 index 00000000..7d4e398e --- /dev/null +++ b/app/models/login_gov.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +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, + # :private_key_password, + # :private_key_path, + 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:, + response_type: "code", + acr_values: config[:acr_value], + scope: "openid email", + redirect_uri: config[:login_redirect_uri], + state: random_value, + nonce: random_value, + prompt: "select_account" + }.to_query + + "#{config[:idp_host]}/openid_connect/authorize?#{query}" + end + + def logout_url + query = { + client_id:, + post_logout_redirect_uri: config[:logout_redirect_uri] + }.to_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 = well_known_configuration + + # 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 + 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:) + end + + 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 + + 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 read_private_key + key_path = config[:private_key_path] + key_pass = config[:private_key_password] + + # decrypt the key from the file + 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 + end + + 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:, + 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 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 @@ +
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 @@ +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 @@ ++ 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") %> +Your login.gov session has been authenticated
diff --git a/config/environments/development.rb b/config/environments/development.rb index a10ac8b1..4ffc19a7 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -75,4 +75,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: "https://idp.int.identitysandbox.gov", + 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/config/environments/test.rb b/config/environments/test.rb index 8a33329b..4b491ee0 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -63,4 +63,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/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/config/routes.rb b/config/routes.rb index 7e495288..12c114e8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,6 +2,8 @@ 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/shell.nix b/shell.nix index 8aa146c6..8206bbe4 100644 --- a/shell.nix +++ b/shell.nix @@ -20,7 +20,7 @@ let pkgs.git pkgs.imagemagick - pkgs.postgresql + pkgs.postgresql_15 pkgs.redis pkgs.ruby_3_2 diff --git a/spec/models/login_gov_spec.rb b/spec/models/login_gov_spec.rb new file mode 100644 index 00000000..bebf7d07 --- /dev/null +++ b/spec/models/login_gov_spec.rb @@ -0,0 +1,104 @@ +require 'rails_helper' + +RSpec.describe LoginGov do + 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(: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" => 1_721_696_128, + "iat" => 1_721_695_228, + "nbf" => 1_721_695_228 + }, { + "kid" => "abc10930230928df", + "alg" => "RS256" + } + ] + end + + before do + stub_request(:get, login_gov.well_known_configuration_url). + to_return(body: { + jwks_uri:, + 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 { login_gov.exchange_token_from_auth_result code_param }. + to raise_error(LoginGov::LoginApiError) + end + + it 'returns userinfo on success' do + # mock the encryption and http requests + 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 + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 7c883a65..ea28bc2b 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -37,6 +37,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..0e752824 --- /dev/null +++ b/spec/requests/sessions_request_spec.rb @@ -0,0 +1,42 @@ +require "rails_helper" + +RSpec.describe "SessionsController" 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(/\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") + expect(flash[:error]).to include("Please try again.") + 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: } + expect(response).to have_http_status(:ok) + expect(response).to render_template(:result) + 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