Feature #43023 » Feature__move_to_modern_authentication(OAuth_2_0)_from_IMAP_version3.patch
.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 |
- « Previous
- 1
- 2
- 3
- Next »