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 »