From 4ec9e7fc5efd8a7f849d9277cf37b1ab94b67d90 Mon Sep 17 00:00:00 2001 From: Ryan Arakawa Date: Wed, 27 Aug 2025 11:38:19 -0500 Subject: [PATCH] CP-10370 authentication (#15) * Add external_id fields to accounts and users tables Adds external_account_id and external_user_id fields to support integration with external ATS systems. These fields will map DocuSeal accounts/users to their corresponding ATS entities. * Add external ID support to Account and User models Implements find_or_create_by_external_id methods for both Account and User models to support automatic provisioning from external ATS systems. Users now have access tokens for authentication. * Add external authentication API endpoint Creates /api/external_auth/get_user_token endpoint for external API systems to authenticate users and receive access tokens. * Refactor authentication to support token-based login Replaces demo user authentication and setup redirect logic with token-based authentication via params, session, or X-Auth-Token header. Users do not login, they are just authenticated via token. * Replace authenticate_user! with authenticate_via_token! Refactored controllers to use authenticate_via_token! instead of authenticate_user! for authentication. Added authenticate_via_token! method to ApiBaseController. * Update controller authentication and authorization logic Removed and replaced several before_action and authorization checks in ExportController, SetupController, and TemplateDocumentsController. * Add external authentication API endpoint * Add IframeAuthentication concern for AJAX requests in iframe context * Create shared concern to handle authentication from HTTP referer * Extracts auth token from referer URL when AJAX requests don't include token * Supports Vue component requests within iframes * Remove old user authentication from dashboard controller * Quick fix for request changes Now that we have scoped users, we're changing this to compare to the template authot * rubocop fixes * Add and update authentication and model specs Introduces new specs for iframe authentication, account, user, application controller, and external auth API. * add safe navigation and remove dead method --- .../active_storage_blobs_proxy_controller.rb | 2 +- ...e_storage_blobs_proxy_legacy_controller.rb | 2 +- app/controllers/api/api_base_controller.rb | 4 +- .../api/external_auth_controller.rb | 32 ++++++ .../api/submitter_email_clicks_controller.rb | 2 +- .../api/submitter_form_views_controller.rb | 2 +- app/controllers/application_controller.rb | 60 ++++++----- .../concerns/iframe_authentication.rb | 37 +++++++ .../console_redirect_controller.rb | 2 +- app/controllers/dashboard_controller.rb | 2 +- app/controllers/enquiries_controller.rb | 2 +- app/controllers/export_controller.rb | 1 - .../send_submission_email_controller.rb | 2 +- app/controllers/setup_controller.rb | 3 +- app/controllers/start_form_controller.rb | 2 +- .../submissions_debug_controller.rb | 2 +- .../submissions_download_controller.rb | 2 +- .../submissions_preview_controller.rb | 2 +- app/controllers/submit_form_controller.rb | 2 +- .../submit_form_decline_controller.rb | 2 +- .../submit_form_download_controller.rb | 2 +- .../submit_form_draw_signature_controller.rb | 2 +- .../submit_form_invite_controller.rb | 2 +- .../submit_form_values_controller.rb | 2 +- .../submitters_request_changes_controller.rb | 10 +- .../template_documents_controller.rb | 7 +- app/controllers/templates_controller.rb | 6 +- .../templates_dashboard_controller.rb | 4 - app/models/account.rb | 27 +++-- app/models/user.rb | 13 +++ app/views/submissions/show.html.erb | 2 +- config/routes.rb | 5 + ..._add_external_ids_to_accounts_and_users.rb | 9 ++ db/schema.rb | 6 +- .../concerns/iframe_authentication_spec.rb | 75 ++++++++++++++ spec/models/account_spec.rb | 59 +++++++++++ spec/models/submitter_spec.rb | 40 ++++++++ spec/models/user_spec.rb | 99 +++++++++++++++++++ spec/requests/application_controller_spec.rb | 98 ++++++++++++++++++ spec/requests/external_auth_spec.rb | 36 +++++++ spec/signing_form_helper.rb | 14 +++ spec/system/signing_form_spec.rb | 1 + 42 files changed, 613 insertions(+), 71 deletions(-) create mode 100644 app/controllers/api/external_auth_controller.rb create mode 100644 app/controllers/concerns/iframe_authentication.rb create mode 100644 db/migrate/20250814214357_add_external_ids_to_accounts_and_users.rb create mode 100644 spec/controllers/concerns/iframe_authentication_spec.rb create mode 100644 spec/models/account_spec.rb create mode 100644 spec/models/user_spec.rb create mode 100644 spec/requests/application_controller_spec.rb create mode 100644 spec/requests/external_auth_spec.rb diff --git a/app/controllers/api/active_storage_blobs_proxy_controller.rb b/app/controllers/api/active_storage_blobs_proxy_controller.rb index 6f505992..f5e2a95a 100644 --- a/app/controllers/api/active_storage_blobs_proxy_controller.rb +++ b/app/controllers/api/active_storage_blobs_proxy_controller.rb @@ -4,7 +4,7 @@ module Api class ActiveStorageBlobsProxyController < ApiBaseController include ActiveStorage::Streaming - skip_before_action :authenticate_user! + skip_before_action :authenticate_via_token! skip_authorization_check before_action :set_cors_headers diff --git a/app/controllers/api/active_storage_blobs_proxy_legacy_controller.rb b/app/controllers/api/active_storage_blobs_proxy_legacy_controller.rb index 77ad2c6a..20675d41 100644 --- a/app/controllers/api/active_storage_blobs_proxy_legacy_controller.rb +++ b/app/controllers/api/active_storage_blobs_proxy_legacy_controller.rb @@ -4,7 +4,7 @@ module Api class ActiveStorageBlobsProxyLegacyController < ApiBaseController include ActiveStorage::Streaming - skip_before_action :authenticate_user! + skip_before_action :authenticate_via_token! skip_authorization_check before_action :set_cors_headers diff --git a/app/controllers/api/api_base_controller.rb b/app/controllers/api/api_base_controller.rb index b681aa67..05e56eb3 100644 --- a/app/controllers/api/api_base_controller.rb +++ b/app/controllers/api/api_base_controller.rb @@ -12,7 +12,7 @@ module Api wrap_parameters false - before_action :authenticate_user! + before_action :authenticate_via_token! check_authorization rescue_from Params::BaseValidator::InvalidParameterError do |e| @@ -81,7 +81,7 @@ module Api result end - def authenticate_user! + def authenticate_via_token! render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user end diff --git a/app/controllers/api/external_auth_controller.rb b/app/controllers/api/external_auth_controller.rb new file mode 100644 index 00000000..497a4a8f --- /dev/null +++ b/app/controllers/api/external_auth_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Api + class ExternalAuthController < Api::ApiBaseController + skip_before_action :authenticate_via_token! + skip_authorization_check + + def user_token + account = Account.find_or_create_by_external_id( + params[:account][:external_id]&.to_i, + name: params[:account][:name], + locale: params[:account][:locale] || 'en-US', + timezone: params[:account][:timezone] || 'UTC' + ) + + user = User.find_or_create_by_external_id( + account, + params[:user][:external_id]&.to_i, + email: params[:user][:email], + first_name: params[:user][:first_name], + last_name: params[:user][:last_name], + role: 'admin' + ) + + render json: { access_token: user.access_token.token } + rescue StandardError => e + Rails.logger.error("External auth error: #{e.message}") + Rollbar.error(e) if defined?(Rollbar) + render json: { error: 'Internal server error' }, status: :internal_server_error + end + end +end diff --git a/app/controllers/api/submitter_email_clicks_controller.rb b/app/controllers/api/submitter_email_clicks_controller.rb index cef26542..aaf97546 100644 --- a/app/controllers/api/submitter_email_clicks_controller.rb +++ b/app/controllers/api/submitter_email_clicks_controller.rb @@ -2,7 +2,7 @@ module Api class SubmitterEmailClicksController < ApiBaseController - skip_before_action :authenticate_user! + skip_before_action :authenticate_via_token! skip_authorization_check def create diff --git a/app/controllers/api/submitter_form_views_controller.rb b/app/controllers/api/submitter_form_views_controller.rb index e8b52095..d2931139 100644 --- a/app/controllers/api/submitter_form_views_controller.rb +++ b/app/controllers/api/submitter_form_views_controller.rb @@ -2,7 +2,7 @@ module Api class SubmitterFormViewsController < ApiBaseController - skip_before_action :authenticate_user! + skip_before_action :authenticate_via_token! skip_authorization_check def create diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 92bf197c..421313aa 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -6,14 +6,12 @@ class ApplicationController < ActionController::Base include ActiveStorage::SetCurrent include Pagy::Backend - before_action :ensure_demo_user_signed_in - check_authorization unless: :devise_controller? around_action :with_locale # before_action :sign_in_for_demo, if: -> { Docuseal.demo? } - before_action :maybe_redirect_to_setup, unless: :signed_in? - before_action :authenticate_user!, unless: :devise_controller? + before_action :maybe_authenticate_via_token + before_action :authenticate_via_token!, unless: :devise_controller? helper_method :button_title, :current_account, @@ -102,34 +100,42 @@ class ApplicationController < ActionController::Base current_user&.account end - def maybe_redirect_to_setup - # Skip setup redirect for iframe embedding - create demo user instead - return if ensure_demo_user_signed_in + def maybe_authenticate_via_token + return if signed_in? - redirect_to setup_index_path unless User.exists? - end + # Check for token in params, session, or X-Auth-Token header + token = params[:auth_token] || session[:auth_token] || request.headers['X-Auth-Token'] + return if token.blank? + + # Try to find user by token and sign them in + sha256 = Digest::SHA256.hexdigest(token) + user = User.joins(:access_token).active.find_by(access_token: { sha256: sha256 }) - def ensure_demo_user_signed_in - return true if signed_in? + return unless user - user = find_or_create_demo_user sign_in(user) - true - end - - def find_or_create_demo_user - User.find_by(email: 'demo@docuseal.local') || begin - account = Account.create!(name: 'Demo Account', locale: 'en', timezone: 'UTC') - User.create!( - email: 'demo@docuseal.local', - password: 'password123', - password_confirmation: 'password123', - first_name: 'Demo', - last_name: 'User', - account: account, - role: 'admin' - ) + session[:auth_token] = token + end + + # Enhanced authentication that tries token auth and fails with error if no user found + # Use this when you need to enforce authentication with better token handling + def authenticate_via_token! + return if signed_in? + + token = params[:auth_token] || session[:auth_token] || request.headers['X-Auth-Token'] + + if token.present? + sha256 = Digest::SHA256.hexdigest(token) + user = User.joins(:access_token).active.find_by(access_token: { sha256: sha256 }) + + if user + sign_in(user) + session[:auth_token] = token + return + end end + + render json: { error: 'Authentication required. Please provide a valid auth_token.' }, status: :unauthorized end def button_title(title: I18n.t('submit'), disabled_with: I18n.t('submitting'), title_class: '', icon: nil, diff --git a/app/controllers/concerns/iframe_authentication.rb b/app/controllers/concerns/iframe_authentication.rb new file mode 100644 index 00000000..0683b361 --- /dev/null +++ b/app/controllers/concerns/iframe_authentication.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module IframeAuthentication + extend ActiveSupport::Concern + + private + + # Custom authentication for iframe context + # AJAX requests from Vue components don't include the auth token that's in the iframe URL, + # so we extract it from the HTTP referer header as a fallback + def authenticate_from_referer + return if signed_in? + + token = params[:auth_token] || session[:auth_token] || request.headers['X-Auth-Token'] + + # If no token found, extract from referer URL (iframe page has the token) + if token.blank? && request.referer.present? + referer_uri = URI.parse(request.referer) + referer_params = CGI.parse(referer_uri.query || '') + token = referer_params['auth_token']&.first + end + + if token.present? + sha256 = Digest::SHA256.hexdigest(token) + user = User.joins(:access_token).active.find_by(access_token: { sha256: sha256 }) + + return unless user + + sign_in(user) + session[:auth_token] = token + return + end + + Rails.logger.error "#{self.class.name}: Authentication failed" + render json: { error: 'Authentication required' }, status: :unauthorized + end +end diff --git a/app/controllers/console_redirect_controller.rb b/app/controllers/console_redirect_controller.rb index dd80e9fe..ca815f60 100644 --- a/app/controllers/console_redirect_controller.rb +++ b/app/controllers/console_redirect_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class ConsoleRedirectController < ApplicationController - skip_before_action :authenticate_user! + skip_before_action :authenticate_via_token! skip_authorization_check def index diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 23df8322..acf25d1d 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class DashboardController < ApplicationController - skip_before_action :authenticate_user!, only: %i[index] + skip_before_action :authenticate_via_token!, only: %i[index] before_action :maybe_redirect_product_url before_action :maybe_render_landing diff --git a/app/controllers/enquiries_controller.rb b/app/controllers/enquiries_controller.rb index 829b578c..98c09f92 100644 --- a/app/controllers/enquiries_controller.rb +++ b/app/controllers/enquiries_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class EnquiriesController < ApplicationController - skip_before_action :authenticate_user! + skip_before_action :authenticate_via_token! skip_authorization_check def create diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb index 3c5403b9..3ec5992a 100644 --- a/app/controllers/export_controller.rb +++ b/app/controllers/export_controller.rb @@ -4,7 +4,6 @@ require 'faraday' class ExportController < ApplicationController skip_authorization_check - skip_before_action :maybe_redirect_to_setup skip_before_action :verify_authenticity_token # Send template to third party. diff --git a/app/controllers/send_submission_email_controller.rb b/app/controllers/send_submission_email_controller.rb index 45852360..d916440f 100644 --- a/app/controllers/send_submission_email_controller.rb +++ b/app/controllers/send_submission_email_controller.rb @@ -3,7 +3,7 @@ class SendSubmissionEmailController < ApplicationController layout 'form' - skip_before_action :authenticate_user! + skip_before_action :authenticate_via_token! skip_before_action :verify_authenticity_token skip_authorization_check diff --git a/app/controllers/setup_controller.rb b/app/controllers/setup_controller.rb index 778255bc..ba2911f8 100644 --- a/app/controllers/setup_controller.rb +++ b/app/controllers/setup_controller.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true class SetupController < ApplicationController - skip_before_action :maybe_redirect_to_setup - skip_before_action :authenticate_user! + skip_before_action :authenticate_via_token! skip_authorization_check before_action :redirect_to_root_if_signed, if: :signed_in? diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb index fb2a9786..fcc87e34 100644 --- a/app/controllers/start_form_controller.rb +++ b/app/controllers/start_form_controller.rb @@ -3,7 +3,7 @@ class StartFormController < ApplicationController layout 'form' - skip_before_action :authenticate_user! + skip_before_action :authenticate_via_token! skip_authorization_check around_action :with_browser_locale, only: %i[show completed] diff --git a/app/controllers/submissions_debug_controller.rb b/app/controllers/submissions_debug_controller.rb index 4a6e8b9d..c5482020 100644 --- a/app/controllers/submissions_debug_controller.rb +++ b/app/controllers/submissions_debug_controller.rb @@ -3,7 +3,7 @@ class SubmissionsDebugController < ApplicationController layout 'plain' - skip_before_action :authenticate_user! + skip_before_action :authenticate_via_token! skip_authorization_check def index diff --git a/app/controllers/submissions_download_controller.rb b/app/controllers/submissions_download_controller.rb index 62836650..f0f3778c 100644 --- a/app/controllers/submissions_download_controller.rb +++ b/app/controllers/submissions_download_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class SubmissionsDownloadController < ApplicationController - skip_before_action :authenticate_user! + skip_before_action :authenticate_via_token! skip_authorization_check TTL = 40.minutes diff --git a/app/controllers/submissions_preview_controller.rb b/app/controllers/submissions_preview_controller.rb index d8a36f0b..c56e8db9 100644 --- a/app/controllers/submissions_preview_controller.rb +++ b/app/controllers/submissions_preview_controller.rb @@ -2,7 +2,7 @@ class SubmissionsPreviewController < ApplicationController around_action :with_browser_locale - skip_before_action :authenticate_user! + skip_before_action :authenticate_via_token! skip_authorization_check prepend_before_action :maybe_redirect_com, only: %i[show completed] diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb index 24092dcc..e2be5cba 100644 --- a/app/controllers/submit_form_controller.rb +++ b/app/controllers/submit_form_controller.rb @@ -4,7 +4,7 @@ class SubmitFormController < ApplicationController layout 'form' around_action :with_browser_locale, only: %i[show completed success] - skip_before_action :authenticate_user! + skip_before_action :authenticate_via_token! skip_authorization_check skip_before_action :verify_authenticity_token, only: :update diff --git a/app/controllers/submit_form_decline_controller.rb b/app/controllers/submit_form_decline_controller.rb index 15572da1..99ea9870 100644 --- a/app/controllers/submit_form_decline_controller.rb +++ b/app/controllers/submit_form_decline_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class SubmitFormDeclineController < ApplicationController - skip_before_action :authenticate_user! + skip_before_action :authenticate_via_token! skip_authorization_check def create diff --git a/app/controllers/submit_form_download_controller.rb b/app/controllers/submit_form_download_controller.rb index d357019c..67815e5e 100644 --- a/app/controllers/submit_form_download_controller.rb +++ b/app/controllers/submit_form_download_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class SubmitFormDownloadController < ApplicationController - skip_before_action :authenticate_user! + skip_before_action :authenticate_via_token! skip_authorization_check FILES_TTL = 5.minutes diff --git a/app/controllers/submit_form_draw_signature_controller.rb b/app/controllers/submit_form_draw_signature_controller.rb index f8352ade..7c327b71 100644 --- a/app/controllers/submit_form_draw_signature_controller.rb +++ b/app/controllers/submit_form_draw_signature_controller.rb @@ -4,7 +4,7 @@ class SubmitFormDrawSignatureController < ApplicationController layout false around_action :with_browser_locale, only: %i[show] - skip_before_action :authenticate_user! + skip_before_action :authenticate_via_token! skip_authorization_check def show diff --git a/app/controllers/submit_form_invite_controller.rb b/app/controllers/submit_form_invite_controller.rb index 1d42779c..1d60e717 100644 --- a/app/controllers/submit_form_invite_controller.rb +++ b/app/controllers/submit_form_invite_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class SubmitFormInviteController < ApplicationController - skip_before_action :authenticate_user! + skip_before_action :authenticate_via_token! skip_authorization_check def create diff --git a/app/controllers/submit_form_values_controller.rb b/app/controllers/submit_form_values_controller.rb index 2c4a2ad3..7c3f651d 100644 --- a/app/controllers/submit_form_values_controller.rb +++ b/app/controllers/submit_form_values_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class SubmitFormValuesController < ApplicationController - skip_before_action :authenticate_user! + skip_before_action :authenticate_via_token! skip_authorization_check def index diff --git a/app/controllers/submitters_request_changes_controller.rb b/app/controllers/submitters_request_changes_controller.rb index b18d8ed0..dff9f862 100644 --- a/app/controllers/submitters_request_changes_controller.rb +++ b/app/controllers/submitters_request_changes_controller.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true class SubmittersRequestChangesController < ApplicationController - before_action :load_submitter + include IframeAuthentication + skip_before_action :verify_authenticity_token, only: :request_changes + skip_before_action :authenticate_via_token!, only: :request_changes + before_action :authenticate_from_referer, only: :request_changes + before_action :load_submitter def request_changes if request.get? || request.head? @@ -48,9 +52,9 @@ class SubmittersRequestChangesController < ApplicationController end def can_request_changes? - # Only the user who created the submission can request changes + # Only the template author (manager) can request changes from submitters # Only for completed submissions that haven't been declined - current_user == @submitter.submission.created_by_user && + current_user == @submitter.submission.template.author && @submitter.completed_at? && !@submitter.declined_at? && !@submitter.changes_requested_at? diff --git a/app/controllers/template_documents_controller.rb b/app/controllers/template_documents_controller.rb index 7144f5e0..7aaa039c 100644 --- a/app/controllers/template_documents_controller.rb +++ b/app/controllers/template_documents_controller.rb @@ -1,8 +1,13 @@ # frozen_string_literal: true class TemplateDocumentsController < ApplicationController + include IframeAuthentication + skip_before_action :verify_authenticity_token - load_and_authorize_resource :template + skip_before_action :authenticate_via_token! + + before_action :authenticate_from_referer + load_and_authorize_resource :template, id_param: :template_id def create if params[:blobs].blank? && params[:files].blank? diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index 12258115..2e2e5919 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -2,12 +2,13 @@ class TemplatesController < ApplicationController include PrefillFieldsHelper + include IframeAuthentication - skip_before_action :maybe_redirect_to_setup skip_before_action :verify_authenticity_token + skip_before_action :authenticate_via_token!, only: [:update] + before_action :authenticate_from_referer, only: [:update] load_and_authorize_resource :template - before_action :load_base_template, only: %i[new create] def show @@ -67,6 +68,7 @@ class TemplatesController < ApplicationController name: params.dig(:template, :name), folder_name: params[:folder_name]) else + @template = Template.new(template_params) if @template.nil? @template.author = current_user @template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name]) end diff --git a/app/controllers/templates_dashboard_controller.rb b/app/controllers/templates_dashboard_controller.rb index 6cb553c1..8690bed6 100644 --- a/app/controllers/templates_dashboard_controller.rb +++ b/app/controllers/templates_dashboard_controller.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true class TemplatesDashboardController < ApplicationController - before_action :ensure_demo_user_signed_in - skip_before_action :authenticate_user! - skip_before_action :maybe_redirect_to_setup - load_and_authorize_resource :template_folder, parent: false load_and_authorize_resource :template, parent: false diff --git a/app/models/account.rb b/app/models/account.rb index 6265734d..4a1b7701 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -4,18 +4,20 @@ # # Table name: accounts # -# id :bigint not null, primary key -# archived_at :datetime -# locale :string not null -# name :string not null -# timezone :string not null -# uuid :string not null -# created_at :datetime not null -# updated_at :datetime not null +# id :bigint not null, primary key +# archived_at :datetime +# locale :string not null +# name :string not null +# timezone :string not null +# uuid :string not null +# created_at :datetime not null +# updated_at :datetime not null +# external_account_id :integer # # Indexes # -# index_accounts_on_uuid (uuid) UNIQUE +# index_accounts_on_external_account_id (external_account_id) UNIQUE +# index_accounts_on_uuid (uuid) UNIQUE # class Account < ApplicationRecord attribute :uuid, :string, default: -> { SecureRandom.uuid } @@ -53,8 +55,15 @@ class Account < ApplicationRecord attribute :timezone, :string, default: 'UTC' attribute :locale, :string, default: 'en-US' + validates :external_account_id, uniqueness: true, allow_nil: true + scope :active, -> { where(archived_at: nil) } + def self.find_or_create_by_external_id(external_id, attributes = {}) + find_by(external_account_id: external_id) || + create!(attributes.merge(external_account_id: external_id)) + end + def testing? linked_account_account&.testing? end diff --git a/app/models/user.rb b/app/models/user.rb index 98e500ee..542924ce 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -29,11 +29,13 @@ # created_at :datetime not null # updated_at :datetime not null # account_id :integer not null +# external_user_id :integer # # Indexes # # index_users_on_account_id (account_id) # index_users_on_email (email) UNIQUE +# index_users_on_external_user_id (external_user_id) UNIQUE # index_users_on_reset_password_token (reset_password_token) UNIQUE # index_users_on_unlock_token (unlock_token) UNIQUE # index_users_on_uuid (uuid) UNIQUE @@ -74,6 +76,17 @@ class User < ApplicationRecord scope :admins, -> { where(role: ADMIN_ROLE) } validates :email, format: { with: /\A[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\z/ } + validates :external_user_id, uniqueness: true, allow_nil: true + + def self.find_or_create_by_external_id(account, external_id, attributes = {}) + account.users.find_by(external_user_id: external_id) || + account.users.create!( + attributes.merge( + external_user_id: external_id, + password: SecureRandom.hex(16) + ) + ) + end def access_token super || build_access_token.tap(&:save!) diff --git a/app/views/submissions/show.html.erb b/app/views/submissions/show.html.erb index 7e878842..122521b6 100644 --- a/app/views/submissions/show.html.erb +++ b/app/views/submissions/show.html.erb @@ -235,7 +235,7 @@ <%= button_to t('resubmit'), submitters_resubmit_path(submitter), method: :put, class: 'btn btn-sm btn-primary w-full', form: { target: '_blank' }, data: { turbo: false } %> <% end %> - <% if signed_in? && submitter && submitter.completed_at? && !submitter.declined_at? && !submitter.changes_requested_at? && current_user == @submission.created_by_user %> + <% if signed_in? && submitter && submitter.completed_at? && !submitter.declined_at? && !submitter.changes_requested_at? && current_user == @submission.template.author %>
<%= link_to 'Request Changes', request_changes_submitter_path(submitter.slug), class: 'btn btn-sm btn-warning w-full', diff --git a/config/routes.rb b/config/routes.rb index d76fba77..eddde784 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -56,6 +56,11 @@ Rails.application.routes.draw do resources :form_events, only: %i[index], path: 'form/:type' resources :submission_events, only: %i[index], path: 'submission/:type' end + resources :external_auth, only: [] do + collection do + post :user_token + end + end end resources :export, controller: 'export' do diff --git a/db/migrate/20250814214357_add_external_ids_to_accounts_and_users.rb b/db/migrate/20250814214357_add_external_ids_to_accounts_and_users.rb new file mode 100644 index 00000000..e5f79598 --- /dev/null +++ b/db/migrate/20250814214357_add_external_ids_to_accounts_and_users.rb @@ -0,0 +1,9 @@ +class AddExternalIdsToAccountsAndUsers < ActiveRecord::Migration[8.0] + def change + add_column :accounts, :external_account_id, :integer + add_column :users, :external_user_id, :integer + + add_index :accounts, :external_account_id, unique: true + add_index :users, :external_user_id, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 658ea3bd..ef23e9e3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_08_11_211829) do +ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do # These are extensions that must be enabled in order to support this database enable_extension "btree_gin" enable_extension "pg_catalog.plpgsql" @@ -62,6 +62,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_11_211829) do t.datetime "updated_at", null: false t.string "uuid", null: false t.datetime "archived_at" + t.integer "external_account_id" + t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true t.index ["uuid"], name: "index_accounts_on_uuid", unique: true end @@ -440,8 +442,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_11_211829) do t.string "otp_secret" t.integer "consumed_timestep" t.boolean "otp_required_for_login", default: false, null: false + t.integer "external_user_id" t.index ["account_id"], name: "index_users_on_account_id" t.index ["email"], name: "index_users_on_email", unique: true + t.index ["external_user_id"], name: "index_users_on_external_user_id", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true t.index ["uuid"], name: "index_users_on_uuid", unique: true diff --git a/spec/controllers/concerns/iframe_authentication_spec.rb b/spec/controllers/concerns/iframe_authentication_spec.rb new file mode 100644 index 00000000..fc9ee1a8 --- /dev/null +++ b/spec/controllers/concerns/iframe_authentication_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +describe IframeAuthentication do + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:token) { user.access_token.token } + + let(:controller_class) do + Class.new(ApplicationController) do + include IframeAuthentication + end + end + + let(:controller) { controller_class.new } + let(:request_double) { instance_double(ActionDispatch::Request, headers: {}, referer: nil) } + + before do + allow(controller).to receive_messages( + request: request_double, + params: {}, + session: {}, + signed_in?: false, + sign_in: nil, + render: nil + ) + allow(Rails.logger).to receive(:error) + end + + describe '#authenticate_from_referer' do + it 'does nothing when already signed in' do + allow(controller).to receive(:signed_in?).and_return(true) + controller.send(:authenticate_from_referer) + expect(controller).not_to have_received(:sign_in) + end + + it 'authenticates with valid params token' do + allow(controller).to receive(:params).and_return({ auth_token: token }) + controller.send(:authenticate_from_referer) + expect(controller).to have_received(:sign_in).with(user) + end + + it 'authenticates with valid session token' do + allow(controller).to receive(:session).and_return({ auth_token: token }) + controller.send(:authenticate_from_referer) + expect(controller).to have_received(:sign_in).with(user) + end + + it 'authenticates with valid header token' do + allow(request_double).to receive(:headers).and_return({ 'X-Auth-Token' => token }) + controller.send(:authenticate_from_referer) + expect(controller).to have_received(:sign_in).with(user) + end + + it 'authenticates with token from referer URL' do + allow(request_double).to receive(:referer).and_return("https://example.com?auth_token=#{token}") + controller.send(:authenticate_from_referer) + expect(controller).to have_received(:sign_in).with(user) + end + + it 'does nothing with invalid token' do + allow(controller).to receive(:params).and_return({ auth_token: 'invalid' }) + controller.send(:authenticate_from_referer) + expect(controller).not_to have_received(:sign_in) + expect(controller).not_to have_received(:render) + end + + it 'renders error with no token' do + controller.send(:authenticate_from_referer) + expect(controller).to have_received(:render).with( + json: { error: 'Authentication required' }, + status: :unauthorized + ) + end + end +end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb new file mode 100644 index 00000000..aa23cda7 --- /dev/null +++ b/spec/models/account_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Account do + describe 'validations' do + it 'is valid with valid attributes' do + account = build(:account) + expect(account).to be_valid + end + + it 'validates uniqueness of external_account_id when present' do + create(:account, external_account_id: 123) + duplicate = build(:account, external_account_id: 123) + expect(duplicate).not_to be_valid + end + end + + describe '.find_or_create_by_external_id' do + let(:external_id) { 123 } + let(:attributes) { { name: 'Test Account' } } + + it 'finds existing account by external_account_id' do + existing_account = create(:account, external_account_id: external_id) + result = described_class.find_or_create_by_external_id(external_id, attributes) + expect(result).to eq(existing_account) + end + + it 'creates new account when none exists' do + result = described_class.find_or_create_by_external_id(external_id, attributes) + expect(result.external_account_id).to eq(external_id) + expect(result.name).to eq('Test Account') + end + end + + describe '#testing?' do + let(:account) { create(:account) } + + it 'delegates to linked_account_account' do + linked_account_account = instance_double(AccountLinkedAccount, testing?: true) + allow(account).to receive(:linked_account_account).and_return(linked_account_account) + + expect(account.testing?).to be true + end + end + + describe '#default_template_folder' do + it 'creates default folder when none exists' do + account = create(:account) + create(:user, account: account) + + expect do + folder = account.default_template_folder + expect(folder.name).to eq(TemplateFolder::DEFAULT_NAME) + expect(folder).to be_persisted + end.to change(account.template_folders, :count).by(1) + end + end +end diff --git a/spec/models/submitter_spec.rb b/spec/models/submitter_spec.rb index 7618dc07..760234db 100644 --- a/spec/models/submitter_spec.rb +++ b/spec/models/submitter_spec.rb @@ -1,5 +1,45 @@ # frozen_string_literal: true +# == Schema Information +# +# Table name: submitters +# +# id :bigint not null, primary key +# changes_requested_at :datetime +# completed_at :datetime +# declined_at :datetime +# email :string +# ip :string +# metadata :text not null +# name :string +# opened_at :datetime +# phone :string +# preferences :text not null +# sent_at :datetime +# slug :string not null +# timezone :string +# ua :string +# uuid :string not null +# values :text not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer not null +# external_id :string +# submission_id :integer not null +# +# Indexes +# +# index_submitters_on_account_id_and_id (account_id,id) +# index_submitters_on_completed_at_and_account_id (completed_at,account_id) +# index_submitters_on_email (email) +# index_submitters_on_external_id (external_id) +# index_submitters_on_slug (slug) UNIQUE +# index_submitters_on_submission_id (submission_id) +# +# Foreign Keys +# +# fk_rails_... (submission_id => submissions.id) +# require 'rails_helper' RSpec.describe Submitter do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 00000000..009f02fd --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe User do + describe 'validations' do + it 'is valid with valid attributes' do + user = build(:user) + expect(user).to be_valid + end + + it 'validates email format' do + user = build(:user, email: 'invalid-email') + expect(user).not_to be_valid + end + + it 'validates uniqueness of external_user_id when present' do + account = create(:account) + create(:user, account: account, external_user_id: 123) + duplicate = build(:user, account: account, external_user_id: 123) + expect(duplicate).not_to be_valid + end + end + + describe '.find_or_create_by_external_id' do + let(:account) { create(:account) } + let(:external_id) { 123 } + let(:attributes) { { first_name: 'Test', last_name: 'User', email: 'test@example.com' } } + + it 'finds existing user by external_user_id' do + existing_user = create(:user, account: account, external_user_id: external_id) + result = described_class.find_or_create_by_external_id(account, external_id, attributes) + expect(result).to eq(existing_user) + end + + it 'creates new user when none exists' do + result = described_class.find_or_create_by_external_id(account, external_id, attributes) + expect(result.external_user_id).to eq(external_id) + expect(result.first_name).to eq('Test') + expect(result.email).to eq('test@example.com') + expect(result.password).to be_present + end + end + + describe '#active_for_authentication?' do + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + + it 'returns true when user and account are active' do + expect(user.active_for_authentication?).to be true + end + + it 'returns false when user is archived' do + user.update!(archived_at: 1.day.ago) + expect(user.active_for_authentication?).to be false + end + + it 'returns false when account is archived' do + account.update!(archived_at: 1.day.ago) + expect(user.active_for_authentication?).to be false + end + end + + describe '#initials' do + it 'returns initials from first and last name' do + user = build(:user, first_name: 'John', last_name: 'Doe') + expect(user.initials).to eq('JD') + end + + it 'handles missing names' do + user = build(:user, first_name: 'John', last_name: nil) + expect(user.initials).to eq('J') + end + end + + describe '#full_name' do + it 'combines first and last name' do + user = build(:user, first_name: 'John', last_name: 'Doe') + expect(user.full_name).to eq('John Doe') + end + + it 'handles missing names' do + user = build(:user, first_name: 'John', last_name: nil) + expect(user.full_name).to eq('John') + end + end + + describe '#friendly_name' do + it 'returns formatted name with email when full name present' do + user = build(:user, first_name: 'John', last_name: 'Doe', email: 'john@example.com') + expect(user.friendly_name).to eq('"John Doe" ') + end + + it 'returns just email when no full name' do + user = build(:user, first_name: nil, last_name: nil, email: 'john@example.com') + expect(user.friendly_name).to eq('john@example.com') + end + end +end diff --git a/spec/requests/application_controller_spec.rb b/spec/requests/application_controller_spec.rb new file mode 100644 index 00000000..68cba51b --- /dev/null +++ b/spec/requests/application_controller_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +describe 'ApplicationController' do + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:token) { user.access_token.token } + + describe 'token authentication methods' do + let(:controller) { ApplicationController.new } + + let(:request_double) { instance_double(ActionDispatch::Request, headers: {}) } + + before do + allow(controller).to receive_messages( + request: request_double, + params: {}, + session: {}, + signed_in?: false + ) + end + + describe '#maybe_authenticate_via_token' do + it 'signs in user with valid token in header' do + request_double_with_token = instance_double(ActionDispatch::Request, headers: { 'X-Auth-Token' => token }) + allow(controller).to receive(:request).and_return(request_double_with_token) + allow(controller).to receive(:sign_in) + + controller.send(:maybe_authenticate_via_token) + + expect(controller).to have_received(:sign_in).with(user) + end + + it 'does nothing with invalid token' do + request_double_with_invalid = instance_double(ActionDispatch::Request, headers: { 'X-Auth-Token' => 'invalid' }) + allow(controller).to receive(:request).and_return(request_double_with_invalid) + allow(controller).to receive(:sign_in) + + controller.send(:maybe_authenticate_via_token) + + expect(controller).not_to have_received(:sign_in) + end + end + + describe '#authenticate_via_token!' do + it 'renders error with no token' do + allow(controller).to receive(:render) + + controller.send(:authenticate_via_token!) + + expect(controller).to have_received(:render).with( + json: { error: 'Authentication required. Please provide a valid auth_token.' }, + status: :unauthorized + ) + end + + it 'renders error with invalid token' do + request_double_with_invalid = instance_double(ActionDispatch::Request, headers: { 'X-Auth-Token' => 'invalid' }) + allow(controller).to receive(:request).and_return(request_double_with_invalid) + allow(controller).to receive(:render) + + controller.send(:authenticate_via_token!) + + expect(controller).to have_received(:render).with( + json: { error: 'Authentication required. Please provide a valid auth_token.' }, + status: :unauthorized + ) + end + + it 'does not render error with valid token' do + request_double_with_token = instance_double(ActionDispatch::Request, headers: { 'X-Auth-Token' => token }) + allow(controller).to receive(:request).and_return(request_double_with_token) + allow(controller).to receive_messages(sign_in: nil, render: nil) + + controller.send(:authenticate_via_token!) + + expect(controller).not_to have_received(:render) + expect(controller).to have_received(:sign_in).with(user) + end + end + end + + describe 'API authentication' do + context 'with valid token' do + it 'authenticates user' do + get '/api/submissions', headers: { 'X-Auth-Token': token } + expect(response).to have_http_status(:ok) + end + end + + context 'with invalid token' do + it 'returns API-specific error message' do + get '/api/submissions', headers: { 'X-Auth-Token': 'invalid_token' } + expect(response).to have_http_status(:unauthorized) + expect(response.parsed_body).to eq({ 'error' => 'Not authenticated' }) + end + end + end +end diff --git a/spec/requests/external_auth_spec.rb b/spec/requests/external_auth_spec.rb new file mode 100644 index 00000000..34a8e5eb --- /dev/null +++ b/spec/requests/external_auth_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +describe 'External Auth API' do + describe 'POST /api/external_auth/user_token' do + let(:valid_params) do + { + account: { + external_id: '123', + name: 'Test Company' + }, + user: { + external_id: '456', + email: 'test@example.com', + first_name: 'John', + last_name: 'Doe' + } + } + end + + it 'returns success with access token' do + post '/api/external_auth/user_token', params: valid_params, as: :json + + expect(response).to have_http_status(:ok) + expect(response.parsed_body).to have_key('access_token') + end + + it 'returns error when params cause exception' do + allow(Account).to receive(:find_or_create_by_external_id).and_raise(StandardError.new('Test error')) + + post '/api/external_auth/user_token', params: valid_params, as: :json + + expect(response).to have_http_status(:internal_server_error) + expect(response.parsed_body).to eq({ 'error' => 'Internal server error' }) + end + end +end diff --git a/spec/signing_form_helper.rb b/spec/signing_form_helper.rb index a0b05a2a..268c7b52 100644 --- a/spec/signing_form_helper.rb +++ b/spec/signing_form_helper.rb @@ -59,4 +59,18 @@ module SigningFormHelper def template_field(template, field_name) template.fields.find { |f| f['name'] == field_name || f['title'] == field_name } || {} end + + # Waits for a job to be queued in Sidekiq for the specified job class. + def wait_for_job_to_queue(job_class, timeout: 5) + initial_count = job_class.jobs.size + Timeout.timeout(timeout) do + loop do + break if job_class.jobs.size > initial_count + + sleep 0.1 + end + end + rescue Timeout::Error + # If timeout occurs, just continue - the test will fail with a more descriptive message + end end diff --git a/spec/system/signing_form_spec.rb b/spec/system/signing_form_spec.rb index 95175bdb..3c86f7a3 100644 --- a/spec/system/signing_form_spec.rb +++ b/spec/system/signing_form_spec.rb @@ -654,6 +654,7 @@ RSpec.describe 'Signing Form' do expect do click_on 'Sign and Complete' + wait_for_job_to_queue(ProcessSubmitterCompletionJob) end.to change(ProcessSubmitterCompletionJob.jobs, :size).by(1) end end