SVX日記

2004|04|05|06|07|08|09|10|11|12|
2005|01|02|03|04|05|06|07|08|09|10|11|12|
2006|01|02|03|04|05|06|07|08|09|10|11|12|
2007|01|02|03|04|05|06|07|08|09|10|11|12|
2008|01|02|03|04|05|06|07|08|09|10|11|12|
2009|01|02|03|04|05|06|07|08|09|10|11|12|
2010|01|02|03|04|05|06|07|08|09|10|11|12|
2011|01|02|03|04|05|06|07|08|09|10|11|12|
2012|01|02|03|04|05|06|07|08|09|10|11|12|
2013|01|02|03|04|05|06|07|08|09|10|11|12|
2014|01|02|03|04|05|06|07|08|09|10|11|12|
2015|01|02|03|04|05|06|07|08|09|10|11|12|
2016|01|02|03|04|05|06|07|08|09|10|11|12|
2017|01|02|03|04|05|06|07|08|09|10|11|12|
2018|01|02|03|04|05|06|07|08|09|10|11|12|
2019|01|02|03|04|05|06|07|08|09|10|11|12|
2020|01|02|03|04|05|06|07|08|09|10|11|12|
2021|01|02|03|04|05|06|07|08|09|10|11|12|
2022|01|02|03|04|05|06|07|08|09|10|11|12|
2023|01|02|03|04|05|06|07|08|09|10|11|12|
2024|01|02|03|04|05|06|07|08|09|10|11|

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させていたのだが、テレワークで不要になったので止めていた間にそんなことになっていたとは。

  別にGmailアクセスは必須じゃないのだが、できないのもシャクに障る。なんでも「OAuth2.0」という認証方式に変わったらしいのだが、別にGoogleの独自規格ではなくRFCが出ているらしい。

  何か以前にも似たようなことあったような、と思い返すと、会社のメールがマイクソソフトExchangeに変わった時だ。MAPIとかいう独自プロトコルらしい。知ろうという気にもならなかった。DavMailという救世主のようなアプリで難を逃れられている。別に比較できる事象ではないけれど、実にマイクソソフトらしい。ホント近づきたくもない。臭うんだから、寄ってくんな、シッシッ。

  というわけで、ダメモトで「OAuth2.0」を学び始める。が、何だろうこのひさびさのワクワク感は。このところの不安定な気分がすっ飛んでいくのを感じる。結局、しばらくの間、新しく歯ごたえのある問題に出会えなかったのが原因だったのだろうか。

  まず、一番重要な情報が以下。

  POP/SMTPアクセスが廃止になったわけではない、ということだ。さすがにそれができなくなると機能追加というレベルになってしまうので、Maveの対応をあきらめざるを得ない。

  しかし、SMTPはともかく、POPに、

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"
}

  アクセストークンが得られたらこっちのもんだ。Rubyのnet/smtpライブラリにOAuth2.0認証機能を足す。といっても、以下を足すだけ。

--- /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)

  アクセストークンを使ってSMTPアクセスするテストプログラムを書いてドン。

#!/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>

  net/popライブラリにもOAuth2.0認証機能を足す。元のAPIの設計が微妙なせいでちょっと美しくないし、net/smtpよりちょっと量があるが、以下の変更を加える。

--- /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'

  アクセストークンを使ってPOPアクセスするテストプログラムを書いてドン。

#!/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>

  んがしかし、これでPOP/SMTPアクセスができる目処はついたのだが、このアクセストークン、有効期限が1時間しかないので、パスワードの替わりという形で実装すると、まったく実用にならない。

  どうも「refresh_token」というのがカギを握っていそうだが……本日はコレまで。あー、しかし、ひさびさにエキサイティングだったわッ!