From 5554bf45b5271ee8cda4904a539039360ddd9337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20B=C4=82LTEANU?= Date: Tue, 7 Jul 2026 23:55:09 +0300 Subject: [PATCH] Implement OAuth 2.0 (XOAUTH2) support for incoming IMAP emails #43023 --- app/controllers/email_oauth_controller.rb | 211 ++++++++++++++++++ app/models/email_oauth_token.rb | 53 +++++ app/models/mailer.rb | 18 ++ app/models/setting.rb | 6 + app/views/admin/index.html.erb | 8 + app/views/email_oauth/index.html.erb | 44 ++++ app/views/email_oauth/new.html.erb | 14 ++ .../email_oauth_refresh_failure.html.erb | 16 ++ .../email_oauth_refresh_failure.text.erb | 14 ++ app/views/settings/_mail_handler.html.erb | 19 ++ config/locales/en.yml | 8 + config/routes.rb | 7 + ...0260702055909_create_email_oauth_tokens.rb | 17 ++ lib/redmine/email_oauth/refresher.rb | 122 ++++++++++ lib/redmine/imap.rb | 15 +- test/functional/admin_controller_test.rb | 13 ++ .../functional/email_oauth_controller_test.rb | 182 +++++++++++++++ test/functional/settings_controller_test.rb | 27 +++ test/unit/email_oauth_refresher_test.rb | 203 +++++++++++++++++ test/unit/email_oauth_token_test.rb | 113 ++++++++++ 20 files changed, 1109 insertions(+), 1 deletion(-) create mode 100644 app/controllers/email_oauth_controller.rb create mode 100644 app/models/email_oauth_token.rb create mode 100644 app/views/email_oauth/index.html.erb create mode 100644 app/views/email_oauth/new.html.erb create mode 100644 app/views/mailer/email_oauth_refresh_failure.html.erb create mode 100644 app/views/mailer/email_oauth_refresh_failure.text.erb create mode 100644 db/migrate/20260702055909_create_email_oauth_tokens.rb create mode 100644 lib/redmine/email_oauth/refresher.rb create mode 100644 test/functional/email_oauth_controller_test.rb create mode 100644 test/unit/email_oauth_refresher_test.rb create mode 100644 test/unit/email_oauth_token_test.rb diff --git a/app/controllers/email_oauth_controller.rb b/app/controllers/email_oauth_controller.rb new file mode 100644 index 000000000..69b0d11ed --- /dev/null +++ b/app/controllers/email_oauth_controller.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +require 'net/http' +require 'uri' +require 'json' + +class EmailOauthController < ApplicationController + layout 'admin' + before_action :require_admin + + def index + @tokens = EmailOauthToken.order(:email) + end + + def new + @token = EmailOauthToken.new + if Setting.email_oauth_providers.empty? + flash[:error] = l(:text_email_oauth_providers_not_configured) + redirect_to email_oauth_index_path + end + end + + def initiate + email = params[:email_oauth_token][:email] + provider = params[:email_oauth_token][:provider] + + provider_config = get_provider_config(provider) + if provider_config.blank? + flash[:error] = l(:error_oauth_authentication_failed, message: "Provider '#{provider}' is not configured in configuration.yml.") + redirect_to new_email_oauth_path and return + end + + # Build authorization URL + auth_uri = build_auth_uri(provider, provider_config, email) + + # Store state in session to prevent CSRF / verify callback + session[:email_oauth_state] = SecureRandom.hex(16) + session[:email_oauth_email] = email + session[:email_oauth_provider] = provider + + redirect_to auth_uri, allow_other_host: true + end + + def callback + state = params[:state] + code = params[:code] + error = params[:error] + + if error.present? + flash[:error] = l(:error_oauth_authentication_failed, message: error) + redirect_to email_oauth_index_path and return + end + + unless Rails.env.development? + if state.blank? || state != session[:email_oauth_state] + flash[:error] = l(:error_oauth_authentication_failed, message: "OAuth verification state mismatch. Potential CSRF request.") + redirect_to email_oauth_index_path and return + end + end + + email = session.delete(:email_oauth_email) + provider = session.delete(:email_oauth_provider) + session.delete(:email_oauth_state) + + provider_config = get_provider_config(provider) + if provider_config.blank? || email.blank? + flash[:error] = l(:error_oauth_authentication_failed, message: "Invalid session state or missing provider configuration.") + redirect_to email_oauth_index_path and return + end + + # Exchange authorization code for tokens + exchange_and_save_tokens(provider, provider_config, email, code) + end + + def destroy + @token = EmailOauthToken.find(params[:id]) + if @token.destroy + flash[:notice] = l(:notice_successful_delete) + else + flash[:error] = l(:error_oauth_authentication_failed, message: "Failed to delete OAuth token mapping.") + end + redirect_to email_oauth_index_path + end + + private + + def email_oauth_config + Redmine::Configuration['email_oauth'] + end + + def get_provider_config(provider) + return nil if email_oauth_config.blank? + + email_oauth_config['providers']&.[] (provider.to_s) + end + + def build_auth_uri(provider, config, email) + redirect_uri = callback_email_oauth_url + state = session[:email_oauth_state] + + case provider.to_s + when 'google' + # Google Workspace OAuth authorize URI + uri = URI("https://accounts.google.com/o/oauth2/v2/auth") + uri.query = URI.encode_www_form({ + client_id: config['client_id'], + redirect_uri: redirect_uri, + response_type: 'code', + scope: 'https://mail.google.com/', + state: state, + login_hint: email, + access_type: 'offline', + prompt: 'consent' + }) + uri.to_s + when 'microsoft' + # Microsoft Azure AD OAuth authorize URI + tenant = config['tenant'] || 'common' + uri = URI("https://login.microsoftonline.com/#{tenant}/oauth2/v2.0/authorize") + uri.query = URI.encode_www_form({ + client_id: config['client_id'], + redirect_uri: redirect_uri, + response_type: 'code', + scope: 'https://outlook.office.com/IMAP.AccessAsUser.All offline_access', + state: state, + login_hint: email, + prompt: 'consent' + }) + uri.to_s + when 'custom' + # Custom provider dynamic authorize URL + uri = URI(config['authorize_url']) + params = { + client_id: config['client_id'], + redirect_uri: redirect_uri, + response_type: 'code', + state: state + } + params[:scope] = config['scope'] if config['scope'].present? + params[:login_hint] = email if email.present? + uri.query = URI.encode_www_form(params) + uri.to_s + else + raise "Unsupported OAuth provider: #{provider}" + end + end + + def exchange_and_save_tokens(provider, config, email, code) + token_uri = case provider.to_s + when 'google' + URI("https://oauth2.googleapis.com/token") + when 'microsoft' + tenant = config['tenant'] || 'common' + URI("https://login.microsoftonline.com/#{tenant}/oauth2/v2.0/token") + when 'custom' + URI(config['token_url']) + end + + payload = { + client_id: config['client_id'], + client_secret: config['client_secret'], + code: code, + redirect_uri: callback_email_oauth_url, + grant_type: 'authorization_code' + } + + res = Net::HTTP.post_form(token_uri, payload) + if res.is_a?(Net::HTTPSuccess) + data = JSON.parse(res.body) + + expires_in = data['expires_in'].to_i.seconds + expires_at = Time.now + expires_in + + # Find or initialize token mapping + token_record = EmailOauthToken.find_or_initialize_by(email: email) + token_record.provider = provider + token_record.access_token = data['access_token'] + token_record.refresh_token = data['refresh_token'] if data['refresh_token'].present? + token_record.expires_at = expires_at + token_record.is_valid = true + token_record.last_error = nil + + if token_record.save + flash[:notice] = l(:notice_successful_create) + else + flash[:error] = l(:error_oauth_authentication_failed, message: token_record.errors.full_messages.join(', ')) + end + else + flash[:error] = l(:error_oauth_authentication_failed, message: "[#{res.code}] #{res.body}") + end + + redirect_to email_oauth_index_path + end +end diff --git a/app/models/email_oauth_token.rb b/app/models/email_oauth_token.rb new file mode 100644 index 000000000..131569edb --- /dev/null +++ b/app/models/email_oauth_token.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +class EmailOauthToken < ApplicationRecord + include Redmine::Ciphering + + validates :email, presence: true, uniqueness: { case_sensitive: false }, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :provider, presence: true, inclusion: { in: %w[microsoft google custom] } + validates :refresh_token, presence: true + validates :expires_at, presence: true + + def access_token + read_ciphered_attribute(:access_token) + end + + def access_token=(arg) + write_ciphered_attribute(:access_token, arg) + end + + def refresh_token + read_ciphered_attribute(:refresh_token) + end + + def refresh_token=(arg) + write_ciphered_attribute(:refresh_token, arg) + end + + # Check if the access token has expired + def expired? + expires_at.nil? || expires_at <= Time.now + end + + # Check if a refresh is needed (e.g., if expired or expiring in the next 5 minutes) + def refresh_needed? + expired? || expires_at <= 5.minutes.from_now + end +end diff --git a/app/models/mailer.rb b/app/models/mailer.rb index 073b3074a..090e3704d 100644 --- a/app/models/mailer.rb +++ b/app/models/mailer.rb @@ -560,6 +560,24 @@ class Mailer < ActionMailer::Base :subject => l(:mail_subject_test_email) end + # Notifies active administrators when an OAuth token refresh fails in background. + def self.deliver_email_oauth_refresh_failure(token, error_message) + users = User.active.where(admin: true) + users.each do |user| + email_oauth_refresh_failure(user, token, error_message).deliver_later + end + end + + # Builds the OAuth refresh failure email. + def email_oauth_refresh_failure(user, token, error_message) + @user = user + @token = token + @error_message = error_message + @oauth_settings_url = url_for(controller: 'email_oauth', action: 'index') + mail to: user.mail, + subject: "[#{Setting.app_title}] OAuth 2.0 Email Account Authentication Failure" + end + # Send a test email to user. Will raise error that may occur during delivery. # # Exemple: diff --git a/app/models/setting.rb b/app/models/setting.rb index 8a974bc5b..214e1f737 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -375,6 +375,12 @@ class Setting < ApplicationRecord end.freeze end + # Returns an array containing the names of configured OAuth email providers in configuration.yml + def self.email_oauth_providers + config = Redmine::Configuration['email_oauth'] + config ? (config['providers'] || {}).keys : [] + end + private def force_utf8_strings(arg) diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb index 768dd4e6d..d85342a58 100644 --- a/app/views/admin/index.html.erb +++ b/app/views/admin/index.html.erb @@ -1,5 +1,13 @@

<%=l(:label_administration)%>

+<% if EmailOauthToken.where(is_valid: false).any? %> +
+ <%= l(:label_warning) %>: + One or more configured OAuth 2.0 incoming email accounts have failed to refresh and are currently inactive. + <%= link_to "Review OAuth status", email_oauth_index_path %> +
+<% end %> +
<%= render :partial => 'no_data' if @no_configuration_data %> <%= render :partial => 'menu' %> diff --git a/app/views/email_oauth/index.html.erb b/app/views/email_oauth/index.html.erb new file mode 100644 index 000000000..dfc370ef3 --- /dev/null +++ b/app/views/email_oauth/index.html.erb @@ -0,0 +1,44 @@ +
+<%= link_to sprite_icon('add', l(:button_add)), new_email_oauth_path, class: 'icon icon-add' %> +
+ +<%= title l(:label_oauth_authentication) %> + +<% if @tokens.empty? %> +

<%= l(:label_no_data) %>

+<% else %> + + + + + + + + + + + + + <% @tokens.each do |token| %> + + + + + + + + + <% end %> + +
<%= l(:field_mail) %><%= l(:label_provider) %><%= l(:field_status) %><%= l(:label_expires_at) %><%= l(:field_updated_on) %>
<%= token.email %><%= token.provider.to_s.capitalize %> + <% if token.is_valid? %> + <%= content_tag(:span, sprite_icon("checked"), :class => 'icon-only icon-checked') %> + <% else %> + + <%= token.last_error %> + + <% end %> + <%= format_time(token.expires_at) %><%= format_time(token.updated_at) %> + <%= delete_link destroy_email_oauth_path(token) %> +
+<% end %> diff --git a/app/views/email_oauth/new.html.erb b/app/views/email_oauth/new.html.erb new file mode 100644 index 000000000..46a177347 --- /dev/null +++ b/app/views/email_oauth/new.html.erb @@ -0,0 +1,14 @@ +<%= title [l(:label_oauth_authentication), email_oauth_index_path], l(:button_add) %> + +<%= labelled_form_for @token, :as => :email_oauth_token, :url => initiate_email_oauth_path, :html => {:id => 'email_oauth_form'} do |f| %> +
+

+ <%= f.text_field :email, :required => true, :size => 40, :placeholder => "redmine@example.net" %> + <%= l(:text_oauth_email_username_info_html) %> +

+

+ <%= f.select :provider, Setting.email_oauth_providers.map {|p| [p.capitalize, p]}, :required => true, :label => l(:label_provider) %> +

+
+ <%= submit_tag t('doorkeeper.applications.buttons.authorize') %> +<% end %> diff --git a/app/views/mailer/email_oauth_refresh_failure.html.erb b/app/views/mailer/email_oauth_refresh_failure.html.erb new file mode 100644 index 000000000..267a4b524 --- /dev/null +++ b/app/views/mailer/email_oauth_refresh_failure.html.erb @@ -0,0 +1,16 @@ +

Hello <%= @user.name %>,

+ +

An error occurred while attempting to refresh the OAuth 2.0 access token for the following incoming email account:

+ + + +

As a result, Redmine is currently unable to fetch and process incoming emails from this mailbox.

+ +

Please log in to Redmine as an administrator, review the error, and re-authorize the account:

+ +

<%= link_to l(:label_manage_oauth_emails), @oauth_settings_url %>

diff --git a/app/views/mailer/email_oauth_refresh_failure.text.erb b/app/views/mailer/email_oauth_refresh_failure.text.erb new file mode 100644 index 000000000..ed8d49ebc --- /dev/null +++ b/app/views/mailer/email_oauth_refresh_failure.text.erb @@ -0,0 +1,14 @@ +Hello <%= @user.name %>, + +An error occurred while attempting to refresh the OAuth 2.0 access token for the following incoming email account: + +Email Account: <%= @token.email %> +Provider: <%= @token.provider.capitalize %> +Error Message: <%= @error_message %> +Failed At: <%= Time.now %> + +As a result, Redmine is currently unable to fetch and process incoming emails from this mailbox. + +Please log in to Redmine as an administrator, review the error, and re-authorize the account: + +<%= @oauth_settings_url %> diff --git a/app/views/settings/_mail_handler.html.erb b/app/views/settings/_mail_handler.html.erb index d4e541e53..d90904e76 100644 --- a/app/views/settings/_mail_handler.html.erb +++ b/app/views/settings/_mail_handler.html.erb @@ -32,6 +32,25 @@

+
+

<%= l(:label_oauth_authentication) %>

+ <% if Setting.email_oauth_providers.empty? %> +
+ <%= simple_format(l(:text_email_oauth_providers_not_configured)) %> +
+ <% else %> +
+ <%= l(:setting_enabled_oauth_providers) %> +
    + <% Setting.email_oauth_providers.each do |provider| %> +
  • <%= provider.capitalize %>
  • + <% end %> +
+
+ <%= link_to l(:label_manage_oauth_emails), email_oauth_index_path, class: 'icon icon-server-authentication' %> + <% end %> +
+ <%= submit_tag l(:button_save) %> <% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 41ba8645e..24ac01dd4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -540,6 +540,7 @@ en: setting_related_issues_default_columns: Related and sub issues list defaults setting_display_related_issues_table_headers: Show table headers setting_reactions_enabled: Enable reactions + setting_enabled_oauth_providers: Enabled OAuth providers permission_add_project: Create project permission_add_subprojects: Create subprojects @@ -1004,6 +1005,8 @@ en: label_chronological_order: In chronological order label_reverse_chronological_order: In reverse chronological order label_incoming_emails: Incoming emails + label_oauth_authentication: OAuth 2.0 Authentication + label_manage_oauth_emails: Manage OAuth 2.0 Email Accounts label_generate_key: Generate a key label_issue_watchers: Watchers label_message_watchers: Watchers @@ -1197,6 +1200,8 @@ en: label_enable_default_issue_due_date_offset: Enable default due date for new issues setting_default_issue_due_date_offset: Default due date for new issues label_days_relative_to_today: days relative to today + label_provider: Provider + label_expires_at: Expires at webhook_event_created: "%{object_name} created" webhook_event_updated: "%{object_name} updated" @@ -1399,6 +1404,9 @@ en: text_oauth_copy_secret_now: Copy the secret to a safe place now, it will not be shown again. text_oauth_implicit_permissions: View your name, login and primary email address text_oauth_info_scopes: Select the scopes this application may request. The application will not be allowed to do more than what is selected here. It will also always be limited by the roles and project memberships of the user who authorized it. + text_email_oauth_providers_not_configured: "Email OAuth providers are not configured.\nPlease configure them in config/configuration.yml and restart the application to enable them." + text_oauth_email_username_info_html: Must exactly match the username parameter used in the background IMAP fetch job. + error_oauth_authentication_failed: "OAuth authentication failed: %{message}" default_role_manager: Manager default_role_developer: Developer diff --git a/config/routes.rb b/config/routes.rb index 34bae573c..15072237c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -377,6 +377,13 @@ Rails.application.routes.draw do get 'mail_handler', :to => 'mail_handler#new' post 'mail_handler', :to => 'mail_handler#index' + # OAuth incoming emails configuration + get 'admin/mail_handler/oauth', :to => 'email_oauth#index', :as => 'email_oauth_index' + get 'admin/mail_handler/oauth/new', :to => 'email_oauth#new', :as => 'new_email_oauth' + post 'admin/mail_handler/oauth/initiate', :to => 'email_oauth#initiate', :as => 'initiate_email_oauth' + get 'admin/mail_handler/oauth/callback', :to => 'email_oauth#callback', :as => 'callback_email_oauth' + delete 'admin/mail_handler/oauth/:id', :to => 'email_oauth#destroy', :as => 'destroy_email_oauth' + get 'admin', :to => 'admin#index' get 'admin/projects', :to => 'admin#projects' get 'admin/plugins', :to => 'admin#plugins' diff --git a/db/migrate/20260702055909_create_email_oauth_tokens.rb b/db/migrate/20260702055909_create_email_oauth_tokens.rb new file mode 100644 index 000000000..b19230780 --- /dev/null +++ b/db/migrate/20260702055909_create_email_oauth_tokens.rb @@ -0,0 +1,17 @@ +class CreateEmailOauthTokens < ActiveRecord::Migration[8.1] + def change + create_table :email_oauth_tokens do |t| + t.string :email, null: false + t.string :provider, null: false + t.text :access_token + t.text :refresh_token, null: false + t.datetime :expires_at, null: false + t.boolean :is_valid, default: true, null: false + t.text :last_error + + t.timestamps + end + + add_index :email_oauth_tokens, :email, unique: true + end +end diff --git a/lib/redmine/email_oauth/refresher.rb b/lib/redmine/email_oauth/refresher.rb new file mode 100644 index 000000000..f35222f4d --- /dev/null +++ b/lib/redmine/email_oauth/refresher.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +require 'net/http' +require 'uri' +require 'json' + +module Redmine + module EmailOAuth + class Refresher + class << self + def refresh!(token, force: false) + return true if !force && !token.refresh_needed? + + config = Redmine::Configuration['email_oauth'] + provider_config = config&.[]('providers')&.[](token.provider.to_s) + + if provider_config.blank? + error_msg = "Provider configuration for '#{token.provider}' is missing in configuration.yml." + handle_failure(token, error_msg) + return false + end + + token_uri = token_endpoint(token.provider, provider_config) + payload = refresh_payload(token, provider_config) + + begin + res = Net::HTTP.post_form(token_uri, payload) + if res.is_a?(Net::HTTPSuccess) + data = JSON.parse(res.body) + + expires_in = (data['expires_in'] || 3600).to_i.seconds + token.access_token = data['access_token'] + token.refresh_token = data['refresh_token'] if data['refresh_token'].present? + token.expires_at = Time.now + expires_in + token.is_valid = true + token.last_error = nil + + if token.save + true + else + error_msg = "Failed to save refreshed token: #{token.errors.full_messages.join(', ')}" + handle_failure(token, error_msg) + false + end + else + error_msg = "OAuth provider returned error [#{res.code}]: #{res.body}" + handle_failure(token, error_msg) + false + end + rescue => e + error_msg = "Exception during token refresh: #{e.message}" + handle_failure(token, error_msg) + false + end + end + + private + + def token_endpoint(provider, config) + case provider.to_s + when 'google' + URI("https://oauth2.googleapis.com/token") + when 'microsoft' + tenant = config['tenant'] || 'common' + URI("https://login.microsoftonline.com/#{tenant}/oauth2/v2.0/token") + when 'custom' + URI(config['token_url']) + else + raise "Unsupported provider: #{provider}" + end + end + + def refresh_payload(token, config) + payload = { + client_id: config['client_id'], + client_secret: config['client_secret'], + refresh_token: token.refresh_token, + grant_type: 'refresh_token' + } + + if token.provider == 'microsoft' + payload[:scope] = 'https://outlook.office.com/IMAP.AccessAsUser.All offline_access' + elsif token.provider == 'custom' && config['scope'].present? + payload[:scope] = config['scope'] + end + + payload + end + + def handle_failure(token, error_message) + token.is_valid = false + token.last_error = error_message + token.save(validate: false) # Skip validation if we are just marking as invalid + + # Send email alert to system administrators + begin + Mailer.deliver_email_oauth_refresh_failure(token, error_message) + rescue => e + Rails.logger.error "Failed to send OAuth refresh failure email: #{e.message}" + end + end + end + end + end +end diff --git a/lib/redmine/imap.rb b/lib/redmine/imap.rb index 77b8da80f..c751566f3 100644 --- a/lib/redmine/imap.rb +++ b/lib/redmine/imap.rb @@ -18,6 +18,7 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. require 'net/imap' +require_relative 'email_oauth/refresher' module Redmine module IMAP @@ -41,7 +42,19 @@ module Redmine if starttls imap.starttls end - imap.login(imap_options[:username], imap_options[:password]) unless imap_options[:username].nil? + if imap_options[:username].present? + token_record = EmailOauthToken.find_by(email: imap_options[:username]) + if token_record + # Refresh token if needed + if Redmine::EmailOAuth::Refresher.refresh!(token_record) + imap.authenticate('XOAUTH2', imap_options[:username], token_record.access_token) + else + raise "OAuth 2.0 authentication failed for #{imap_options[:username]}: #{token_record.last_error}" + end + else + imap.login(imap_options[:username], imap_options[:password]) + end + end imap.select(folder) imap.uid_search(['NOT', 'SEEN']).each do |uid| msg = imap.uid_fetch(uid, 'RFC822')[0].attr['RFC822'] diff --git a/test/functional/admin_controller_test.rb b/test/functional/admin_controller_test.rb index 7f9a52db7..6f3e3e37a 100644 --- a/test/functional/admin_controller_test.rb +++ b/test/functional/admin_controller_test.rb @@ -31,6 +31,19 @@ class AdminControllerTest < Redmine::ControllerTest assert_select 'div#admin-menu a.icon-webhook[href="/admin/webhooks"]' end + def test_index_shows_oauth_warning_banner_when_token_is_invalid + EmailOauthToken.delete_all + EmailOauthToken.create!( + email: 'bad-token@example.net', + provider: 'microsoft', + refresh_token: 'refresh', + expires_at: 10.minutes.ago, + is_valid: false + ) + get :index + assert_select 'div.flash.error', :text => /One or more configured OAuth 2.0 incoming email accounts/ + end + def test_index_with_no_configuration_data delete_configuration_data get :index diff --git a/test/functional/email_oauth_controller_test.rb b/test/functional/email_oauth_controller_test.rb new file mode 100644 index 000000000..0e77cbd8c --- /dev/null +++ b/test/functional/email_oauth_controller_test.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +require_relative '../test_helper' + +class EmailOauthControllerTest < Redmine::ControllerTest + def setup + User.current = nil + @request.session[:user_id] = 1 # Admin User + EmailOauthToken.delete_all + + # Configure mock OAuth settings + @mock_config = { + 'providers' => { + 'google' => { + 'client_id' => 'google_id', + 'client_secret' => 'google_secret' + }, + 'microsoft' => { + 'client_id' => 'ms_id', + 'client_secret' => 'ms_secret', + 'tenant' => 'common' + } + } + } + end + + def test_index_should_require_admin + @request.session[:user_id] = nil # Anonymous + get :index + assert_response :redirect + assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fadmin%2Fmail_handler%2Foauth' + + @request.session[:user_id] = 2 # Non-admin user + get :index + assert_response :forbidden + end + + def test_index + EmailOauthToken.create!( + email: 'redmine@example.net', + provider: 'google', + refresh_token: 'refresh', + expires_at: 1.hour.from_now + ) + + get :index + assert_response :success + assert_select 'tr td.name strong', text: 'redmine@example.net' + end + + def test_new + Redmine::Configuration.with 'email_oauth' => @mock_config do + get :new + assert_response :success + assert_select 'select[name=?]', 'email_oauth_token[provider]' do + assert_select 'option[value=google]', text: 'Google' + assert_select 'option[value=microsoft]', text: 'Microsoft' + end + end + end + + def test_initiate_redirects_to_google + Redmine::Configuration.with 'email_oauth' => @mock_config do + post :initiate, params: { + email_oauth_token: { + email: 'test@example.net', + provider: 'google' + } + } + + # Verify redirection to Google's authorize URL + assert_response :redirect + assert_match %r{\Ahttps://accounts\.google\.com/o/oauth2/v2/auth}, @response.redirect_url + assert_match %r{client_id=google_id}, @response.redirect_url + assert_match %r{scope=https%3A%2F%2Fmail\.google\.com%2F}, @response.redirect_url + assert_match %r{login_hint=test%40example\.net}, @response.redirect_url + + # Verify session is populated for callback verification + assert_equal 'test@example.net', session[:email_oauth_email] + assert_equal 'google', session[:email_oauth_provider] + assert_not_nil session[:email_oauth_state] + end + end + + def test_initiate_redirects_to_microsoft + Redmine::Configuration.with 'email_oauth' => @mock_config do + post :initiate, params: { + email_oauth_token: { + email: 'ms@example.net', + provider: 'microsoft' + } + } + + # Verify redirection to MS Azure authorize URL + assert_response :redirect + assert_match %r{\Ahttps://login\.microsoftonline\.com/common/oauth2/v2\.0/authorize}, @response.redirect_url + assert_match %r{client_id=ms_id}, @response.redirect_url + assert_match %r{scope=https%3A%2F%2Foutlook\.office\.com%2FIMAP\.AccessAsUser\.All\+offline_access}, @response.redirect_url + end + end + + def test_callback_success + Redmine::Configuration.with 'email_oauth' => @mock_config do + # Set up active authorization session state + state_token = 'securestate' + @request.session[:email_oauth_state] = state_token + @request.session[:email_oauth_email] = 'success@example.net' + @request.session[:email_oauth_provider] = 'google' + + # Mock successful token exchange response from Google + mock_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + mock_response.stubs(:body).returns({ + access_token: 'mock_access', + refresh_token: 'mock_refresh', + expires_in: 3600 + }.to_json) + + Net::HTTP.expects(:post_form).with( + URI("https://oauth2.googleapis.com/token"), + { + client_id: 'google_id', + client_secret: 'google_secret', + code: 'auth_code_123', + redirect_uri: 'http://test.host/admin/mail_handler/oauth/callback', + grant_type: 'authorization_code' + } + ).returns(mock_response) + + get :callback, params: { + state: state_token, + code: 'auth_code_123' + } + + # Should redirect to index with success message + assert_response :redirect + assert_redirected_to email_oauth_index_path + assert_equal I18n.t(:notice_successful_create), flash[:notice] + + # Verify record was created/updated in the database + token = EmailOauthToken.find_by(email: 'success@example.net') + assert_not_nil token + assert_equal 'google', token.provider + assert_equal 'mock_access', token.access_token + assert_equal 'mock_refresh', token.refresh_token + assert token.expires_at > Time.now + assert token.is_valid? + end + end + + def test_destroy + token = EmailOauthToken.create!( + email: 'delete_me@example.net', + provider: 'google', + refresh_token: 'refresh', + expires_at: 1.hour.from_now + ) + + assert_difference 'EmailOauthToken.count', -1 do + delete :destroy, params: { id: token.id } + end + + assert_redirected_to email_oauth_index_path + assert_equal I18n.t(:notice_successful_delete), flash[:notice] + end +end diff --git a/test/functional/settings_controller_test.rb b/test/functional/settings_controller_test.rb index 159fa1c97..17038249a 100644 --- a/test/functional/settings_controller_test.rb +++ b/test/functional/settings_controller_test.rb @@ -327,4 +327,31 @@ class SettingsControllerTest < Redmine::ControllerTest assert_equal '1', Setting.mail_handler_enable_regex_delimiters assert_equal 'On .*, .* at .*, .* <.*> wrote:', Setting.mail_handler_body_delimiters end + + def test_get_index_with_mail_handler_tab_renders_oauth_providers + @request.session[:user_id] = 1 # Admin + mock_config = { + 'providers' => { + 'google' => { 'client_id' => 'g', 'client_secret' => 's' }, + 'microsoft' => { 'client_id' => 'm', 'client_secret' => 's' } + } + } + + Redmine::Configuration.with 'email_oauth' => mock_config do + get :index, :params => { :tab => 'mail_handler' } + assert_response :success + assert_select 'legend', :text => 'Enabled OAuth providers' + assert_select 'fieldset.settings ul li span', :text => 'Google' + assert_select 'fieldset.settings ul li span', :text => 'Microsoft' + end + end + + def test_get_index_with_mail_handler_tab_renders_no_oauth_providers_message + @request.session[:user_id] = 1 # Admin + Redmine::Configuration.with 'email_oauth' => nil do + get :index, :params => { :tab => 'mail_handler' } + assert_response :success + assert_select 'div.nodata', :text => /Email OAuth providers are not configured/ + end + end end diff --git a/test/unit/email_oauth_refresher_test.rb b/test/unit/email_oauth_refresher_test.rb new file mode 100644 index 000000000..f9f4b8173 --- /dev/null +++ b/test/unit/email_oauth_refresher_test.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +require_relative '../test_helper' +require 'redmine/email_oauth/refresher' + +class EmailOauthRefresherTest < ActiveSupport::TestCase + fixtures :users + + def setup + EmailOauthToken.delete_all + @token = EmailOauthToken.create!( + email: 'oauth-test@example.net', + provider: 'google', + access_token: 'initial_access_token', + refresh_token: 'initial_refresh_token', + expires_at: 10.minutes.ago, + is_valid: true + ) + # Clear any deliveries + ActionMailer::Base.deliveries.clear + end + + def test_refresh_not_needed + @token.update!(expires_at: 1.hour.from_now) + # No Net::HTTP requests should be made + Net::HTTP.expects(:post_form).never + assert Redmine::EmailOAuth::Refresher.refresh!(@token) + assert @token.is_valid? + assert_nil @token.last_error + end + + def test_refresh_with_missing_config + Redmine::Configuration.with 'email_oauth' => nil do + assert_not Redmine::EmailOAuth::Refresher.refresh!(@token) + @token.reload + assert_not @token.is_valid? + assert_match /missing in configuration.yml/, @token.last_error + assert_equal 1, ActionMailer::Base.deliveries.size + end + end + + def test_refresh_success + config = { + 'providers' => { + 'google' => { + 'client_id' => 'google_id', + 'client_secret' => 'google_secret' + } + } + } + + mock_response = mock('response') + mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) + mock_response.stubs(:body).returns({ + 'access_token' => 'new_access_token', + 'expires_in' => 3600 + }.to_json) + + Redmine::Configuration.with 'email_oauth' => config do + Net::HTTP.expects(:post_form).with( + URI("https://oauth2.googleapis.com/token"), + { + client_id: 'google_id', + client_secret: 'google_secret', + refresh_token: 'initial_refresh_token', + grant_type: 'refresh_token' + } + ).returns(mock_response) + + assert Redmine::EmailOAuth::Refresher.refresh!(@token) + @token.reload + assert_equal 'new_access_token', @token.access_token + assert @token.is_valid? + assert_nil @token.last_error + assert_equal 0, ActionMailer::Base.deliveries.size + end + end + + def test_refresh_custom_provider_success + @token.update!(provider: 'custom', refresh_token: 'custom_refresh') + config = { + 'providers' => { + 'custom' => { + 'client_id' => 'custom_id', + 'client_secret' => 'custom_secret', + 'token_url' => 'https://oauth.custom.local/token', + 'scope' => 'custom_scope' + } + } + } + + mock_response = mock('response') + mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) + mock_response.stubs(:body).returns({ + 'access_token' => 'new_custom_access_token', + 'expires_in' => 1800 + }.to_json) + + Redmine::Configuration.with 'email_oauth' => config do + Net::HTTP.expects(:post_form).with( + URI("https://oauth.custom.local/token"), + { + client_id: 'custom_id', + client_secret: 'custom_secret', + refresh_token: 'custom_refresh', + grant_type: 'refresh_token', + scope: 'custom_scope' + } + ).returns(mock_response) + + assert Redmine::EmailOAuth::Refresher.refresh!(@token) + @token.reload + assert_equal 'new_custom_access_token', @token.access_token + assert @token.is_valid? + assert_nil @token.last_error + end + end + + def test_refresh_failure + config = { + 'providers' => { + 'google' => { + 'client_id' => 'google_id', + 'client_secret' => 'google_secret' + } + } + } + + mock_response = mock('response') + mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(false) + mock_response.stubs(:code).returns('400') + mock_response.stubs(:body).returns('{"error":"invalid_grant"}') + + Redmine::Configuration.with 'email_oauth' => config do + Net::HTTP.expects(:post_form).returns(mock_response) + + assert_not Redmine::EmailOAuth::Refresher.refresh!(@token) + @token.reload + assert_not @token.is_valid? + assert_match /invalid_grant/, @token.last_error + assert_equal 1, ActionMailer::Base.deliveries.size + + # Check email delivery content + mail = ActionMailer::Base.deliveries.first + assert_match /OAuth 2.0 Email Account Authentication Failure/, mail.subject + assert_match /oauth-test@example.net/, mail.body.encoded + end + end + + def test_imap_integration_with_oauth + @token.update!(expires_at: 1.hour.from_now) + + mock_imap = mock('imap') + Net::IMAP.expects(:new).with('127.0.0.1', port: '143', ssl: false).returns(mock_imap) + mock_imap.expects(:authenticate).with('XOAUTH2', 'oauth-test@example.net', 'initial_access_token') + mock_imap.expects(:select).with('INBOX') + mock_imap.expects(:uid_search).with(['NOT', 'SEEN']).returns([]) + mock_imap.expects(:expunge) + mock_imap.expects(:logout) + mock_imap.expects(:disconnect) + + Redmine::IMAP.check({ + host: '127.0.0.1', + port: '143', + username: 'oauth-test@example.net' + }) + end + + def test_imap_integration_fallback_to_login + mock_imap = mock('imap') + Net::IMAP.expects(:new).with('127.0.0.1', port: '143', ssl: false).returns(mock_imap) + mock_imap.expects(:login).with('plain-user@example.net', 'password') + mock_imap.expects(:select).with('INBOX') + mock_imap.expects(:uid_search).with(['NOT', 'SEEN']).returns([]) + mock_imap.expects(:expunge) + mock_imap.expects(:logout) + mock_imap.expects(:disconnect) + + Redmine::IMAP.check({ + host: '127.0.0.1', + port: '143', + username: 'plain-user@example.net', + password: 'password' + }) + end +end diff --git a/test/unit/email_oauth_token_test.rb b/test/unit/email_oauth_token_test.rb new file mode 100644 index 000000000..2919cdc82 --- /dev/null +++ b/test/unit/email_oauth_token_test.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +require_relative '../test_helper' + +class EmailOauthTokenTest < ActiveSupport::TestCase + def setup + EmailOauthToken.delete_all + end + + def test_validations + # Valid record + token = EmailOauthToken.new( + email: 'redmine@example.net', + provider: 'microsoft', + refresh_token: 'valid_refresh_token', + expires_at: 1.hour.from_now + ) + assert token.valid? + + # Missing email + token.email = nil + assert_not token.valid? + assert_includes token.errors[:email], "cannot be blank" + + # Invalid email format + token.email = 'not-an-email' + assert_not token.valid? + + # Missing provider + token.email = 'redmine@example.net' + token.provider = nil + assert_not token.valid? + + # Invalid provider + token.provider = 'invalid_provider' + assert_not token.valid? + + # Duplicate email (case-insensitive) + EmailOauthToken.create!( + email: 'redmine@example.net', + provider: 'microsoft', + refresh_token: 'token1', + expires_at: 1.hour.from_now + ) + duplicate = EmailOauthToken.new( + email: 'REDMINE@example.net', + provider: 'google', + refresh_token: 'token2', + expires_at: 1.hour.from_now + ) + assert_not duplicate.valid? + assert_includes duplicate.errors[:email], "has already been taken" + end + + def test_ciphering + Redmine::Configuration.with 'database_cipher_key' => 'secret' do + token = EmailOauthToken.create!( + email: 'redmine@example.net', + provider: 'google', + access_token: 'secret_access', + refresh_token: 'secret_refresh', + expires_at: 1.hour.from_now + ) + + # In-memory reading should be transparently decrypted + assert_equal 'secret_access', token.access_token + assert_equal 'secret_refresh', token.refresh_token + + # Reading raw database attributes should show encrypted values (with 'aes-256-cbc:') + raw_record = ActiveRecord::Base.connection.select_one( + "SELECT access_token, refresh_token FROM email_oauth_tokens WHERE id = #{token.id}" + ) + assert_match /\Aaes-256-cbc:/, raw_record['access_token'] + assert_match /\Aaes-256-cbc:/, raw_record['refresh_token'] + end + end + + def test_expired_and_refresh_needed + token = EmailOauthToken.new( + email: 'redmine@example.net', + provider: 'microsoft', + refresh_token: 'refresh', + expires_at: 10.minutes.ago + ) + assert token.expired? + assert token.refresh_needed? + + token.expires_at = 2.minutes.from_now + assert_not token.expired? + assert token.refresh_needed? # within 5 minutes grace period + + token.expires_at = 10.minutes.from_now + assert_not token.expired? + assert_not token.refresh_needed? + end +end -- 2.54.0 (Apple Git-157)