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