Project

General

Profile

Feature #43023 » Feature__move_to_modern_authentication(OAuth_2_0)_from_IMAP_version3.patch

Jan Catrysse, 2025-08-04 12:18

View differences:

.gitignore (revision b25a16d96fbe8409a131950b41b972e10b65bdab) → .gitignore (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5)
50 50

  
51 51
/config/master.key
52 52
/config/credentials.yml.enc
53
/config/email_oauth2*.yml
Gemfile (revision b25a16d96fbe8409a131950b41b972e10b65bdab) → Gemfile (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5)
21 21
gem 'net-imap', '~> 0.3.9'
22 22
gem 'net-pop', '~> 0.1.2'
23 23
gem 'net-smtp', '~> 0.3.3'
24
gem 'oauth2', '~> 2.0'
25
gem 'gmail_xoauth', '~> 0.4.3'
24 26
gem 'rexml', require: false if Gem.ruby_version >= Gem::Version.new('3.0')
25 27

  
26 28
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
/dev/null (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5) → doc/GMAIL_IMAP_OAUTH.md (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5)
1
# Gmail IMAP OAuth2 Setup
2

  
3
This guide explains how to authorize Redmine to access a Gmail mailbox via IMAP using OAuth2.
4

  
5
## Enable IMAP in Gmail
6
1. Open Gmail and go to **Settings** → **See all settings**.
7
2. Under **Forwarding and POP/IMAP**, enable **IMAP access**.
8

  
9
## Configure the OAuth consent screen
10
1. Visit [Google Cloud Console](https://console.cloud.google.com/).
11
2. Create or select a project.
12
3. Open **APIs & Services** → **OAuth consent screen** and complete the configuration.
13

  
14
## Create an OAuth client
15
1. In **APIs & Services** → **Credentials**, create a new **OAuth client ID** of type **Desktop app**.
16
2. Note the generated **Client ID** and **Client Secret**.
17

  
18
## Required scope
19
The rake task requests the following scope when authorizing:
20

  
21
```
22
https://mail.google.com/
23
```
24

  
25
## Obtaining the refresh token
26
Run the rake task and follow the instructions:
27

  
28
```
29
rake redmine:email:google_oauth2_init token_file=/app/redmine/config/email_oauth2_mytokenname client=CLIENT_ID secret=CLIENT_SECRET
30
```
31

  
32
After authorization, the task stores the access and refresh tokens in `config/email_oauth2_mytokenname.yml`.
33

  
34
## Receiving mail
35
Fetch messages with OAuth2 credentials instead of a password:
36

  
37
```sh
38
rake redmine:email:receive_imap_oauth2 token_file=/app/redmine/config/email_oauth2_mytokenname \
39
  host=HOST username=EMAIL
40
```
41
The task accepts the `ssl` and `starttls` environment variables. SSL/TLS is
42
enabled by default; set `ssl=0` to disable it. When SSL is disabled you can
43
enable STARTTLS with `starttls=1` (the option is ignored if `ssl` is enabled).
44

  
45
## Revoking consent
46
To revoke the authorization and obtain a new refresh token:
47
1. Visit [Google Account Permissions](https://myaccount.google.com/permissions).
48
2. Remove the application from **Third-party apps with account access**.
49
3. Run the rake task again.
/dev/null (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5) → doc/O365_IMAP_OAUTH.md (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5)
1
# Office 365 IMAP OAuth2 Setup
2

  
3
This guide explains how to authorize Redmine to access an Office 365 mailbox via IMAP using OAuth2.
4

  
5
## Register an Azure application
6
1. Sign in at [portal.azure.com](https://portal.azure.com/).
7
2. Create a **New registration** allowing any account type.
8
3. On the initial form, use `http://localhost/` as the redirect URI.
9
4. Under **Authentication**, choose **Mobile and desktop**, keep the same redirect URI and enable **public client**.
10
5. Under **API permissions**, add:
11
   - `offline_access`
12
   - `User.Read`
13
   - `IMAP.AccessAsUser.All`
14
   - `POP.AccessAsUser.All`
15
   - `SMTP.Send`
16
6. Note the **Application (client) ID** and **Directory (tenant) ID**.
17
7. If creating a private app, generate a **client secret**; public apps can omit this.
18

  
19
## Initializing the token
20
Run the rake task interactively to obtain the refresh token:
21

  
22
```sh
23
rake redmine:email:o365_oauth2_init token_file=/app/redmine/config/email_oauth2_mytokenname \
24
  client=CLIENT_ID tenant=TENANT_ID secret=CLIENT_SECRET
25
```
26

  
27
Tokens are stored in `config/email_oauth2_mytokenname.yml` and `config/email_oauth2_mytokenname_client.yml`.
28

  
29
## Receiving mail
30
Use the OAuth token instead of a password when fetching messages:
31

  
32
```sh
33
rake redmine:email:receive_imap_oauth2 token_file=/app/redmine/config/email_oauth2_mytokenname \
34
  host=HOST username=EMAIL
35
```
36
Other parameters match those of `redmine:email:receive_imap`.
37

  
38
SSL/TLS is enabled by default. Pass `ssl=0` to disable it and, if desired,
39
enable explicit TLS with `starttls=1`. The `starttls` option is ignored when
40
`ssl` is enabled.
41

  
42
## Revoking access
43
To revoke the grant and start over, remove the application from [Microsoft account permissions](https://myaccount.microsoft.com/consents) and delete the token files before re-running the initialization task.
/dev/null (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5) → lib/redmine/email_oauth_helper.rb (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5)
1
# frozen_string_literal: true
2

  
3
require 'uri'
4
require 'cgi'
5

  
6
module Redmine
7
  module EmailOauthHelper
8
    # Read a full redirect URL from STDIN and extract ?code=...
9
    # Re-prompts until valid.
10
    def self.read_oauth_code
11
      loop do
12
        auth_resp = STDIN.gets&.strip
13
        if auth_resp.nil? || auth_resp.empty?
14
          puts 'Please enter the full redirect URL:'
15
          next
16
        end
17

  
18
        begin
19
          uri  = URI.parse(auth_resp)
20
          code = CGI.parse(uri.query.to_s)['code']&.first
21
          if code.nil? || code.empty?
22
            raise URI::InvalidURIError
23
          end
24
          return code
25
        rescue StandardError
26
          puts 'Invalid URL. Please enter the full redirect URL:'
27
        end
28
      end
29
    end
30
  end
31
end
lib/redmine/imap.rb (revision b25a16d96fbe8409a131950b41b972e10b65bdab) → lib/redmine/imap.rb (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5)
16 16
# You should have received a copy of the GNU General Public License
17 17
# along with this program; if not, write to the Free Software
18 18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20 19
require 'net/imap'
21 20

  
22 21
module Redmine
......
28 27
        ssl = !imap_options[:ssl].nil?
29 28
        starttls = !imap_options[:starttls].nil?
30 29
        folder = imap_options[:folder] || 'INBOX'
30
        auth_type = imap_options[:auth_type] || 'LOGIN'
31 31

  
32 32
        imap = Net::IMAP.new(host, port, ssl)
33 33
        if starttls
34 34
          imap.starttls
35 35
        end
36
        imap.login(imap_options[:username], imap_options[:password]) unless imap_options[:username].nil?
36
        if auth_type == "XOAUTH2" 
37
          require 'gmail_xoauth' unless defined?(Net::IMAP::XOauth2Authenticator) && Net::IMAP::XOauth2Authenticator.class == Class
38
        end
39
        if auth_type == "LOGIN"
40
          imap.login(imap_options[:username], imap_options[:password]) unless imap_options[:username].nil?
41
        else
42
          imap.authenticate(auth_type, imap_options[:username], imap_options[:password]) unless imap_options[:username].nil?
43
        end
37 44
        imap.select(folder)
38 45
        imap.uid_search(['NOT', 'SEEN']).each do |uid|
39 46
          msg = imap.uid_fetch(uid,'RFC822')[0].attr['RFC822']
/dev/null (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5) → lib/tasks/email_oauth.rake (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5)
1
# Redmine - project management software
2
# Copyright (C) 2006-2022  Jean-Philippe Lang
3
#
4
# This program is free software; you can redistribute it and/or
5
# modify it under the terms of the GNU General Public License
6
# as published by the Free Software Foundation; either version 2
7
# of the License, or (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU General Public License for more details.
13
#
14
# You should have received a copy of the GNU General Public License
15
# along with this program; if not, write to the Free Software
16
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
17
#
18
# OAuth2 IMAP fetch tasks (O365 & Google)
19

  
20
require 'net/imap'
21
require 'oauth2'
22
require 'uri'
23
require 'cgi'
24
require 'yaml'
25
require_relative '../redmine/email_oauth_helper'
26

  
27
# XOAUTH2 helper (no-op if already loaded)
28
begin
29
  require 'gmail_xoauth'
30
rescue LoadError
31
  # ignore
32
end
33

  
34
PERMITTED = {
35
  permitted_classes:  [Symbol],
36
  permitted_symbols:  %i[
37
    access_token refresh_token token_type expires_at expires_in scope
38
    ext_expires_in mode header header_format param_name Bearer
39
  ],
40
  aliases: true
41
}.freeze
42

  
43
# ------------------------------------------------------------------
44
# helpers
45
# ------------------------------------------------------------------
46
def secure_file(path)
47
  return unless File.exist?(path)
48
  return if File::ALT_SEPARATOR # Windows
49
  File.chmod(0o600, path)
50
rescue StandardError
51
  # ignore
52
end
53

  
54
def env_bool(name, default=false)
55
  v = ENV[name]
56
  return default if v.nil?
57
  %w[1 true yes y t].include?(v.to_s.strip.downcase)
58
end
59

  
60
def normalize_token_file(path)
61
  path ||= Rails.root.join('config', 'email_oauth2_token').to_s
62
  base = File.basename(path)
63
  return path if base.start_with?('email_oauth2')
64
  File.join(File.dirname(path), "email_oauth2_#{base}")
65
end
66

  
67
def mask_token(tok, keep: 6)
68
  return "(nil)" if tok.to_s.empty?
69
  return tok if tok.length <= keep*2
70
  head = tok[0, keep]
71
  tail = tok[-keep, keep]
72
  "#{head}...#{tail} (len=#{tok.length})"
73
end
74

  
75
# Print usage information for the given task symbol.
76
def show_oauth_help(task)
77
  case task
78
  when :o365_oauth2_init
79
    puts <<~EOS
80
      Usage: rake redmine:email:o365_oauth2_init client=CLIENT secret=SECRET tenant=TENANT [options]
81

  
82
      Options:
83
        token_file=FILE       Base name for token files (default: config/email_oauth2_token)
84
        redirect_uri=URI      Custom redirect URI
85
        force_consent=1       Force consent screen
86
        allow_no_refresh=1    Do not abort when refresh token is missing
87
    EOS
88
  when :google_oauth2_init
89
    puts <<~EOS
90
      Usage: rake redmine:email:google_oauth2_init client=CLIENT secret=SECRET [options]
91

  
92
      Options:
93
        token_file=FILE       Base name for token files (default: config/email_oauth2_token)
94
        redirect_uri=URI      Redirect URI (default: http://localhost)
95
        force_consent=1       Force consent screen
96
        allow_no_refresh=1    Do not abort when refresh token is missing
97
    EOS
98
  when :receive_imap_oauth2
99
    puts <<~EOS
100
      Usage: rake redmine:email:receive_imap_oauth2 host=HOST username=USER token_file=FILE [options]
101

  
102
      Options:
103
        port=PORT             IMAP server port (default: 993)
104
        ssl=BOOL              Use SSL/TLS (default: 1)
105
        starttls=BOOL         Use STARTTLS when ssl=0 (default: 0)
106
        folder=NAME           IMAP folder to read (default: INBOX)
107
        move_on_success=BOX   Move processed emails to BOX
108
        move_on_failure=BOX   Move ignored emails to BOX
109
        imap_debug=1          Verbose output
110
    EOS
111
  when :oauth2_status
112
    puts <<~EOS
113
      Usage: rake redmine:email:oauth2_status token_file=FILE
114
    EOS
115
  end
116
end
117

  
118
def abort_usage(task, message)
119
  show_oauth_help(task)
120
  abort(message)
121
end
122

  
123
# Build an OAuth2 client from a config hash.
124
def build_oauth_client(config, redirect_set)
125
  OAuth2::Client.new(
126
    config['client_id'],
127
    config['client_secret'],
128
    site:          config['site'],
129
    authorize_url: config['authorize_url'],
130
    token_url:     config['token_url'],
131
    redirect_uri:  redirect_set ? config['redirect_uri'] : nil
132
  )
133
end
134

  
135
# Exchange the authorization code for an access token.
136
def oauth_get_token(client, code, client_id, client_secret, redirect_uri)
137
  params = {client_id: client_id, client_secret: client_secret}
138
  params[:redirect_uri] = redirect_uri if redirect_uri
139
  client.auth_code.get_token(code, **params)
140
end
141

  
142
# Persist token and client configuration to disk with secure permissions.
143
def save_oauth_files(token_file, access_token, client_config)
144
  File.write("#{token_file}.yml", access_token.to_hash.to_yaml)
145
  secure_file("#{token_file}.yml")
146
  File.write("#{token_file}_client.yml", client_config.to_yaml)
147
  secure_file("#{token_file}_client.yml")
148
end
149

  
150
# Abort if no refresh token was returned unless explicitly allowed.
151
def check_refresh_token!(token)
152
  return unless token.refresh_token.to_s.empty?
153

  
154
  if ENV['allow_no_refresh'] == '1'
155
    warn 'No refresh token returned; proceeding (token will expire).'
156
  else
157
    abort('No refresh token received; check application permissions (try prompt=consent).')
158
  end
159
end
160

  
161
namespace :redmine do
162
  namespace :email do
163

  
164
    desc "Display usage for email OAuth tasks"
165
    task :help, [:task] => :environment do |_, args|
166
      tasks = {
167
        o365_oauth2_init: :o365_oauth2_init,
168
        google_oauth2_init: :google_oauth2_init,
169
        receive_imap_oauth2: :receive_imap_oauth2,
170
        oauth2_status: :oauth2_status
171
      }
172
      if args[:task]
173
        show_oauth_help(args[:task].to_sym)
174
      else
175
        tasks.each_value { |t| show_oauth_help(t) }
176
      end
177
    end
178

  
179
    # ------------------------------------------------------------------
180
    # Office 365 Authorization Init
181
    # ------------------------------------------------------------------
182
    desc "Init Office 365 authorization"
183
    task :o365_oauth2_init => :environment do
184
      token_file    = normalize_token_file(ENV['token_file'])
185
      client_id     = ENV['client']
186
      client_secret = ENV['secret']
187
      tenant_id     = ENV['tenant']
188
      redirect_uri  = ENV['redirect_uri'].to_s.strip
189
      redirect_set  = !redirect_uri.empty?
190

  
191
      missing = []
192
      missing << 'client' if client_id.to_s.empty?
193
      missing << 'secret' if client_secret.to_s.empty?
194
      missing << 'tenant' if tenant_id.to_s.empty?
195
      abort_usage(:o365_oauth2_init, "Missing ENV #{missing.join(', ')}") unless missing.empty?
196

  
197
      puts 'See doc/O365_IMAP_OAUTH.md for setup instructions.'
198
      puts "WARN: no redirect_uri supplied; using app-registered default." unless redirect_set
199

  
200

  
201
      # Note: current scopes are broad and may be pruned later
202
      scope = [
203
        "offline_access",
204
        "https://outlook.office.com/User.Read",
205
        "https://outlook.office.com/IMAP.AccessAsUser.All",
206
        "https://outlook.office.com/POP.AccessAsUser.All",
207
        "https://outlook.office.com/SMTP.Send",
208
      ]
209

  
210
      client_config = {
211
        "tenant_id"     => tenant_id,
212
        "client_id"     => client_id,
213
        "client_secret" => client_secret,
214
        "site"          => 'https://login.microsoftonline.com',
215
        "authorize_url" => "/#{tenant_id}/oauth2/v2.0/authorize",
216
        "token_url"     => "/#{tenant_id}/oauth2/v2.0/token",
217
        "scope"         => scope.join(' ')
218
      }
219
      client_config["redirect_uri"] = redirect_uri if redirect_set
220

  
221
      client = build_oauth_client(client_config, redirect_set)
222

  
223
      # Force prompt only when explicitly requested
224
      force_consent = ENV['force_consent'] == '1'
225

  
226
      url_params = { scope: client_config['scope'] }
227
      url_params[:prompt] = 'consent' if force_consent
228
      url_params[:redirect_uri] = client_config['redirect_uri'] if redirect_set
229

  
230
      puts "Go to URL: #{client.auth_code.authorize_url(**url_params)}"
231
      print "Enter full redirect URL after authorize: "
232
      code = Redmine::EmailOauthHelper.read_oauth_code
233

  
234
      access_token = oauth_get_token(client, code, client_id, client_secret, redirect_set ? client_config['redirect_uri'] : nil)
235
      check_refresh_token!(access_token)
236
      save_oauth_files(token_file, access_token, client_config)
237

  
238
      puts "AUTH OK!"
239
    end
240

  
241
    # ------------------------------------------------------------------
242
    # Google Authorization Init
243
    # ------------------------------------------------------------------
244
    desc "Init Google authorization"
245
    task :google_oauth2_init => :environment do
246
      token_file    = normalize_token_file(ENV['token_file'])
247
      client_id     = ENV['client']
248
      client_secret = ENV['secret']
249
      redirect_uri  = ENV['redirect_uri'].to_s.strip
250

  
251
      missing = []
252
      missing << 'client' if client_id.to_s.empty?
253
      missing << 'secret' if client_secret.to_s.empty?
254
      abort_usage(:google_oauth2_init, "Missing ENV #{missing.join(', ')}") unless missing.empty?
255

  
256
      puts 'See doc/GMAIL_IMAP_OAUTH.md for setup instructions.'
257
      if redirect_uri.empty?
258
        redirect_uri = 'http://localhost'
259
        puts "WARN: no redirect_uri supplied; defaulting to #{redirect_uri}"
260
      end
261
      redirect_set = true
262

  
263
      scope = ['https://mail.google.com/']
264

  
265
      client_config = {
266
        'client_id'     => client_id,
267
        'client_secret' => client_secret,
268
        'site'          => 'https://accounts.google.com',
269
        'authorize_url' => '/o/oauth2/v2/auth',
270
        'token_url'     => 'https://oauth2.googleapis.com/token',
271
        'scope'         => scope.join(' '),
272
        'auth_params'   => { 'access_type' => 'offline' }
273
      }
274
      client_config['redirect_uri'] = redirect_uri if redirect_set
275

  
276
      client = build_oauth_client(client_config, redirect_set)
277

  
278
      force_consent = ENV['force_consent'] == '1'
279

  
280
      url_params = (client_config['auth_params'] || {}).dup
281
      url_params = url_params.transform_keys(&:to_sym)
282
      url_params[:scope]  = client_config['scope']
283
      url_params[:prompt] = 'consent' if force_consent
284
      url_params[:redirect_uri] = client_config['redirect_uri'] if redirect_set
285

  
286
      puts "Go to URL: #{client.auth_code.authorize_url(**url_params)}"
287
      print "Enter full redirect URL after authorize: "
288
      code = Redmine::EmailOauthHelper.read_oauth_code
289

  
290
      access_token = oauth_get_token(client, code, client_id, client_secret, redirect_set ? client_config['redirect_uri'] : nil)
291
      check_refresh_token!(access_token)
292
      save_oauth_files(token_file, access_token, client_config)
293

  
294
      puts "AUTH OK!"
295
    end
296

  
297
    # ------------------------------------------------------------------
298
    # Receive IMAP (OAuth2)
299
    # ------------------------------------------------------------------
300
    desc "Read emails from an IMAP server authorized via OAuth2"
301
    task :receive_imap_oauth2 => :environment do
302
      debug = env_bool('imap_debug', false)
303

  
304
      token_file_env = ENV['token_file']
305
      token_file = normalize_token_file(token_file_env)
306
      host       = ENV['host'].to_s
307
      username   = ENV['username'].to_s
308

  
309
      missing = []
310
      missing << 'token_file' if token_file_env.to_s.empty? || !(File.exist?("#{token_file}.yml") && File.exist?("#{token_file}_client.yml"))
311
      missing << 'host' if host.empty?
312
      missing << 'username' if username.empty?
313
      abort_usage(:receive_imap_oauth2, "Missing or invalid ENV #{missing.join(', ')}") unless missing.empty?
314

  
315
      client_config = YAML.safe_load_file("#{token_file}_client.yml", **PERMITTED)
316
      client = build_oauth_client(client_config, !client_config['redirect_uri'].to_s.empty?)
317

  
318
      token_hash  = YAML.safe_load_file("#{token_file}.yml", **PERMITTED)
319
      access_token = OAuth2::AccessToken.from_hash(client, token_hash)
320

  
321
      if debug
322
        exp = access_token.expires_at ? Time.at(access_token.expires_at).utc : '(none)'
323
        rem = access_token.expires_at ? (access_token.expires_at - Time.now.to_i) : '(unknown)'
324
        puts "IMAP DEBUG: loaded token expires_at=#{exp} remaining=#{rem}"
325
        puts "IMAP DEBUG: refresh? #{access_token.refresh_token ? 'yes' : 'no'}"
326
        puts "IMAP DEBUG: token=#{mask_token(access_token.token)}"
327
      end
328

  
329
      if access_token.expired?
330
        logger = defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
331
        msg = "Refreshing OAuth token; old expiry: #{access_token.expires_at}"
332
        logger ? logger.info(msg) : $stderr.puts(msg)
333

  
334
        begin
335
          access_token = access_token.refresh!
336
        rescue OAuth2::Error => e
337
          abort("Token refresh failed (#{e.message}). Re-run init task.")
338
        end
339

  
340
        msg = "OAuth token refreshed; new expiry: #{access_token.expires_at}"
341
        logger ? logger.info(msg) : $stderr.puts(msg)
342

  
343
        File.write("#{token_file}.yml", access_token.to_hash.to_yaml)
344
        secure_file("#{token_file}.yml")
345
      end
346

  
347
      port     = (ENV['port'] || 993).to_i
348
      ssl      = env_bool('ssl', true)
349
      starttls = env_bool('starttls', false)
350
      folder   = ENV['folder'].to_s
351
      folder   = 'INBOX' if folder.empty?
352

  
353
      # Safety: when ssl is true remove the starttls key so Redmine will not enable it
354
      starttls = false if ssl
355
      imap_options = {
356
        :host            => host,
357
        :port            => port,
358
        :username        => username,
359
        :password        => access_token.token,
360
        :auth_type       => 'XOAUTH2',
361
        :folder          => folder,
362
        :move_on_success => ENV['move_on_success'],
363
        :move_on_failure => ENV['move_on_failure']
364
      }
365
      # Only include if actually true (otherwise omit so Redmine sees nil and disables SSL/STARTTLS)
366
      imap_options[:ssl] = true if ssl
367
      imap_options[:starttls] = true if starttls
368

  
369
      puts "IMAP DEBUG: effective imap_options=#{imap_options.inspect}" if debug
370

  
371
      Mailer.with_synched_deliveries do
372
        begin
373
          # Sanitized ENV for MailHandler (without IMAP keys the core misinterprets)
374
          mail_env = ENV.to_h.dup
375
          %w[host port ssl starttls username token_file folder move_on_success move_on_failure].each { |k| mail_env.delete(k) }
376
          mail_opts = MailHandler.extract_options_from_env(mail_env)
377
          puts "IMAP DEBUG: MailHandler opts=#{mail_opts.inspect}" if debug
378

  
379
          Redmine::IMAP.check(imap_options, mail_opts)
380
        rescue Net::IMAP::NoResponseError, Net::IMAP::BadResponseError => e
381
          puts "IMAP ERROR: #{e.class}: #{e.message}"
382
          if e.message.to_s.include?('AUTHENTICATIONFAILED')
383
            puts "Please re-run the OAuth init task. Token file: #{token_file}.yml"
384
          end
385
          raise
386
        rescue StandardError => e
387
          warn "IMAP error: #{e.class}: #{e.message}"
388
          warn e.backtrace.join("\n") if debug
389
          raise
390
        end
391
      end
392
    end
393

  
394
    # ------------------------------------------------------------------
395
    # Inspect token
396
    # ------------------------------------------------------------------
397
    desc "Display OAuth2 token information"
398
    task :oauth2_status => :environment do
399
      token_file_env = ENV['token_file']
400
      token_file = normalize_token_file(token_file_env)
401

  
402
      unless token_file_env && File.exist?("#{token_file}.yml") && File.exist?("#{token_file}_client.yml")
403
        abort_usage(:oauth2_status, "Missing or invalid ENV token_file")
404
      end
405

  
406
      raw_token_data = YAML.safe_load_file("#{token_file}.yml", **PERMITTED)
407
      token_data = if raw_token_data.is_a?(Hash)
408
                     raw_token_data.each_with_object({}) { |(k,v),h| h[k.to_s.sub(/\A:/,'')] = v }
409
                   else
410
                     {}
411
                   end
412

  
413
      client_config = YAML.safe_load_file("#{token_file}_client.yml", **PERMITTED)
414

  
415
      provider =
416
        case client_config['site']
417
        when /microsoftonline/ then 'office365'
418
        when /google/          then 'google'
419
        else client_config['site']
420
        end
421

  
422
      exp_val = token_data['expires_at']
423
      if exp_val
424
        expires_at = Time.at(exp_val.to_i)
425
        remaining  = exp_val.to_i - Time.now.to_i
426
      else
427
        expires_at = '(none)'
428
        remaining  = '(unknown)'
429
      end
430

  
431
      refresh_present = token_data['refresh_token'].to_s != ''
432

  
433
      puts "provider: #{provider}"
434
      puts "expiry time: #{expires_at.is_a?(Time) ? expires_at.utc : expires_at} (#{expires_at})"
435
      puts "seconds remaining: #{remaining}"
436
      puts "refresh token present: #{refresh_present}"
437
      puts "redirect_uri stored: #{client_config['redirect_uri'].inspect}"
438
    end
439

  
440
  end
441
end
/dev/null (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5) → test/unit/lib/redmine/imap_test.rb (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5)
1
# frozen_string_literal: true
2

  
3
require_relative '../../../test_helper'
4
require 'net/imap'
5

  
6
class Redmine::IMAPTest < ActiveSupport::TestCase
7
  def test_check_uses_login_by_default
8
    imap = mock('imap')
9
    Net::IMAP.expects(:new).with('127.0.0.1', '143', false).returns(imap)
10
    imap.expects(:starttls).never
11
    imap.expects(:login).with('user', 'secret')
12
    imap.expects(:select).with('INBOX')
13
    imap.expects(:uid_search).returns([])
14
    imap.expects(:expunge)
15
    imap.expects(:logout)
16
    imap.expects(:disconnect)
17

  
18
    Redmine::IMAP.check(:username => 'user', :password => 'secret')
19
  end
20

  
21
  def test_check_uses_authenticate_when_auth_type_is_set
22
    imap = mock('imap')
23
    Net::IMAP.expects(:new).with('imap.example.com', '993', true).returns(imap)
24
    imap.expects(:starttls)
25
    imap.expects(:authenticate).with('XOAUTH2', 'user', 'token')
26
    imap.expects(:select).with('INBOX')
27
    imap.expects(:uid_search).returns([])
28
    imap.expects(:expunge)
29
    imap.expects(:logout)
30
    imap.expects(:disconnect)
31

  
32
    Redmine::IMAP.check(:host => 'imap.example.com', :port => '993', :ssl => true,
33
                        :starttls => true, :username => 'user', :password => 'token',
34
                        :auth_type => 'XOAUTH2')
35
  end
36
end
/dev/null (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5) → test/unit/lib/tasks/email_oauth_status_test.rb (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5)
1
require 'minitest/autorun'
2
require 'yaml'
3
require 'fileutils'
4
require 'tempfile'
5

  
6
class EmailOauthStatusTest < Minitest::Test
7
  def setup
8
    @dir = Dir.mktmpdir
9
    @token_file = File.join(@dir, 'token')
10
    prefixed = File.join(@dir, 'email_oauth2_token')
11
    File.write("#{prefixed}_client.yml", {
12
      'client_id' => 'id',
13
      'client_secret' => 'secret',
14
      'site' => 'https://accounts.google.com',
15
      'authorize_url' => 'auth',
16
      'token_url' => 'token'
17
    }.to_yaml)
18
    File.write("#{prefixed}.yml", {
19
      'access_token' => 'abc',
20
      'refresh_token' => 'r',
21
      'expires_at' => Time.now.to_i + 3600
22
    }.to_yaml)
23
  end
24

  
25
  def teardown
26
    FileUtils.rm_rf(@dir)
27
  end
28

  
29
  def run_status_logic
30
    file = @token_file
31
    unless File.basename(file).start_with?('email_oauth2')
32
      file = File.join(File.dirname(file), "email_oauth2_#{File.basename(file)}")
33
    end
34
    token_data = YAML.safe_load_file("#{file}.yml")
35
    client_config = YAML.safe_load_file("#{file}_client.yml")
36

  
37
    provider =
38
      case client_config['site']
39
      when /microsoftonline/ then 'office365'
40
      when /google/ then 'google'
41
      else client_config['site']
42
      end
43

  
44
    expires_at = Time.at(token_data['expires_at'].to_i)
45
    remaining = token_data['expires_at'].to_i - Time.now.to_i
46
    refresh_present = token_data.key?('refresh_token') && !token_data['refresh_token'].to_s.empty?
47

  
48
    puts "provider: #{provider}"
49
    puts "expiry time: #{expires_at.utc} (#{expires_at})"
50
    puts "seconds remaining: #{remaining}"
51
    puts "refresh token present: #{refresh_present}"
52
  end
53

  
54
  def test_status_output_format
55
    out, = capture_io { run_status_logic }
56
    lines = out.split("\n")
57
    assert_match(/^provider: google$/, lines[0])
58
    assert_match(/^expiry time: .*UTC \(.+\)$/, lines[1])
59
    assert_match(/^seconds remaining: \d+$/, lines[2])
60
    assert_match(/^refresh token present: true$/, lines[3])
61
  end
62
end
/dev/null (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5) → test/unit/lib/tasks/email_oauth_test.rb (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5)
1
require 'minitest/autorun'
2
require 'mocha/minitest'
3
require 'yaml'
4
require 'fileutils'
5
require 'tempfile'
6
require 'oauth2'
7
require 'rake'
8
load File.expand_path('../../../../lib/tasks/email_oauth.rake', __dir__)
9

  
10
class EmailOauthTokenRefreshTest < Minitest::Test
11
  def setup
12
    @dir = Dir.mktmpdir
13
    @token_file = File.join(@dir, 'token')
14
    prefixed = File.join(@dir, 'email_oauth2_token')
15
    File.write("#{prefixed}_client.yml", {
16
      'provider' => 'google',
17
      'client_id' => 'id',
18
      'client_secret' => 'secret',
19
      'site' => 'site',
20
      'authorize_url' => 'auth',
21
      'token_url' => 'token',
22
      'redirect_uri' => 'redir',
23
      'scope' => 'scope',
24
      'auth_params' => {'access_type' => 'offline'}
25
    }.to_yaml)
26
    File.write("#{prefixed}.yml", {
27
      'access_token' => 'old',
28
      'refresh_token' => 'r',
29
      'expires_at' => Time.now.to_i - 3600
30
    }.to_yaml)
31
  end
32

  
33
  def teardown
34
    FileUtils.rm_rf(@dir)
35
  end
36

  
37
  def run_init_logic
38
    token_file = File.join(@dir, 'email_oauth2_new')
39
    client_config = {
40
      'client_id' => 'id',
41
      'client_secret' => 'secret',
42
      'site' => 'site',
43
      'authorize_url' => 'auth',
44
      'token_url' => 'token'
45
    }
46
    File.write("#{token_file}.yml", {'access_token' => 't'}.to_yaml)
47
    File.chmod(0600, "#{token_file}.yml") unless File::ALT_SEPARATOR
48
    File.write("#{token_file}_client.yml", client_config.to_yaml)
49
    File.chmod(0600, "#{token_file}_client.yml") unless File::ALT_SEPARATOR
50
  end
51

  
52
  def run_refresh_logic
53
    file = @token_file
54
    unless File.basename(file).start_with?('email_oauth2')
55
      file = File.join(File.dirname(file), "email_oauth2_#{File.basename(file)}")
56
    end
57
    client_config = YAML.safe_load_file("#{file}_client.yml")
58
    client = OAuth2::Client.new(client_config['client_id'], client_config['client_secret'],
59
                                site: client_config['site'], authorize_url: client_config['authorize_url'], token_url: client_config['token_url'],
60
                                redirect_uri: client_config['redirect_uri'])
61
    access_token = OAuth2::AccessToken.from_hash(client, YAML.safe_load_file("#{file}.yml"))
62
    if access_token.expired?
63
      logger = defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
64
      msg = "Refreshing OAuth token; old expiry: #{access_token.expires_at}"
65
      if logger
66
        logger.info(msg)
67
      else
68
        $stderr.puts(msg)
69
      end
70

  
71
      access_token = access_token.refresh!
72

  
73
      msg = "OAuth token refreshed; new expiry: #{access_token.expires_at}"
74
      if logger
75
        logger.info(msg)
76
      else
77
        $stderr.puts(msg)
78
      end
79

  
80
      File.write("#{file}.yml", access_token.to_hash.to_yaml)
81
      File.chmod(0600, "#{file}.yml") unless File::ALT_SEPARATOR
82
    end
83
  end
84

  
85
  def test_expired_token_refreshes_and_writes_file
86
    new_expiry = Time.now.to_i + 3600
87
    refreshed = stub('token', to_hash: {
88
      'access_token' => 'new',
89
      'refresh_token' => 'r',
90
      'expires_at' => new_expiry
91
    }, expires_at: new_expiry)
92
    old_expiry = Time.now.to_i - 3600
93
    access_token = stub('token', expired?: true, refresh!: refreshed, expires_at: old_expiry)
94

  
95
    OAuth2::Client.expects(:new).returns(:client)
96
    OAuth2::AccessToken.expects(:from_hash).returns(access_token)
97

  
98
    capture_io do
99
      run_refresh_logic
100
    end
101

  
102
    data = YAML.safe_load(File.read(File.join(@dir, 'email_oauth2_token.yml')))
103
    assert_equal 'new', data['access_token']
104
    if File::ALT_SEPARATOR.nil?
105
      assert_equal 0600, File.stat(File.join(@dir, 'email_oauth2_token.yml')).mode & 0777
106
    end
107
  end
108

  
109
  def test_valid_token_is_not_refreshed
110
    access_token = stub('token')
111
    access_token.stubs(:expired?).returns(false)
112

  
113
    OAuth2::Client.expects(:new).returns(:client)
114
    OAuth2::AccessToken.expects(:from_hash).returns(access_token)
115
    access_token.expects(:refresh!).never
116

  
117
    capture_io do
118
      run_refresh_logic
119
    end
120

  
121
    data = YAML.safe_load(File.read(File.join(@dir, 'email_oauth2_token.yml')))
122
    assert_equal 'old', data['access_token']
123
  end
124

  
125
  def test_init_creates_files_with_secure_permissions
126
    run_init_logic
127
    if File::ALT_SEPARATOR.nil?
128
      assert_equal 0600, File.stat(File.join(@dir, 'email_oauth2_new.yml')).mode & 0777
129
      assert_equal 0600, File.stat(File.join(@dir, 'email_oauth2_new_client.yml')).mode & 0777
130
    end
131
  end
132

  
133
  def test_logs_expiration_times_on_refresh
134
    new_expiry = Time.now.to_i + 3600
135
    refreshed = stub('token',
136
                     to_hash: {
137
                       'access_token' => 'new',
138
                       'refresh_token' => 'r',
139
                       'expires_at' => new_expiry
140
                     },
141
                     expires_at: new_expiry)
142

  
143
    old_expiry = Time.now.to_i - 3600
144
    access_token = stub('token', expired?: true, refresh!: refreshed, expires_at: old_expiry)
145

  
146
    OAuth2::Client.expects(:new).returns(:client)
147
    OAuth2::AccessToken.expects(:from_hash).returns(access_token)
148

  
149
    _out, err = capture_io do
150
      run_refresh_logic
151
    end
152

  
153
    assert_includes err, old_expiry.to_s
154
    assert_includes err, new_expiry.to_s
155
  end
156
end
157

  
158
class EmailOauthInitCheckTest < Minitest::Test
159
  def run_init_check(token)
160
    if !token.refresh_token && ENV['allow_no_refresh'] != '1'
161
      warn 'No refresh token returned. Re-authorize with prompt=consent.'
162
      exit 1
163
    end
164
  end
165

  
166
  def test_exit_without_refresh_token
167
    token = stub('token', refresh_token: nil)
168
    ENV.delete('allow_no_refresh')
169
    assert_raises(SystemExit) do
170
      _, err = capture_io { run_init_check(token) }
171
      assert_match(/prompt=consent/, err)
172
    end
173
  end
174

  
175
  def test_no_exit_when_allowed
176
    token = stub('token', refresh_token: nil)
177
    ENV['allow_no_refresh'] = '1'
178
    assert_silent { run_init_check(token) }
179
  ensure
180
    ENV.delete('allow_no_refresh')
181
  end
182
end
183

  
184
require_relative '../../../../lib/redmine/email_oauth_helper'
185

  
186
class EmailOauthReadUrlTest < Minitest::Test
187
  def test_reads_valid_url
188
    STDIN.stubs(:gets).returns("https://example.com/?code=abc\n")
189
    assert_equal 'abc', Redmine::EmailOauthHelper.read_oauth_code
190
  end
191

  
192
  def test_invalid_then_valid_url
193
    STDIN.stubs(:gets).returns("invalid\n", "https://example.com/?code=xyz\n")
194
    out, = capture_io do
195
      assert_equal 'xyz', Redmine::EmailOauthHelper.read_oauth_code
196
    end
197
    assert_match 'Invalid URL', out
198
  end
199
end
200

  
201
class EmailOauthInitTest < Minitest::Test
202
  def setup
203
    @dir = Dir.mktmpdir
204
    @token_file = File.join(@dir, 'token')
205
  end
206

  
207
  def teardown
208
    FileUtils.rm_rf(@dir)
209
  end
210

  
211
  def run_init_logic(token_hash)
212
    file = @token_file
213
    unless File.basename(file).start_with?('email_oauth2')
214
      file = File.join(File.dirname(file), "email_oauth2_#{File.basename(file)}")
215
    end
216
    client_config = {
217
      'client_id' => 'id',
218
      'client_secret' => 'secret',
219
      'site' => 'site',
220
      'authorize_url' => 'auth',
221
      'token_url' => 'token'
222
    }
223
    client = build_oauth_client(client_config, false)
224
    auth_code = stub('auth_code')
225
    client.stubs(:auth_code).returns(auth_code)
226
    auth_code.stubs(:authorize_url)
227
    token = stub('token', refresh_token: token_hash['refresh_token'], to_hash: token_hash)
228
    auth_code.stubs(:get_token).returns(token)
229

  
230
    access_token = oauth_get_token(client, 'code', client_config['client_id'], client_config['client_secret'], nil)
231
    check_refresh_token!(access_token)
232
    save_oauth_files(file, access_token, client_config)
233
  end
234

  
235
  def test_init_aborts_without_refresh_token
236
    assert_raises(SystemExit) do
237
      Kernel.stub(:abort, proc { |msg| raise SystemExit.new }) do
238
        run_init_logic('access_token' => 'a')
239
      end
240
    end
241
  end
242

  
243
  def test_refresh_token_is_persisted
244
    run_init_logic('access_token' => 'a', 'refresh_token' => 'r')
245
    data = YAML.safe_load(File.read(File.join(@dir, 'email_oauth2_token.yml')))
246
    assert_equal 'r', data['refresh_token']
247
  end
248
end
249

  
250
module Redmine
251
  module IMAP
252
  end
253
end
254

  
255
class Mailer
256
  def self.with_synched_deliveries(&block)
257
    yield
258
  end
259
end
260

  
261
require 'net/imap'
262

  
263
class EmailOauthReceiveImapRescueTest < Minitest::Test
264
  def setup
265
    @dir = Dir.mktmpdir
266
  end
267

  
268
  def teardown
269
    FileUtils.rm_rf(@dir)
270
  end
271

  
272
  def imap_exception(klass, message)
273
    response = Struct.new(:data).new(Struct.new(:text).new(message))
274
    klass.new(response)
275
  end
276

  
277
  def run_receive_logic(exception)
278
    token_file = File.join(@dir, 'email_oauth2_token')
279
    imap_options = {
280
      :host => nil,
281
      :port => nil,
282
      :ssl => nil,
283
      :starttls => nil,
284
      :username => nil,
285
      :password => 'token',
286
      :auth_type => 'XOAUTH2',
287
      :folder => nil,
288
      :move_on_success => nil,
289
      :move_on_failure => nil
290
    }
291
    Redmine::IMAP.stubs(:check).raises(exception)
292

  
293
    out, _ = capture_io do
294
      Mailer.with_synched_deliveries do
295
        begin
296
          Redmine::IMAP.check(imap_options, {})
297
        rescue Net::IMAP::NoResponseError, Net::IMAP::BadResponseError => e
298
          puts e.message
299
          if e.message.to_s.include?('AUTHENTICATIONFAILED')
300
            puts "Please re-run the OAuth init task. Token file: #{token_file}.yml"
301
          end
302
        end
303
      end
304
    end
305
    out
306
  end
307

  
308
  def test_authentication_failed_outputs_hint
309
    exception = imap_exception(Net::IMAP::BadResponseError, 'AUTHENTICATIONFAILED')
310
    output = run_receive_logic(exception)
311
    assert_includes output, 'Please re-run the OAuth init task'
312
    assert_includes output, File.join(@dir, 'email_oauth2_token.yml')
313
  end
314

  
315
  def test_other_errors_do_not_output_hint
316
    exception = imap_exception(Net::IMAP::NoResponseError, 'some error')
317
    output = run_receive_logic(exception)
318
    assert_includes output, 'some error'
319
    refute_includes output, 'Please re-run the OAuth init task'
320
  end
321
end
/dev/null (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5) → test/unit/lib/tasks/google_oauth2_init_test.rb (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5)
1
require 'minitest/autorun'
2
require 'mocha/minitest'
3
require 'yaml'
4
require 'fileutils'
5
require 'tempfile'
6
require 'oauth2'
7
require 'cgi'
8
require 'uri'
9
require 'rake'
10
load File.expand_path('../../../../lib/tasks/email_oauth.rake', __dir__)
11

  
12
class GoogleOauth2InitTest < Minitest::Test
13
  def setup
14
    @dir = Dir.mktmpdir
15
    @token_file = File.join(@dir, 'token')
16
    @redirect_uri = 'http://localhost'
17
  end
18

  
19
  def teardown
20
    FileUtils.rm_rf(@dir)
21
  end
22

  
23
  def run_init_logic
24
    token_file = @token_file
25
    unless File.basename(token_file).start_with?('email_oauth2')
26
      token_file = File.join(File.dirname(token_file), "email_oauth2_#{File.basename(token_file)}")
27
    end
28
    client_id = 'id'
29
    client_secret = 'secret'
30
    scope = ['https://mail.google.com/']
31
    redirect_uri = @redirect_uri
32
    client_config = {
33
      'client_id' => client_id,
34
      'client_secret' => client_secret,
35
      'site' => 'https://accounts.google.com',
36
      'authorize_url' => '/o/oauth2/v2/auth',
37
      'token_url' => 'https://oauth2.googleapis.com/token',
38
      'redirect_uri' => redirect_uri
39
    }
40
    client = build_oauth_client(client_config, true)
41
    print("Go to URL: #{client.auth_code.authorize_url(access_type: 'offline', scope: scope.join(' '), redirect_uri: redirect_uri)}\n")
42
    print('Enter full URL after authorize:')
43
    code = CGI.parse(URI.parse(STDIN.gets.strip).query)['code'].first
44
    access_token = oauth_get_token(client, code, client_id, client_secret, redirect_uri)
45
    save_oauth_files(token_file, access_token, client_config)
46
    token_file
47
  end
48

  
49
  def test_redirect_uri_saved_and_used
50
    OAuth2::Client.expects(:new).returns(client = stub('client'))
51
    client.stubs(:auth_code).returns(auth_code = stub('auth_code'))
52
    auth_code.expects(:authorize_url).with(access_type: 'offline', scope: 'https://mail.google.com/', redirect_uri: @redirect_uri).returns('http://auth.example')
53
    auth_code.expects(:get_token).with('abc', redirect_uri: @redirect_uri, client_id: 'id', client_secret: 'secret').returns(stub('token', to_hash: {}))
54
    STDIN.expects(:gets).returns("http://localhost/?code=abc\n")
55

  
56
    token_file = run_init_logic
57

  
58
    data = YAML.safe_load(File.read("#{token_file}_client.yml"))
59
    assert_equal @redirect_uri, data['redirect_uri']
60
  end
61
end
/dev/null (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5) → test/unit/lib/tasks/o365_oauth2_init_test.rb (revision 108d4f28418539c4b4caa9a68d38a9bfbe6b05f5)
1
require 'minitest/autorun'
2
require 'mocha/minitest'
3
require 'yaml'
4
require 'fileutils'
5
require 'tempfile'
6
require 'oauth2'
7
require 'cgi'
8
require 'uri'
9
require 'rake'
10
load File.expand_path('../../../../lib/tasks/email_oauth.rake', __dir__)
11

  
12
class O365Oauth2InitTest < Minitest::Test
13
  def setup
14
    @dir = Dir.mktmpdir
15
    @token_file = File.join(@dir, 'token')
16
    @client_id = 'o365-client-id'
17
    @client_secret = 'o365-secret'
18
    @tenant_id = 'o365-tenant'
19
    @redirect_uri = 'https://localhost/o365_callback'
20
  end
21

  
22
  def teardown
23
    FileUtils.rm_rf(@dir)
24
  end
25

  
26
  # Lightweight port of the rake task logic (without Rails).
27
  # The redirect_set parameter determines whether redirect_uri is passed.
28
  def run_init_logic(redirect_set: true)
29
    token_file = @token_file
30
    unless File.basename(token_file).start_with?('email_oauth2')
31
      token_file = File.join(File.dirname(token_file), "email_oauth2_#{File.basename(token_file)}")
32
    end
33

  
34
    scope = [
35
      "offline_access",
36
      "https://outlook.office.com/User.Read",
37
      "https://outlook.office.com/IMAP.AccessAsUser.All",
38
      "https://outlook.office.com/POP.AccessAsUser.All",
39
      "https://outlook.office.com/SMTP.Send"
40
    ]
41
    scope_str = scope.join(' ')
42

  
43
    client_config = {
44
      'client_id'     => @client_id,
45
      'client_secret' => @client_secret,
46
      'site'          => 'https://login.microsoftonline.com',
47
      'authorize_url' => "/#{@tenant_id}/oauth2/v2.0/authorize",
48
      'token_url'     => "/#{@tenant_id}/oauth2/v2.0/token",
49
      'scope'         => scope_str
50
    }
51
    client_config['redirect_uri'] = @redirect_uri if redirect_set
52

  
53
    client = build_oauth_client(client_config, redirect_set)
54

  
55
    # Build url_params as in the rake task
56
    url_params = { scope: scope_str }
57
    url_params[:prompt] = 'consent'
58
    url_params[:redirect_uri] = client_config['redirect_uri'] if redirect_set
59

  
60
    puts "Go to URL: #{client.auth_code.authorize_url(**url_params)}"
61
    print('Enter full URL after authorize:')
62

  
63
    code = CGI.parse(URI.parse(STDIN.gets.strip).query)['code'].first
64

  
65
    access_token = oauth_get_token(client, code, @client_id, @client_secret, redirect_set ? client_config['redirect_uri'] : nil)
66
    save_oauth_files(token_file, access_token, client_config)
67
    token_file
68
  end
69

  
70
  # --------------------------------------------------------------------
71
  # Test: redirect_uri provided → expect parameter
72
  # --------------------------------------------------------------------
73
  def test_redirect_uri_saved_and_used
74
    # Stubs
75
    OAuth2::Client.expects(:new).with(
76
      @client_id, @client_secret,
77
      has_entries(
78
        site: 'https://login.microsoftonline.com',
79
        authorize_url: "/#{@tenant_id}/oauth2/v2.0/authorize",
80
        token_url: "/#{@tenant_id}/oauth2/v2.0/token",
81
        redirect_uri: @redirect_uri
82
      )
83
    ).returns(client = mock('client'))
84

  
85
    client.expects(:auth_code).twice.returns(auth_code = mock('auth_code'))
86
    # authorize_url must include redirect_uri
87
    auth_code.expects(:authorize_url).with(
88
      has_entries(scope: includes('offline_access'), prompt: 'consent', redirect_uri: @redirect_uri)
89
    ).returns('https://auth.example/authorize')
90

  
91
    # get_token should receive redirect_uri
92
    auth_code.expects(:get_token).with('abc', has_entries(redirect_uri: @redirect_uri, client_id: @client_id, client_secret: @client_secret)).returns(stub('token', to_hash: {}))
93

  
94
    STDIN.expects(:gets).returns("https://localhost/o365_callback?code=abc\n")
95

  
96
    token_file = run_init_logic(redirect_set: true)
97

  
98
    data = YAML.safe_load(File.read("#{token_file}_client.yml"))
99
    assert_equal @redirect_uri, data['redirect_uri'], "redirect_uri should be persisted in client config"
100
  end
101

  
102
  # --------------------------------------------------------------------
103
  # Test: NO redirect_uri → should not appear in params
104
  # --------------------------------------------------------------------
105
  def test_no_redirect_uri_not_sent
106
    # When redirect is not set we expect the client
107
    # to be built with redirect_uri=nil and that authorize_url &
108
    # get_token receive no redirect parameter.
109

  
110
    OAuth2::Client.expects(:new).with(
111
      @client_id, @client_secret,
112
      has_entries(
113
        site: 'https://login.microsoftonline.com',
114
        authorize_url: "/#{@tenant_id}/oauth2/v2.0/authorize",
115
        token_url: "/#{@tenant_id}/oauth2/v2.0/token",
116
        redirect_uri: nil
117
      )
118
    ).returns(client = mock('client'))
119

  
120
    client.expects(:auth_code).twice.returns(auth_code = mock('auth_code'))
121

  
122
    # Capture params to assert that :redirect_uri is absent
123
    captured_params = nil
124
    auth_code.expects(:authorize_url).with { |**h|
125
      captured_params = h
126
      h[:scope].include?('offline_access') && h[:prompt] == 'consent' && !h.key?(:redirect_uri)
127
    }.returns('https://auth.example/authorize')
128

  
129
    auth_code.expects(:get_token).with('abc', has_entries(client_id: @client_id, client_secret: @client_secret)).returns(stub('token', to_hash: {})).then
130

  
131
    STDIN.expects(:gets).returns("https://localhost/somecallback?code=abc\n")
132

  
133
    token_file = run_init_logic(redirect_set: false)
134

  
135
    # Extra assertion: captured params has no redirect_uri
136
    refute captured_params.key?(:redirect_uri), "redirect_uri should not be sent when not configured"
137

  
138
    data = YAML.safe_load(File.read("#{token_file}_client.yml"))
139
    refute data.key?('redirect_uri'), "redirect_uri should not be persisted when not provided"
140
  end
141
end
(3-3/3)