Project

General

Profile

Feature #43023 » 0001-Implement-OAuth-2.0-XOAUTH2-support-for-incoming-IMA.patch

Marius BĂLTEANU, 2026-10-07 22:49

View differences:

app/controllers/email_oauth_controller.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require 'net/http'
21
require 'uri'
22
require 'json'
23

  
24
class EmailOauthController < ApplicationController
25
  layout 'admin'
26
  before_action :require_admin
27

  
28
  def index
29
    @tokens = EmailOauthToken.order(:email)
30
  end
31

  
32
  def new
33
    @token = EmailOauthToken.new
34
    if Setting.email_oauth_providers.empty?
35
      flash[:error] = l(:text_email_oauth_providers_not_configured)
36
      redirect_to email_oauth_index_path
37
    end
38
  end
39

  
40
  def initiate
41
    email = params[:email_oauth_token][:email]
42
    provider = params[:email_oauth_token][:provider]
43

  
44
    provider_config = get_provider_config(provider)
45
    if provider_config.blank?
46
      flash[:error] = l(:error_oauth_authentication_failed, message: "Provider '#{provider}' is not configured in configuration.yml.")
47
      redirect_to new_email_oauth_path and return
48
    end
49

  
50
    # Build authorization URL
51
    auth_uri = build_auth_uri(provider, provider_config, email)
52

  
53
    # Store state in session to prevent CSRF / verify callback
54
    session[:email_oauth_state] = SecureRandom.hex(16)
55
    session[:email_oauth_email] = email
56
    session[:email_oauth_provider] = provider
57

  
58
    redirect_to auth_uri, allow_other_host: true
59
  end
60

  
61
  def callback
62
    state = params[:state]
63
    code = params[:code]
64
    error = params[:error]
65

  
66
    if error.present?
67
      flash[:error] = l(:error_oauth_authentication_failed, message: error)
68
      redirect_to email_oauth_index_path and return
69
    end
70

  
71
    unless Rails.env.development?
72
      if state.blank? || state != session[:email_oauth_state]
73
        flash[:error] = l(:error_oauth_authentication_failed, message: "OAuth verification state mismatch. Potential CSRF request.")
74
        redirect_to email_oauth_index_path and return
75
      end
76
    end
77

  
78
    email = session.delete(:email_oauth_email)
79
    provider = session.delete(:email_oauth_provider)
80
    session.delete(:email_oauth_state)
81

  
82
    provider_config = get_provider_config(provider)
83
    if provider_config.blank? || email.blank?
84
      flash[:error] = l(:error_oauth_authentication_failed, message: "Invalid session state or missing provider configuration.")
85
      redirect_to email_oauth_index_path and return
86
    end
87

  
88
    # Exchange authorization code for tokens
89
    exchange_and_save_tokens(provider, provider_config, email, code)
90
  end
91

  
92
  def destroy
93
    @token = EmailOauthToken.find(params[:id])
94
    if @token.destroy
95
      flash[:notice] = l(:notice_successful_delete)
96
    else
97
      flash[:error] = l(:error_oauth_authentication_failed, message: "Failed to delete OAuth token mapping.")
98
    end
99
    redirect_to email_oauth_index_path
100
  end
101

  
102
  private
103

  
104
  def email_oauth_config
105
    Redmine::Configuration['email_oauth']
106
  end
107

  
108
  def get_provider_config(provider)
109
    return nil if email_oauth_config.blank?
110

  
111
    email_oauth_config['providers']&.[] (provider.to_s)
112
  end
113

  
114
  def build_auth_uri(provider, config, email)
115
    redirect_uri = callback_email_oauth_url
116
    state = session[:email_oauth_state]
117

  
118
    case provider.to_s
119
    when 'google'
120
      # Google Workspace OAuth authorize URI
121
      uri = URI("https://accounts.google.com/o/oauth2/v2/auth")
122
      uri.query = URI.encode_www_form({
123
                                        client_id: config['client_id'],
124
        redirect_uri: redirect_uri,
125
        response_type: 'code',
126
        scope: 'https://mail.google.com/',
127
        state: state,
128
        login_hint: email,
129
        access_type: 'offline',
130
        prompt: 'consent'
131
                                      })
132
      uri.to_s
133
    when 'microsoft'
134
      # Microsoft Azure AD OAuth authorize URI
135
      tenant = config['tenant'] || 'common'
136
      uri = URI("https://login.microsoftonline.com/#{tenant}/oauth2/v2.0/authorize")
137
      uri.query = URI.encode_www_form({
138
                                        client_id: config['client_id'],
139
        redirect_uri: redirect_uri,
140
        response_type: 'code',
141
        scope: 'https://outlook.office.com/IMAP.AccessAsUser.All offline_access',
142
        state: state,
143
        login_hint: email,
144
        prompt: 'consent'
145
                                      })
146
      uri.to_s
147
    when 'custom'
148
      # Custom provider dynamic authorize URL
149
      uri = URI(config['authorize_url'])
150
      params = {
151
        client_id: config['client_id'],
152
        redirect_uri: redirect_uri,
153
        response_type: 'code',
154
        state: state
155
      }
156
      params[:scope] = config['scope'] if config['scope'].present?
157
      params[:login_hint] = email if email.present?
158
      uri.query = URI.encode_www_form(params)
159
      uri.to_s
160
    else
161
      raise "Unsupported OAuth provider: #{provider}"
162
    end
163
  end
164

  
165
  def exchange_and_save_tokens(provider, config, email, code)
166
    token_uri = case provider.to_s
167
                when 'google'
168
                  URI("https://oauth2.googleapis.com/token")
169
                when 'microsoft'
170
                  tenant = config['tenant'] || 'common'
171
                  URI("https://login.microsoftonline.com/#{tenant}/oauth2/v2.0/token")
172
                when 'custom'
173
                  URI(config['token_url'])
174
                end
175

  
176
    payload = {
177
      client_id: config['client_id'],
178
      client_secret: config['client_secret'],
179
      code: code,
180
      redirect_uri: callback_email_oauth_url,
181
      grant_type: 'authorization_code'
182
    }
183

  
184
    res = Net::HTTP.post_form(token_uri, payload)
185
    if res.is_a?(Net::HTTPSuccess)
186
      data = JSON.parse(res.body)
187

  
188
      expires_in = data['expires_in'].to_i.seconds
189
      expires_at = Time.now + expires_in
190

  
191
      # Find or initialize token mapping
192
      token_record = EmailOauthToken.find_or_initialize_by(email: email)
193
      token_record.provider = provider
194
      token_record.access_token = data['access_token']
195
      token_record.refresh_token = data['refresh_token'] if data['refresh_token'].present?
196
      token_record.expires_at = expires_at
197
      token_record.is_valid = true
198
      token_record.last_error = nil
199

  
200
      if token_record.save
201
        flash[:notice] = l(:notice_successful_create)
202
      else
203
        flash[:error] = l(:error_oauth_authentication_failed, message: token_record.errors.full_messages.join(', '))
204
      end
205
    else
206
      flash[:error] = l(:error_oauth_authentication_failed, message: "[#{res.code}] #{res.body}")
207
    end
208

  
209
    redirect_to email_oauth_index_path
210
  end
211
end
app/models/email_oauth_token.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
class EmailOauthToken < ApplicationRecord
21
  include Redmine::Ciphering
22

  
23
  validates :email, presence: true, uniqueness: { case_sensitive: false }, format: { with: URI::MailTo::EMAIL_REGEXP }
24
  validates :provider, presence: true, inclusion: { in: %w[microsoft google custom] }
25
  validates :refresh_token, presence: true
26
  validates :expires_at, presence: true
27

  
28
  def access_token
29
    read_ciphered_attribute(:access_token)
30
  end
31

  
32
  def access_token=(arg)
33
    write_ciphered_attribute(:access_token, arg)
34
  end
35

  
36
  def refresh_token
37
    read_ciphered_attribute(:refresh_token)
38
  end
39

  
40
  def refresh_token=(arg)
41
    write_ciphered_attribute(:refresh_token, arg)
42
  end
43

  
44
  # Check if the access token has expired
45
  def expired?
46
    expires_at.nil? || expires_at <= Time.now
47
  end
48

  
49
  # Check if a refresh is needed (e.g., if expired or expiring in the next 5 minutes)
50
  def refresh_needed?
51
    expired? || expires_at <= 5.minutes.from_now
52
  end
53
end
app/models/mailer.rb
560 560
      :subject => l(:mail_subject_test_email)
561 561
  end
562 562

  
563
  # Notifies active administrators when an OAuth token refresh fails in background.
564
  def self.deliver_email_oauth_refresh_failure(token, error_message)
565
    users = User.active.where(admin: true)
566
    users.each do |user|
567
      email_oauth_refresh_failure(user, token, error_message).deliver_later
568
    end
569
  end
570

  
571
  # Builds the OAuth refresh failure email.
572
  def email_oauth_refresh_failure(user, token, error_message)
573
    @user = user
574
    @token = token
575
    @error_message = error_message
576
    @oauth_settings_url = url_for(controller: 'email_oauth', action: 'index')
577
    mail to: user.mail,
578
         subject: "[#{Setting.app_title}] OAuth 2.0 Email Account Authentication Failure"
579
  end
580

  
563 581
  # Send a test email to user. Will raise error that may occur during delivery.
564 582
  #
565 583
  # Exemple:
app/models/setting.rb
375 375
    end.freeze
376 376
  end
377 377

  
378
  # Returns an array containing the names of configured OAuth email providers in configuration.yml
379
  def self.email_oauth_providers
380
    config = Redmine::Configuration['email_oauth']
381
    config ? (config['providers'] || {}).keys : []
382
  end
383

  
378 384
  private
379 385

  
380 386
  def force_utf8_strings(arg)
app/views/admin/index.html.erb
1 1
<h2><%=l(:label_administration)%></h2>
2 2

  
3
<% if EmailOauthToken.where(is_valid: false).any? %>
4
  <div class="flash error">
5
    <strong><%= l(:label_warning) %>:</strong>
6
    One or more configured OAuth 2.0 incoming email accounts have failed to refresh and are currently inactive.
7
    <%= link_to "Review OAuth status", email_oauth_index_path %>
8
  </div>
9
<% end %>
10

  
3 11
<div id="admin-index">
4 12
  <%= render :partial => 'no_data' if @no_configuration_data %>
5 13
  <%= render :partial => 'menu' %>
app/views/email_oauth/index.html.erb
1
<div class="contextual">
2
<%= link_to sprite_icon('add', l(:button_add)), new_email_oauth_path, class: 'icon icon-add' %>
3
</div>
4

  
5
<%= title l(:label_oauth_authentication) %>
6

  
7
<% if @tokens.empty? %>
8
  <p class="nodata"><%= l(:label_no_data) %></p>
9
<% else %>
10
  <table class="list">
11
    <thead>
12
      <tr>
13
        <th><%= l(:field_mail) %></th>
14
        <th><%= l(:label_provider) %></th>
15
        <th><%= l(:field_status) %></th>
16
        <th><%= l(:label_expires_at) %></th>
17
        <th><%= l(:field_updated_on) %></th>
18
        <th></th>
19
      </tr>
20
    </thead>
21
    <tbody>
22
      <% @tokens.each do |token| %>
23
        <tr class="<%= cycle('odd', 'even') %>">
24
          <td class="name"><strong><%= token.email %></strong></td>
25
          <td><%= token.provider.to_s.capitalize %></td>
26
          <td>
27
            <% if token.is_valid? %>
28
              <%= content_tag(:span, sprite_icon("checked"), :class => 'icon-only icon-checked') %>
29
            <% else %>
30
              <span class="icon icon-warning">
31
                <%= token.last_error %>
32
              </span>
33
            <% end %>
34
          </td>
35
          <td><%= format_time(token.expires_at) %></td>
36
          <td><%= format_time(token.updated_at) %></td>
37
          <td class="buttons">
38
            <%= delete_link destroy_email_oauth_path(token) %>
39
          </td>
40
        </tr>
41
      <% end %>
42
    </tbody>
43
  </table>
44
<% end %>
app/views/email_oauth/new.html.erb
1
<%= title [l(:label_oauth_authentication), email_oauth_index_path], l(:button_add) %>
2

  
3
<%= labelled_form_for @token, :as => :email_oauth_token, :url => initiate_email_oauth_path, :html => {:id => 'email_oauth_form'} do |f| %>
4
  <div class="box tabular">
5
    <p>
6
      <%= f.text_field :email, :required => true, :size => 40, :placeholder => "redmine@example.net" %>
7
      <em class="info"><%= l(:text_oauth_email_username_info_html) %></em>
8
    </p>
9
    <p>
10
      <%= f.select :provider, Setting.email_oauth_providers.map {|p| [p.capitalize, p]}, :required => true, :label => l(:label_provider) %>
11
    </p>
12
  </div>
13
  <%= submit_tag t('doorkeeper.applications.buttons.authorize') %>
14
<% end %>
app/views/mailer/email_oauth_refresh_failure.html.erb
1
<p>Hello <%= @user.name %>,</p>
2

  
3
<p>An error occurred while attempting to refresh the OAuth 2.0 access token for the following incoming email account:</p>
4

  
5
<ul>
6
  <li><strong>Email Account:</strong> <%= @token.email %></li>
7
  <li><strong>Provider:</strong> <%= @token.provider.capitalize %></li>
8
  <li><strong>Error Message:</strong> <%= @error_message %></li>
9
  <li><strong>Failed At:</strong> <%= Time.now %></li>
10
</ul>
11

  
12
<p>As a result, Redmine is currently unable to fetch and process incoming emails from this mailbox.</p>
13

  
14
<p>Please log in to Redmine as an administrator, review the error, and re-authorize the account:</p>
15

  
16
<p><%= link_to l(:label_manage_oauth_emails), @oauth_settings_url %></p>
app/views/mailer/email_oauth_refresh_failure.text.erb
1
Hello <%= @user.name %>,
2

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

  
5
Email Account: <%= @token.email %>
6
Provider: <%= @token.provider.capitalize %>
7
Error Message: <%= @error_message %>
8
Failed At: <%= Time.now %>
9

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

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

  
14
<%= @oauth_settings_url %>
app/views/settings/_mail_handler.html.erb
32 32
</p>
33 33
</div>
34 34

  
35
<div class="box tabular settings">
36
  <h3><%= l(:label_oauth_authentication) %></h3>
37
  <% if Setting.email_oauth_providers.empty? %>
38
    <div class="nodata">
39
      <%= simple_format(l(:text_email_oauth_providers_not_configured)) %>
40
    </div>
41
  <% else %>
42
    <fieldset class="box settings">
43
    <legend><%= l(:setting_enabled_oauth_providers) %></legend>
44
    <ul>
45
    <% Setting.email_oauth_providers.each do |provider| %>
46
      <li><span><%= provider.capitalize %></span></li>
47
    <% end %>
48
    </ul>
49
    </fieldset>
50
      <%= link_to l(:label_manage_oauth_emails), email_oauth_index_path, class: 'icon icon-server-authentication' %>
51
  <% end %>
52
</div>
53

  
35 54
<%= submit_tag l(:button_save) %>
36 55

  
37 56
<% end %>
config/locales/en.yml
540 540
  setting_related_issues_default_columns: Related and sub issues list defaults
541 541
  setting_display_related_issues_table_headers: Show table headers
542 542
  setting_reactions_enabled: Enable reactions
543
  setting_enabled_oauth_providers: Enabled OAuth providers
543 544

  
544 545
  permission_add_project: Create project
545 546
  permission_add_subprojects: Create subprojects
......
1004 1005
  label_chronological_order: In chronological order
1005 1006
  label_reverse_chronological_order: In reverse chronological order
1006 1007
  label_incoming_emails: Incoming emails
1008
  label_oauth_authentication: OAuth 2.0 Authentication
1009
  label_manage_oauth_emails: Manage OAuth 2.0 Email Accounts
1007 1010
  label_generate_key: Generate a key
1008 1011
  label_issue_watchers: Watchers
1009 1012
  label_message_watchers: Watchers
......
1197 1200
  label_enable_default_issue_due_date_offset: Enable default due date for new issues
1198 1201
  setting_default_issue_due_date_offset: Default due date for new issues
1199 1202
  label_days_relative_to_today: days relative to today
1203
  label_provider: Provider
1204
  label_expires_at: Expires at
1200 1205

  
1201 1206
  webhook_event_created: "%{object_name} created"
1202 1207
  webhook_event_updated: "%{object_name} updated"
......
1399 1404
  text_oauth_copy_secret_now: Copy the secret to a safe place now, it will not be shown again.
1400 1405
  text_oauth_implicit_permissions: View your name, login and primary email address
1401 1406
  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.
1407
  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."
1408
  text_oauth_email_username_info_html: Must exactly match the <code>username</code> parameter used in the background IMAP fetch job.
1409
  error_oauth_authentication_failed: "OAuth authentication failed: %{message}"
1402 1410

  
1403 1411
  default_role_manager: Manager
1404 1412
  default_role_developer: Developer
config/routes.rb
377 377
  get  'mail_handler', :to => 'mail_handler#new'
378 378
  post 'mail_handler', :to => 'mail_handler#index'
379 379

  
380
  # OAuth incoming emails configuration
381
  get 'admin/mail_handler/oauth', :to => 'email_oauth#index', :as => 'email_oauth_index'
382
  get 'admin/mail_handler/oauth/new', :to => 'email_oauth#new', :as => 'new_email_oauth'
383
  post 'admin/mail_handler/oauth/initiate', :to => 'email_oauth#initiate', :as => 'initiate_email_oauth'
384
  get 'admin/mail_handler/oauth/callback', :to => 'email_oauth#callback', :as => 'callback_email_oauth'
385
  delete 'admin/mail_handler/oauth/:id', :to => 'email_oauth#destroy', :as => 'destroy_email_oauth'
386

  
380 387
  get 'admin', :to => 'admin#index'
381 388
  get 'admin/projects', :to => 'admin#projects'
382 389
  get 'admin/plugins', :to => 'admin#plugins'
db/migrate/20260702055909_create_email_oauth_tokens.rb
1
class CreateEmailOauthTokens < ActiveRecord::Migration[8.1]
2
  def change
3
    create_table :email_oauth_tokens do |t|
4
      t.string :email, null: false
5
      t.string :provider, null: false
6
      t.text :access_token
7
      t.text :refresh_token, null: false
8
      t.datetime :expires_at, null: false
9
      t.boolean :is_valid, default: true, null: false
10
      t.text :last_error
11

  
12
      t.timestamps
13
    end
14

  
15
    add_index :email_oauth_tokens, :email, unique: true
16
  end
17
end
lib/redmine/email_oauth/refresher.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require 'net/http'
21
require 'uri'
22
require 'json'
23

  
24
module Redmine
25
  module EmailOAuth
26
    class Refresher
27
      class << self
28
        def refresh!(token, force: false)
29
          return true if !force && !token.refresh_needed?
30

  
31
          config = Redmine::Configuration['email_oauth']
32
          provider_config = config&.[]('providers')&.[](token.provider.to_s)
33

  
34
          if provider_config.blank?
35
            error_msg = "Provider configuration for '#{token.provider}' is missing in configuration.yml."
36
            handle_failure(token, error_msg)
37
            return false
38
          end
39

  
40
          token_uri = token_endpoint(token.provider, provider_config)
41
          payload = refresh_payload(token, provider_config)
42

  
43
          begin
44
            res = Net::HTTP.post_form(token_uri, payload)
45
            if res.is_a?(Net::HTTPSuccess)
46
              data = JSON.parse(res.body)
47
              
48
              expires_in = (data['expires_in'] || 3600).to_i.seconds
49
              token.access_token = data['access_token']
50
              token.refresh_token = data['refresh_token'] if data['refresh_token'].present?
51
              token.expires_at = Time.now + expires_in
52
              token.is_valid = true
53
              token.last_error = nil
54

  
55
              if token.save
56
                true
57
              else
58
                error_msg = "Failed to save refreshed token: #{token.errors.full_messages.join(', ')}"
59
                handle_failure(token, error_msg)
60
                false
61
              end
62
            else
63
              error_msg = "OAuth provider returned error [#{res.code}]: #{res.body}"
64
              handle_failure(token, error_msg)
65
              false
66
            end
67
          rescue => e
68
            error_msg = "Exception during token refresh: #{e.message}"
69
            handle_failure(token, error_msg)
70
            false
71
          end
72
        end
73

  
74
        private
75

  
76
        def token_endpoint(provider, config)
77
          case provider.to_s
78
          when 'google'
79
            URI("https://oauth2.googleapis.com/token")
80
          when 'microsoft'
81
            tenant = config['tenant'] || 'common'
82
            URI("https://login.microsoftonline.com/#{tenant}/oauth2/v2.0/token")
83
          when 'custom'
84
            URI(config['token_url'])
85
          else
86
            raise "Unsupported provider: #{provider}"
87
          end
88
        end
89

  
90
        def refresh_payload(token, config)
91
          payload = {
92
            client_id: config['client_id'],
93
            client_secret: config['client_secret'],
94
            refresh_token: token.refresh_token,
95
            grant_type: 'refresh_token'
96
          }
97

  
98
          if token.provider == 'microsoft'
99
            payload[:scope] = 'https://outlook.office.com/IMAP.AccessAsUser.All offline_access'
100
          elsif token.provider == 'custom' && config['scope'].present?
101
            payload[:scope] = config['scope']
102
          end
103

  
104
          payload
105
        end
106

  
107
        def handle_failure(token, error_message)
108
          token.is_valid = false
109
          token.last_error = error_message
110
          token.save(validate: false) # Skip validation if we are just marking as invalid
111

  
112
          # Send email alert to system administrators
113
          begin
114
            Mailer.deliver_email_oauth_refresh_failure(token, error_message)
115
          rescue => e
116
            Rails.logger.error "Failed to send OAuth refresh failure email: #{e.message}"
117
          end
118
        end
119
      end
120
    end
121
  end
122
end
lib/redmine/imap.rb
18 18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19 19

  
20 20
require 'net/imap'
21
require_relative 'email_oauth/refresher'
21 22

  
22 23
module Redmine
23 24
  module IMAP
......
41 42
        if starttls
42 43
          imap.starttls
43 44
        end
44
        imap.login(imap_options[:username], imap_options[:password]) unless imap_options[:username].nil?
45
        if imap_options[:username].present?
46
          token_record = EmailOauthToken.find_by(email: imap_options[:username])
47
          if token_record
48
            # Refresh token if needed
49
            if Redmine::EmailOAuth::Refresher.refresh!(token_record)
50
              imap.authenticate('XOAUTH2', imap_options[:username], token_record.access_token)
51
            else
52
              raise "OAuth 2.0 authentication failed for #{imap_options[:username]}: #{token_record.last_error}"
53
            end
54
          else
55
            imap.login(imap_options[:username], imap_options[:password])
56
          end
57
        end
45 58
        imap.select(folder)
46 59
        imap.uid_search(['NOT', 'SEEN']).each do |uid|
47 60
          msg = imap.uid_fetch(uid, 'RFC822')[0].attr['RFC822']
test/functional/admin_controller_test.rb
31 31
    assert_select 'div#admin-menu a.icon-webhook[href="/admin/webhooks"]'
32 32
  end
33 33

  
34
  def test_index_shows_oauth_warning_banner_when_token_is_invalid
35
    EmailOauthToken.delete_all
36
    EmailOauthToken.create!(
37
      email: 'bad-token@example.net',
38
      provider: 'microsoft',
39
      refresh_token: 'refresh',
40
      expires_at: 10.minutes.ago,
41
      is_valid: false
42
    )
43
    get :index
44
    assert_select 'div.flash.error', :text => /One or more configured OAuth 2.0 incoming email accounts/
45
  end
46

  
34 47
  def test_index_with_no_configuration_data
35 48
    delete_configuration_data
36 49
    get :index
test/functional/email_oauth_controller_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../test_helper'
21

  
22
class EmailOauthControllerTest < Redmine::ControllerTest
23
  def setup
24
    User.current = nil
25
    @request.session[:user_id] = 1 # Admin User
26
    EmailOauthToken.delete_all
27
    
28
    # Configure mock OAuth settings
29
    @mock_config = {
30
      'providers' => {
31
        'google' => {
32
          'client_id' => 'google_id',
33
          'client_secret' => 'google_secret'
34
        },
35
        'microsoft' => {
36
          'client_id' => 'ms_id',
37
          'client_secret' => 'ms_secret',
38
          'tenant' => 'common'
39
        }
40
      }
41
    }
42
  end
43

  
44
  def test_index_should_require_admin
45
    @request.session[:user_id] = nil # Anonymous
46
    get :index
47
    assert_response :redirect
48
    assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fadmin%2Fmail_handler%2Foauth'
49

  
50
    @request.session[:user_id] = 2 # Non-admin user
51
    get :index
52
    assert_response :forbidden
53
  end
54

  
55
  def test_index
56
    EmailOauthToken.create!(
57
      email: 'redmine@example.net',
58
      provider: 'google',
59
      refresh_token: 'refresh',
60
      expires_at: 1.hour.from_now
61
    )
62

  
63
    get :index
64
    assert_response :success
65
    assert_select 'tr td.name strong', text: 'redmine@example.net'
66
  end
67

  
68
  def test_new
69
    Redmine::Configuration.with 'email_oauth' => @mock_config do
70
      get :new
71
      assert_response :success
72
      assert_select 'select[name=?]', 'email_oauth_token[provider]' do
73
        assert_select 'option[value=google]', text: 'Google'
74
        assert_select 'option[value=microsoft]', text: 'Microsoft'
75
      end
76
    end
77
  end
78

  
79
  def test_initiate_redirects_to_google
80
    Redmine::Configuration.with 'email_oauth' => @mock_config do
81
      post :initiate, params: {
82
        email_oauth_token: {
83
          email: 'test@example.net',
84
          provider: 'google'
85
        }
86
      }
87
      
88
      # Verify redirection to Google's authorize URL
89
      assert_response :redirect
90
      assert_match %r{\Ahttps://accounts\.google\.com/o/oauth2/v2/auth}, @response.redirect_url
91
      assert_match %r{client_id=google_id}, @response.redirect_url
92
      assert_match %r{scope=https%3A%2F%2Fmail\.google\.com%2F}, @response.redirect_url
93
      assert_match %r{login_hint=test%40example\.net}, @response.redirect_url
94
      
95
      # Verify session is populated for callback verification
96
      assert_equal 'test@example.net', session[:email_oauth_email]
97
      assert_equal 'google', session[:email_oauth_provider]
98
      assert_not_nil session[:email_oauth_state]
99
    end
100
  end
101

  
102
  def test_initiate_redirects_to_microsoft
103
    Redmine::Configuration.with 'email_oauth' => @mock_config do
104
      post :initiate, params: {
105
        email_oauth_token: {
106
          email: 'ms@example.net',
107
          provider: 'microsoft'
108
        }
109
      }
110

  
111
      # Verify redirection to MS Azure authorize URL
112
      assert_response :redirect
113
      assert_match %r{\Ahttps://login\.microsoftonline\.com/common/oauth2/v2\.0/authorize}, @response.redirect_url
114
      assert_match %r{client_id=ms_id}, @response.redirect_url
115
      assert_match %r{scope=https%3A%2F%2Foutlook\.office\.com%2FIMAP\.AccessAsUser\.All\+offline_access}, @response.redirect_url
116
    end
117
  end
118

  
119
  def test_callback_success
120
    Redmine::Configuration.with 'email_oauth' => @mock_config do
121
      # Set up active authorization session state
122
      state_token = 'securestate'
123
      @request.session[:email_oauth_state] = state_token
124
      @request.session[:email_oauth_email] = 'success@example.net'
125
      @request.session[:email_oauth_provider] = 'google'
126

  
127
      # Mock successful token exchange response from Google
128
      mock_response = Net::HTTPSuccess.new('1.1', '200', 'OK')
129
      mock_response.stubs(:body).returns({
130
        access_token: 'mock_access',
131
        refresh_token: 'mock_refresh',
132
        expires_in: 3600
133
      }.to_json)
134

  
135
      Net::HTTP.expects(:post_form).with(
136
        URI("https://oauth2.googleapis.com/token"),
137
        {
138
          client_id: 'google_id',
139
          client_secret: 'google_secret',
140
          code: 'auth_code_123',
141
          redirect_uri: 'http://test.host/admin/mail_handler/oauth/callback',
142
          grant_type: 'authorization_code'
143
        }
144
      ).returns(mock_response)
145

  
146
      get :callback, params: {
147
        state: state_token,
148
        code: 'auth_code_123'
149
      }
150

  
151
      # Should redirect to index with success message
152
      assert_response :redirect
153
      assert_redirected_to email_oauth_index_path
154
      assert_equal I18n.t(:notice_successful_create), flash[:notice]
155

  
156
      # Verify record was created/updated in the database
157
      token = EmailOauthToken.find_by(email: 'success@example.net')
158
      assert_not_nil token
159
      assert_equal 'google', token.provider
160
      assert_equal 'mock_access', token.access_token
161
      assert_equal 'mock_refresh', token.refresh_token
162
      assert token.expires_at > Time.now
163
      assert token.is_valid?
164
    end
165
  end
166

  
167
  def test_destroy
168
    token = EmailOauthToken.create!(
169
      email: 'delete_me@example.net',
170
      provider: 'google',
171
      refresh_token: 'refresh',
172
      expires_at: 1.hour.from_now
173
    )
174

  
175
    assert_difference 'EmailOauthToken.count', -1 do
176
      delete :destroy, params: { id: token.id }
177
    end
178

  
179
    assert_redirected_to email_oauth_index_path
180
    assert_equal I18n.t(:notice_successful_delete), flash[:notice]
181
  end
182
end
test/functional/settings_controller_test.rb
327 327
    assert_equal '1', Setting.mail_handler_enable_regex_delimiters
328 328
    assert_equal 'On .*, .* at .*, .* <.*<mailto:.*>> wrote:', Setting.mail_handler_body_delimiters
329 329
  end
330

  
331
  def test_get_index_with_mail_handler_tab_renders_oauth_providers
332
    @request.session[:user_id] = 1 # Admin
333
    mock_config = {
334
      'providers' => {
335
        'google' => { 'client_id' => 'g', 'client_secret' => 's' },
336
        'microsoft' => { 'client_id' => 'm', 'client_secret' => 's' }
337
      }
338
    }
339

  
340
    Redmine::Configuration.with 'email_oauth' => mock_config do
341
      get :index, :params => { :tab => 'mail_handler' }
342
      assert_response :success
343
      assert_select 'legend', :text => 'Enabled OAuth providers'
344
      assert_select 'fieldset.settings ul li span', :text => 'Google'
345
      assert_select 'fieldset.settings ul li span', :text => 'Microsoft'
346
    end
347
  end
348

  
349
  def test_get_index_with_mail_handler_tab_renders_no_oauth_providers_message
350
    @request.session[:user_id] = 1 # Admin
351
    Redmine::Configuration.with 'email_oauth' => nil do
352
      get :index, :params => { :tab => 'mail_handler' }
353
      assert_response :success
354
      assert_select 'div.nodata', :text => /Email OAuth providers are not configured/
355
    end
356
  end
330 357
end
test/unit/email_oauth_refresher_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../test_helper'
21
require 'redmine/email_oauth/refresher'
22

  
23
class EmailOauthRefresherTest < ActiveSupport::TestCase
24
  fixtures :users
25

  
26
  def setup
27
    EmailOauthToken.delete_all
28
    @token = EmailOauthToken.create!(
29
      email: 'oauth-test@example.net',
30
      provider: 'google',
31
      access_token: 'initial_access_token',
32
      refresh_token: 'initial_refresh_token',
33
      expires_at: 10.minutes.ago,
34
      is_valid: true
35
    )
36
    # Clear any deliveries
37
    ActionMailer::Base.deliveries.clear
38
  end
39

  
40
  def test_refresh_not_needed
41
    @token.update!(expires_at: 1.hour.from_now)
42
    # No Net::HTTP requests should be made
43
    Net::HTTP.expects(:post_form).never
44
    assert Redmine::EmailOAuth::Refresher.refresh!(@token)
45
    assert @token.is_valid?
46
    assert_nil @token.last_error
47
  end
48

  
49
  def test_refresh_with_missing_config
50
    Redmine::Configuration.with 'email_oauth' => nil do
51
      assert_not Redmine::EmailOAuth::Refresher.refresh!(@token)
52
      @token.reload
53
      assert_not @token.is_valid?
54
      assert_match /missing in configuration.yml/, @token.last_error
55
      assert_equal 1, ActionMailer::Base.deliveries.size
56
    end
57
  end
58

  
59
  def test_refresh_success
60
    config = {
61
      'providers' => {
62
        'google' => {
63
          'client_id' => 'google_id',
64
          'client_secret' => 'google_secret'
65
        }
66
      }
67
    }
68

  
69
    mock_response = mock('response')
70
    mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
71
    mock_response.stubs(:body).returns({
72
      'access_token' => 'new_access_token',
73
      'expires_in' => 3600
74
    }.to_json)
75

  
76
    Redmine::Configuration.with 'email_oauth' => config do
77
      Net::HTTP.expects(:post_form).with(
78
        URI("https://oauth2.googleapis.com/token"),
79
        {
80
          client_id: 'google_id',
81
          client_secret: 'google_secret',
82
          refresh_token: 'initial_refresh_token',
83
          grant_type: 'refresh_token'
84
        }
85
      ).returns(mock_response)
86

  
87
      assert Redmine::EmailOAuth::Refresher.refresh!(@token)
88
      @token.reload
89
      assert_equal 'new_access_token', @token.access_token
90
      assert @token.is_valid?
91
      assert_nil @token.last_error
92
      assert_equal 0, ActionMailer::Base.deliveries.size
93
    end
94
  end
95

  
96
  def test_refresh_custom_provider_success
97
    @token.update!(provider: 'custom', refresh_token: 'custom_refresh')
98
    config = {
99
      'providers' => {
100
        'custom' => {
101
          'client_id' => 'custom_id',
102
          'client_secret' => 'custom_secret',
103
          'token_url' => 'https://oauth.custom.local/token',
104
          'scope' => 'custom_scope'
105
        }
106
      }
107
    }
108

  
109
    mock_response = mock('response')
110
    mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
111
    mock_response.stubs(:body).returns({
112
      'access_token' => 'new_custom_access_token',
113
      'expires_in' => 1800
114
    }.to_json)
115

  
116
    Redmine::Configuration.with 'email_oauth' => config do
117
      Net::HTTP.expects(:post_form).with(
118
        URI("https://oauth.custom.local/token"),
119
        {
120
          client_id: 'custom_id',
121
          client_secret: 'custom_secret',
122
          refresh_token: 'custom_refresh',
123
          grant_type: 'refresh_token',
124
          scope: 'custom_scope'
125
        }
126
      ).returns(mock_response)
127

  
128
      assert Redmine::EmailOAuth::Refresher.refresh!(@token)
129
      @token.reload
130
      assert_equal 'new_custom_access_token', @token.access_token
131
      assert @token.is_valid?
132
      assert_nil @token.last_error
133
    end
134
  end
135

  
136
  def test_refresh_failure
137
    config = {
138
      'providers' => {
139
        'google' => {
140
          'client_id' => 'google_id',
141
          'client_secret' => 'google_secret'
142
        }
143
      }
144
    }
145

  
146
    mock_response = mock('response')
147
    mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(false)
148
    mock_response.stubs(:code).returns('400')
149
    mock_response.stubs(:body).returns('{"error":"invalid_grant"}')
150

  
151
    Redmine::Configuration.with 'email_oauth' => config do
152
      Net::HTTP.expects(:post_form).returns(mock_response)
153

  
154
      assert_not Redmine::EmailOAuth::Refresher.refresh!(@token)
155
      @token.reload
156
      assert_not @token.is_valid?
157
      assert_match /invalid_grant/, @token.last_error
158
      assert_equal 1, ActionMailer::Base.deliveries.size
159

  
160
      # Check email delivery content
161
      mail = ActionMailer::Base.deliveries.first
162
      assert_match /OAuth 2.0 Email Account Authentication Failure/, mail.subject
163
      assert_match /oauth-test@example.net/, mail.body.encoded
164
    end
165
  end
166

  
167
  def test_imap_integration_with_oauth
168
    @token.update!(expires_at: 1.hour.from_now)
169

  
170
    mock_imap = mock('imap')
171
    Net::IMAP.expects(:new).with('127.0.0.1', port: '143', ssl: false).returns(mock_imap)
172
    mock_imap.expects(:authenticate).with('XOAUTH2', 'oauth-test@example.net', 'initial_access_token')
173
    mock_imap.expects(:select).with('INBOX')
174
    mock_imap.expects(:uid_search).with(['NOT', 'SEEN']).returns([])
175
    mock_imap.expects(:expunge)
176
    mock_imap.expects(:logout)
177
    mock_imap.expects(:disconnect)
178

  
179
    Redmine::IMAP.check({
180
      host: '127.0.0.1',
181
      port: '143',
182
      username: 'oauth-test@example.net'
183
    })
184
  end
185

  
186
  def test_imap_integration_fallback_to_login
187
    mock_imap = mock('imap')
188
    Net::IMAP.expects(:new).with('127.0.0.1', port: '143', ssl: false).returns(mock_imap)
189
    mock_imap.expects(:login).with('plain-user@example.net', 'password')
190
    mock_imap.expects(:select).with('INBOX')
191
    mock_imap.expects(:uid_search).with(['NOT', 'SEEN']).returns([])
192
    mock_imap.expects(:expunge)
193
    mock_imap.expects(:logout)
194
    mock_imap.expects(:disconnect)
195

  
196
    Redmine::IMAP.check({
197
      host: '127.0.0.1',
198
      port: '143',
199
      username: 'plain-user@example.net',
200
      password: 'password'
201
    })
202
  end
203
end
test/unit/email_oauth_token_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../test_helper'
21

  
22
class EmailOauthTokenTest < ActiveSupport::TestCase
23
  def setup
24
    EmailOauthToken.delete_all
25
  end
26

  
27
  def test_validations
28
    # Valid record
29
    token = EmailOauthToken.new(
30
      email: 'redmine@example.net',
31
      provider: 'microsoft',
32
      refresh_token: 'valid_refresh_token',
33
      expires_at: 1.hour.from_now
34
    )
35
    assert token.valid?
36

  
37
    # Missing email
38
    token.email = nil
39
    assert_not token.valid?
40
    assert_includes token.errors[:email], "cannot be blank"
41

  
42
    # Invalid email format
43
    token.email = 'not-an-email'
44
    assert_not token.valid?
45

  
46
    # Missing provider
47
    token.email = 'redmine@example.net'
48
    token.provider = nil
49
    assert_not token.valid?
50

  
51
    # Invalid provider
52
    token.provider = 'invalid_provider'
53
    assert_not token.valid?
54

  
55
    # Duplicate email (case-insensitive)
56
    EmailOauthToken.create!(
57
      email: 'redmine@example.net',
58
      provider: 'microsoft',
59
      refresh_token: 'token1',
60
      expires_at: 1.hour.from_now
61
    )
62
    duplicate = EmailOauthToken.new(
63
      email: 'REDMINE@example.net',
64
      provider: 'google',
65
      refresh_token: 'token2',
66
      expires_at: 1.hour.from_now
67
    )
68
    assert_not duplicate.valid?
69
    assert_includes duplicate.errors[:email], "has already been taken"
70
  end
71

  
72
  def test_ciphering
73
    Redmine::Configuration.with 'database_cipher_key' => 'secret' do
74
      token = EmailOauthToken.create!(
75
        email: 'redmine@example.net',
76
        provider: 'google',
77
        access_token: 'secret_access',
78
        refresh_token: 'secret_refresh',
79
        expires_at: 1.hour.from_now
80
      )
81

  
82
      # In-memory reading should be transparently decrypted
83
      assert_equal 'secret_access', token.access_token
84
      assert_equal 'secret_refresh', token.refresh_token
85

  
86
      # Reading raw database attributes should show encrypted values (with 'aes-256-cbc:')
87
      raw_record = ActiveRecord::Base.connection.select_one(
88
        "SELECT access_token, refresh_token FROM email_oauth_tokens WHERE id = #{token.id}"
89
      )
90
      assert_match /\Aaes-256-cbc:/, raw_record['access_token']
91
      assert_match /\Aaes-256-cbc:/, raw_record['refresh_token']
92
    end
93
  end
94

  
95
  def test_expired_and_refresh_needed
96
    token = EmailOauthToken.new(
97
      email: 'redmine@example.net',
98
      provider: 'microsoft',
99
      refresh_token: 'refresh',
100
      expires_at: 10.minutes.ago
101
    )
102
    assert token.expired?
103
    assert token.refresh_needed?
104

  
105
    token.expires_at = 2.minutes.from_now
106
    assert_not token.expired?
107
    assert token.refresh_needed? # within 5 minutes grace period
108

  
109
    token.expires_at = 10.minutes.from_now
110
    assert_not token.expired?
111
    assert_not token.refresh_needed?
112
  end
113
end
(4-4/7)