SVX日記
2023-08-18(Fri) RubyでOAuth2.0認証でGmailをPOP/SMTPする
コンテナ上のリモートデスクトップ環境の整備の一環で、愛用の自作メーラであるMaveも動き出したのだが、PVである/home/userの下で動かす分にはDockerは関係しないことに気づいた。そらそうか。
で、Maveの動作確認の一環でGmailアカウントにPOP/SMTPアクセスしようと思ったら、認証エラー。以前はできていたはずなのだが、と、しばらく止めていたMaverickコンテナを再起動したのだが、認証エラー。なんだ? 環境の問題ではないっぽい?
いろいろ調べていると、なんでも2022年5月をもって通常の認証手段によるPOP/SMTPアクセスは廃止になっていたらしい。トンと知らなかった。職場でメールチェックするのに、外部のVPS上にMaverickコンテナを上げてPOPさせていたのだが、テレワークで不要になったので止めていた間にそんなことになっていたとは。
何か以前にも似たようなことあったような、と思い返すと、会社のメールがマイクソソフトExchangeに変わった時だ。MAPIとかいう独自プロトコルらしい。知ろうという気にもならなかった。DavMailという救世主のようなアプリで難を逃れられている。別に比較できる事象ではないけれど、実にマイクソソフトらしい。ホント近づきたくもない。臭うんだから、寄ってくんな、シッシッ。
というわけで、ダメモトで「OAuth2.0」を学び始める。が、何だろうこのひさびさのワクワク感は。このところの不安定な気分がすっ飛んでいくのを感じる。結局、しばらくの間、新しく歯ごたえのある問題に出会えなかったのが原因だったのだろうか。
AUTH XOAUTH2 dXNlcj1zb21ldXNlckBleGFtcGxlLmNvbQFhdXRoPUJlY...
こんな認証コマンドあったっけ? と、思ったら、RFC1734として定義があるらしい。だいぶ古い。そんならRubyのnet/popライブラリにあるのかしらん? と、思ってコードを見ると、それは実装されていないようだ。ダメじゃん。ほんじゃnet/smtpライブラリには? と、確認すると「AUTH PLAIN」や「AUTH LOGIN」はあるが「AUTH XOAUTH2」や「AUTH xxx」という仕組みはないようだ。これも、ダメじゃん。
ほぼ、というのは、POP/SMTPに必要なスコープは、「https://www.googleapis.com/auth/gmail.modify」や「https://www.googleapis.com/auth/gmail.readonly」ではなく、フルコントロールっぽい「https://mail.google.com/」だというところ。面倒なので細かく試していないが、たぶんそう。これが原因でだいぶ悩まされた。
#!/usr/bin/env ruby
require 'net/http'
require 'json'
# setup GCP
# https://blog.ver001.com/gmail-api-oauth2-credential-key/
if(ARGV.size == 0)
warn <<USAGE
Usage:
$ ./oauth2.rb client_secret_xxxx.json > auth.html
$ google-chrome auth.html
$ ./oauth2.rb client_secret_xxxx.json 'http://localhost/?code=xxxx'
USAGE
exit(1)
end
cs = nil; if((it = ARGV[0]) =~ /^client/)
open(it) {|fh|
cs = JSON.parse(fh.read)
}
end
if(ARGV.size == 1 and cs)
auth_uri = 'https://accounts.google.com/o/oauth2/auth'
auth_params = {
response_type: 'code',
scope: 'https://mail.google.com/',
client_id: cs['installed']['client_id'],
redirect_uri: 'http://localhost',
}
auth_response = Net::HTTP.post_form(URI(auth_uri), auth_params)
puts(auth_response.body)
elsif(ARGV.size == 2 and cs and (it = ARGV[1]) =~ /^http:/)
ps = URI.decode_www_form(URI(it).query)
auth_uri = 'https://accounts.google.com/o/oauth2/token'
auth_params = {
client_id: cs['installed']['client_id'],
client_secret: cs['installed']['client_secret'],
redirect_uri: 'http://localhost',
grant_type: 'authorization_code',
code: ps[0][1],
}
auth_response = Net::HTTP.post_form(URI(auth_uri), auth_params)
puts(auth_response.body)
else
warn('Invalid.')
end
$ ./oauth2.rb client_secret_xxxx.json > auth.html
$ google-chrome auth.html
$ ./oauth2.rb client_secret_xxxx.json 'http://localhost/?code=xxxx'
なんと、途中でブラウザを立ち上げて承認するという奇妙な手順を経て、さらに飛び先のURLを、再びコマンドに食わせるという奇妙な手順を経る。以下のようなJSONが返ればウッシッシ。アクセストークン、ゲットだぜ。
{
"access_token": "ya29.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"expires_in": 3599,
"refresh_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"scope": "https://mail.google.com/",
"token_type": "Bearer"
}
--- /usr/share/ruby/net/smtp.rb.org 2018-11-03 02:52:33.000000000 +0900
+++ /usr/share/ruby/net/smtp.rb 2023-08-18 16:40:20.249910652 +0900
@@ -760,6 +760,15 @@
res
end
+ def auth_oauth2(user, secret)
+ check_auth_args user, secret
+ res = critical {
+ get_response('AUTH XOAUTH2 ' + base64_encode("user=#{user}\1auth=#{secret}\1\1"))
+ }
+ check_auth_response res
+ res
+ end
+
private
def check_auth_method(type)
#!/usr/bin/env ruby
require 'net/smtp'
smtp = Net::SMTP.new('smtp.gmail.com', 587)
ssl = OpenSSL::SSL::SSLContext.new
ssl.verify_mode = OpenSSL::SSL::VERIFY_PEER
ssl.ca_file = '/etc/pki/tls/certs/ca-bundle.crt'
smtp.enable_starttls(ssl)
token = 'ya29.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
smtp.start('example.com', 'example@gmail.com', 'Bearer ' + token, :oauth2) {|smtp|
p smtp
}
$ ./smtp_test.rb
#<Net::SMTP smtp.gmail.com:587 started=true>
--- /usr/share/ruby/net/pop.rb.org 2018-11-03 02:52:33.000000000 +0900
+++ /usr/share/ruby/net/pop.rb 2023-08-18 16:38:38.149686063 +0900
@@ -415,11 +415,12 @@
# to use APOP authentication; it defaults to +false+.
#
# This method does *not* open the TCP connection.
- def initialize(addr, port = nil, isapop = false)
+ def initialize(addr, port = nil, isapop = false, isoauth2 = false)
@address = addr
@ssl_params = POP3.ssl_params
@port = port
@apop = isapop
+ @oauth2 = isoauth2
@command = nil
@socket = nil
@@ -438,6 +439,10 @@
@apop
end
+ def oauth2?
+ @oauth2
+ end
+
# does this instance use SSL?
def use_ssl?
return !@ssl_params.nil?
@@ -563,6 +568,8 @@
@command = POP3Command.new(@socket)
if apop?
@command.apop account, password
+ elsif oauth2?
+ @command.auth_oauth2 account, password
else
@command.auth account, password
end
@@ -918,6 +925,13 @@
})
end
+ def auth_oauth2(account, password)
+ check_response_auth(critical {
+ get_response('AUTH XOAUTH2 ' + ["user=#{account}\1auth=#{password}\1\1"].pack('m0'))
+ })
+ end
+
def list
critical {
getok 'LIST'
#!/usr/bin/env ruby
require 'net/pop'
pop = Net::POP3.new('pop.gmail.com', 995, false, true)
pop.enable_ssl(OpenSSL::SSL::VERIFY_NONE, '/etc/pki/tls/certs/ca-bundle.crt')
token = 'ya29.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
pop.start('example@gmail.com', 'Bearer ' + token) {|pop|
p pop
}
$ ./pop_test.rb
#<Net::POP3 pop.gmail.com:995 open=true>