Project

General

Profile

Feature #43023 » Feature__move_to_modern_authentication(OAuth_2_0)_from_IMAP_version2.patch

Jan Catrysse, 2025-07-18 16:20

View differences:

.gitignore (revision b25a16d96fbe8409a131950b41b972e10b65bdab) → .gitignore (revision 15d33bb9e483174c135a2032aa9cc4c846e1bf31)
50 50

  
51 51
/config/master.key
52 52
/config/credentials.yml.enc
53
/config/email_oauth2*.yml
Gemfile (revision b25a16d96fbe8409a131950b41b972e10b65bdab) → Gemfile (revision 15d33bb9e483174c135a2032aa9cc4c846e1bf31)
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 15d33bb9e483174c135a2032aa9cc4c846e1bf31) → doc/GMAIL_IMAP_OAUTH.md (revision 15d33bb9e483174c135a2032aa9cc4c846e1bf31)
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 15d33bb9e483174c135a2032aa9cc4c846e1bf31) → doc/O365_IMAP_OAUTH.md (revision 15d33bb9e483174c135a2032aa9cc4c846e1bf31)
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 15d33bb9e483174c135a2032aa9cc4c846e1bf31) → lib/redmine/email_oauth_helper.rb (revision 15d33bb9e483174c135a2032aa9cc4c846e1bf31)
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 15d33bb9e483174c135a2032aa9cc4c846e1bf31)
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 15d33bb9e483174c135a2032aa9cc4c846e1bf31) → lib/tasks/email_oauth.rake (revision 15d33bb9e483174c135a2032aa9cc4c846e1bf31)
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 als al geladen)
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
# Build an OAuth2 client from a config hash.
76
def build_oauth_client(config, redirect_set)
77
  OAuth2::Client.new(
78
    config['client_id'],
79
    config['client_secret'],
80
    site:          config['site'],
81
    authorize_url: config['authorize_url'],
82
    token_url:     config['token_url'],
83
    redirect_uri:  redirect_set ? config['redirect_uri'] : nil
84
  )
85
end
86

  
87
# Exchange the authorization code for an access token.
88
def oauth_get_token(client, code, client_id, client_secret, redirect_uri)
89
  params = {client_id: client_id, client_secret: client_secret}
90
  params[:redirect_uri] = redirect_uri if redirect_uri
91
  client.auth_code.get_token(code, **params)
92
end
93

  
94
# Persist token and client configuration to disk with secure permissions.
95
def save_oauth_files(token_file, access_token, client_config)
96
  File.write("#{token_file}.yml", access_token.to_hash.to_yaml)
97
  secure_file("#{token_file}.yml")
98
  File.write("#{token_file}_client.yml", client_config.to_yaml)
99
  secure_file("#{token_file}_client.yml")
100
end
101

  
102
# Abort if no refresh token was returned unless explicitly allowed.
103
def check_refresh_token!(token)
104
  return unless token.refresh_token.to_s.empty?
105

  
106
  if ENV['allow_no_refresh'] == '1'
107
    warn 'No refresh token returned; proceeding (token will expire).'
108
  else
109
    abort('No refresh token received; check application permissions (try prompt=consent).')
110
  end
111
end
112

  
113
namespace :redmine do
114
  namespace :email do
115

  
116
    # ------------------------------------------------------------------
117
    # Office 365 Authorization Init
118
    # ------------------------------------------------------------------
119
    desc "Init Office 365 authorization"
120
    task :o365_oauth2_init => :environment do
121
      token_file    = normalize_token_file(ENV['token_file'])
122
      client_id     = ENV['client']
123
      client_secret = ENV['secret']
124
      tenant_id     = ENV['tenant']
125
      redirect_uri  = ENV['redirect_uri'].to_s.strip
126
      redirect_set  = !redirect_uri.empty?
127

  
128
      puts 'See doc/O365_IMAP_OAUTH.md for setup instructions.'
129
      abort("Missing ENV client")  if client_id.to_s.empty?
130
      abort("Missing ENV secret")  if client_secret.to_s.empty?
131
      abort("Missing ENV tenant")  if tenant_id.to_s.empty?
132
      puts "WARN: no redirect_uri supplied; using app-registered default." unless redirect_set
133

  
134

  
135
      # NB: scopes hier nog breed; snoeien kan later
136
      scope = [
137
        "offline_access",
138
        "https://outlook.office.com/User.Read",
139
        "https://outlook.office.com/IMAP.AccessAsUser.All",
140
        "https://outlook.office.com/POP.AccessAsUser.All",
141
        "https://outlook.office.com/SMTP.Send",
142
      ]
143

  
144
      client_config = {
145
        "tenant_id"     => tenant_id,
146
        "client_id"     => client_id,
147
        "client_secret" => client_secret,
148
        "site"          => 'https://login.microsoftonline.com',
149
        "authorize_url" => "/#{tenant_id}/oauth2/v2.0/authorize",
150
        "token_url"     => "/#{tenant_id}/oauth2/v2.0/token",
151
        "scope"         => scope.join(' ')
152
      }
153
      client_config["redirect_uri"] = redirect_uri if redirect_set
154

  
155
      client = build_oauth_client(client_config, redirect_set)
156

  
157
      # force prompt alleen als expliciet aangevraagd
158
      force_consent = ENV['force_consent'] == '1'
159

  
160
      url_params = { scope: client_config['scope'] }
161
      url_params[:prompt] = 'consent' if force_consent
162
      url_params[:redirect_uri] = client_config['redirect_uri'] if redirect_set
163

  
164
      puts "Go to URL: #{client.auth_code.authorize_url(**url_params)}"
165
      print "Enter full redirect URL after authorize: "
166
      code = Redmine::EmailOauthHelper.read_oauth_code
167

  
168
      access_token = oauth_get_token(client, code, client_id, client_secret, redirect_set ? client_config['redirect_uri'] : nil)
169
      check_refresh_token!(access_token)
170
      save_oauth_files(token_file, access_token, client_config)
171

  
172
      puts "AUTH OK!"
173
    end
174

  
175
    # ------------------------------------------------------------------
176
    # Google Authorization Init
177
    # ------------------------------------------------------------------
178
    desc "Init Google authorization"
179
    task :google_oauth2_init => :environment do
180
      token_file    = normalize_token_file(ENV['token_file'])
181
      client_id     = ENV['client']
182
      client_secret = ENV['secret']
183
      redirect_uri  = ENV['redirect_uri'].to_s.strip
184

  
185
      puts 'See doc/GMAIL_IMAP_OAUTH.md for setup instructions.'
186
      abort("Missing ENV client")  if client_id.to_s.empty?
187
      abort("Missing ENV secret")  if client_secret.to_s.empty?
188
      if redirect_uri.empty?
189
        redirect_uri = 'http://localhost'
190
        puts "WARN: no redirect_uri supplied; defaulting to #{redirect_uri}"
191
      end
192
      redirect_set = true
193

  
194
      scope = ['https://mail.google.com/']
195

  
196
      client_config = {
197
        'client_id'     => client_id,
198
        'client_secret' => client_secret,
199
        'site'          => 'https://accounts.google.com',
200
        'authorize_url' => '/o/oauth2/v2/auth',
201
        'token_url'     => 'https://oauth2.googleapis.com/token',
202
        'scope'         => scope.join(' '),
203
        'auth_params'   => { 'access_type' => 'offline' }
204
      }
205
      client_config['redirect_uri'] = redirect_uri if redirect_set
206

  
207
      client = build_oauth_client(client_config, redirect_set)
208

  
209
      force_consent = ENV['force_consent'] == '1'
210

  
211
      url_params = (client_config['auth_params'] || {}).dup
212
      url_params = url_params.transform_keys(&:to_sym)
213
      url_params[:scope]  = client_config['scope']
214
      url_params[:prompt] = 'consent' if force_consent
215
      url_params[:redirect_uri] = client_config['redirect_uri'] if redirect_set
216

  
217
      puts "Go to URL: #{client.auth_code.authorize_url(**url_params)}"
218
      print "Enter full redirect URL after authorize: "
219
      code = Redmine::EmailOauthHelper.read_oauth_code
220

  
221
      access_token = oauth_get_token(client, code, client_id, client_secret, redirect_set ? client_config['redirect_uri'] : nil)
222
      check_refresh_token!(access_token)
223
      save_oauth_files(token_file, access_token, client_config)
224

  
225
      puts "AUTH OK!"
226
    end
227

  
228
    # ------------------------------------------------------------------
229
    # Receive IMAP (OAuth2)
230
    # ------------------------------------------------------------------
231
    desc "Read emails from an IMAP server authorized via OAuth2"
232
    task :receive_imap_oauth2 => :environment do
233
      debug = env_bool('imap_debug', false)
234

  
235
      token_file = normalize_token_file(ENV['token_file'])
236
      unless File.exist?("#{token_file}.yml") && File.exist?("#{token_file}_client.yml")
237
        raise "token_file not defined or not exists (expected #{token_file}.yml and _client.yml)"
238
      end
239

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

  
243
      token_hash  = YAML.safe_load_file("#{token_file}.yml", **PERMITTED)
244
      access_token = OAuth2::AccessToken.from_hash(client, token_hash)
245

  
246
      if debug
247
        exp = access_token.expires_at ? Time.at(access_token.expires_at).utc : '(none)'
248
        rem = access_token.expires_at ? (access_token.expires_at - Time.now.to_i) : '(unknown)'
249
        puts "IMAP DEBUG: loaded token expires_at=#{exp} remaining=#{rem}"
250
        puts "IMAP DEBUG: refresh? #{access_token.refresh_token ? 'yes' : 'no'}"
251
        puts "IMAP DEBUG: token=#{mask_token(access_token.token)}"
252
      end
253

  
254
      if access_token.expired?
255
        logger = defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
256
        msg = "Refreshing OAuth token; old expiry: #{access_token.expires_at}"
257
        logger ? logger.info(msg) : $stderr.puts(msg)
258

  
259
        begin
260
          access_token = access_token.refresh!
261
        rescue OAuth2::Error => e
262
          abort("Token refresh failed (#{e.message}). Re-run init task.")
263
        end
264

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

  
268
        File.write("#{token_file}.yml", access_token.to_hash.to_yaml)
269
        secure_file("#{token_file}.yml")
270
      end
271

  
272
      host     = ENV['host'].to_s
273
      abort("ENV host (IMAP server) required") if host.empty?
274
      port     = (ENV['port'] || 993).to_i
275
      ssl      = env_bool('ssl', true)
276
      starttls = env_bool('starttls', false)
277
      folder   = ENV['folder'].to_s
278
      folder   = 'INBOX' if folder.empty?
279

  
280
      # Safety: als ssl==true -> verwijder starttls sleutel zodat Redmine hem niet activeert
281
      starttls = false if ssl
282
      imap_options = {
283
        :host            => host,
284
        :port            => port,
285
        :username        => ENV['username'],
286
        :password        => access_token.token,
287
        :auth_type       => 'XOAUTH2',
288
        :folder          => folder,
289
        :move_on_success => ENV['move_on_success'],
290
        :move_on_failure => ENV['move_on_failure']
291
      }
292
      # Alleen meegeven als effectief true (anders NIET in hash => Redmine ziet nil => geen SSL/STARTTLS)
293
      imap_options[:ssl] = true if ssl
294
      imap_options[:starttls] = true if starttls
295

  
296
      puts "IMAP DEBUG: effective imap_options=#{imap_options.inspect}" if debug
297

  
298
      Mailer.with_synched_deliveries do
299
        begin
300
          # Sanitized ENV voor MailHandler (geen IMAP-keys die core verkeerd leest)
301
          mail_env = ENV.to_h.dup
302
          %w[host port ssl starttls username token_file folder move_on_success move_on_failure].each { |k| mail_env.delete(k) }
303
          mail_opts = MailHandler.extract_options_from_env(mail_env)
304
          puts "IMAP DEBUG: MailHandler opts=#{mail_opts.inspect}" if debug
305

  
306
          Redmine::IMAP.check(imap_options, mail_opts)
307
        rescue Net::IMAP::NoResponseError, Net::IMAP::BadResponseError => e
308
          puts "IMAP ERROR: #{e.class}: #{e.message}"
309
          if e.message.to_s.include?('AUTHENTICATIONFAILED')
310
            puts "Please re-run the OAuth init task. Token file: #{token_file}.yml"
311
          end
312
          raise
313
        rescue StandardError => e
314
          warn "IMAP error: #{e.class}: #{e.message}"
315
          warn e.backtrace.join("\n") if debug
316
          raise
317
        end
318
      end
319
    end
320

  
321
    # ------------------------------------------------------------------
322
    # Inspect token
323
    # ------------------------------------------------------------------
324
    desc "Display OAuth2 token information"
325
    task :oauth2_status => :environment do
326
      token_file = normalize_token_file(ENV['token_file'])
327

  
328
      unless File.exist?("#{token_file}.yml") && File.exist?("#{token_file}_client.yml")
329
        raise "token_file not defined or not exists"
330
      end
331

  
332
      raw_token_data = YAML.safe_load_file("#{token_file}.yml", **PERMITTED)
333
      token_data = if raw_token_data.is_a?(Hash)
334
                     raw_token_data.each_with_object({}) { |(k,v),h| h[k.to_s.sub(/\A:/,'')] = v }
335
                   else
336
                     {}
337
                   end
338

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

  
341
      provider =
342
        case client_config['site']
343
        when /microsoftonline/ then 'office365'
344
        when /google/          then 'google'
345
        else client_config['site']
346
        end
347

  
348
      exp_val = token_data['expires_at']
349
      if exp_val
350
        expires_at = Time.at(exp_val.to_i)
351
        remaining  = exp_val.to_i - Time.now.to_i
352
      else
353
        expires_at = '(none)'
354
        remaining  = '(unknown)'
355
      end
356

  
357
      refresh_present = token_data['refresh_token'].to_s != ''
358

  
359
      puts "provider: #{provider}"
360
      puts "expiry time: #{expires_at.is_a?(Time) ? expires_at.utc : expires_at} (#{expires_at})"
361
      puts "seconds remaining: #{remaining}"
362
      puts "refresh token present: #{refresh_present}"
363
      puts "redirect_uri stored: #{client_config['redirect_uri'].inspect}"
364
    end
365

  
366
  end
367
end
/dev/null (revision 15d33bb9e483174c135a2032aa9cc4c846e1bf31) → test/unit/lib/redmine/imap_test.rb (revision 15d33bb9e483174c135a2032aa9cc4c846e1bf31)
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 15d33bb9e483174c135a2032aa9cc4c846e1bf31) → test/unit/lib/tasks/email_oauth_status_test.rb (revision 15d33bb9e483174c135a2032aa9cc4c846e1bf31)
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 15d33bb9e483174c135a2032aa9cc4c846e1bf31) → test/unit/lib/tasks/email_oauth_test.rb (revision 15d33bb9e483174c135a2032aa9cc4c846e1bf31)
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 15d33bb9e483174c135a2032aa9cc4c846e1bf31) → test/unit/lib/tasks/google_oauth2_init_test.rb (revision 15d33bb9e483174c135a2032aa9cc4c846e1bf31)
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 15d33bb9e483174c135a2032aa9cc4c846e1bf31) → test/unit/lib/tasks/o365_oauth2_init_test.rb (revision 15d33bb9e483174c135a2032aa9cc4c846e1bf31)
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 van de rake-task logica (zonder Rails).
27
  # Parameter redirect_set bepaalt of we redirect_uri meegeven.
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
    # Opbouw url_params zoals in 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 meegegeven → verwacht param
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 moet redirect_uri bevatten
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 moet redirect_uri meekrijgen
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: GEEN redirect_uri → mag niet in params zitten
104
  # --------------------------------------------------------------------
105
  def test_no_redirect_uri_not_sent
106
    # Wanneer redirect niet gezet is, verwachten we dat client
107
    # met redirect_uri=nil wordt gebouwd en dat authorize_url &
108
    # get_token *geen* redirect param meekrijgen.
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 NIET aanwezig is
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 heeft geen 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
(2-2/2)