OAuthコンシューマの仕組みと実装 〜 Ruby編

前置き

前回の記事でOAuthを使ってTwitter APIにアクセスすることができるようになりましたが、ruby-oauthは内部でNet::HTTPを呼び出しているため、そのままではGoogle App Engine for Java上のJRuby(以下JRuby for GAE/J)で利用できません。

JRuby for GAE/JでもNet::HTTPが使えるようになる」というrb-gae-supportと組み合わせればOKなのかもしれませんが*1、OAuthの仕様自体はシンプルなものですし、せっかくなので勉強がてら自分で実装してみることにします。

車輪の再発明おいしいです!*2

ちなみにタイトルにRuby編と付いていますが、他の言語編を作成する予定は特にありません。

OAuthの仕様

実装の前にOAuthの仕様や、そもそもの成り立ちについて調べました。既にわかりやすいまとめ記事がいくつも書かれていますので、ここではそれらを貼り付けるだけにとどめておきます。

OAuthの成り立ちや概要を理解するのならこちら。

「認証」と「認可」の違い、OpenIDとの比較などについてはこちら。

オフィシャルな資料にも目を通しておきます。

2009年8月現在、OAuthの仕様は"1.0"と"1.0 Revision A"がありますが、この記事ではより新しい"1.0 Revision A"を元に実装しています。

実装する範囲

シンプルな仕様とは言え、仕様に載っているものを全て実装しようとするとそれなりに大変です。*3

ですので、あくまでも最終的な目標である「JRuby for GAE/Jで動作するTwitterbot」を作るのに最低限必要な機能だけに絞って実装してみたいと思います。

まず、クライアントを作るわけですから、当然“コンシューマ”を実装することになりますね。

また、前回の記事でも触れたように、現時点ではTwitterのアクセストークンに期限切れはありませんので、そこは手動で頑張ることにすると、アクセストークンを取得するまでの処理の実装は必要ありません。

更に、TwitterはHMAC-SHA1形式の署名しかサポートしていませんので、それ以外の署名を実装する必要もありません。

OAuthプロトコルパラメータの渡し方もAuthorizationヘッダを使った方法だけ用意しておけば十分でしょう。

このように切り詰めていくと、最終的には

  • 既に取得済みのアクセストークンを使い
  • HMAC-SHA1で署名されたOAuthプロトコルパラメータを
  • Authorizationヘッダに格納し
  • サービスプロバイダから保護されたリソースを取得する

という一連の手順が実行できるだけの機能があればOK、ということになります。かなりシンプルなライブラリにできそうです。

実装の前提

最終的にはJRuby for GAE/Jで動かしたいのですが、色々試行錯誤して作っていくのならば、やはり素のRubyのほうが何かとお手軽です。

というわけで、まずは普通にNet::HTTPを使って通信するようなライブラリを書くことにします。それができれば、あとはNet::HTTP部分をGAEのURLFetchに置き換えるのはそれほど難しい作業ではないからです。

一応、1.8.7と1.9.1両方で動くようにはしているつもりです。

想定する使い方

OAuthによる認証に必要なコンシューマキー、コンシューマ秘密鍵、アクセストークン、アクセストーク秘密鍵を引数に与えてインスタンスを生成し、Net::HTTPのようにgetやpostなどのHTTPメソッド名でそのままHTTP通信を実行できるような形にします。

こんな感じのコードになりそうです。

simple_oauth = SimpleOAuth.new(CONSUMER_KEY, CONSUMER_SECRET, TOKEN, TOKEN_SECRET)
response = simple_oauth.get('http://twitter.com/statuses/friends_timeline.json')
response = simple_oauth.post('http://twitter.com/statuses/update.json', {
  :status => 'This is a test tweet.'
})

各メソッドの戻り値はNet::HTTPと同じくNet::HTTPResponseのインスタンスとします。

実装

では、仕様とにらめっこしつつ、少しずつ実装を進めていってみます。

インターフェース

インターフェース部分の実装はこんな感じになります。

class SimpleOAuth
  def initialize(consumer_key, consumer_secret, token, token_secret)
    @consumer_key = consumer_key
    @consumer_secret = consumer_secret
    @token = token
    @token_secret = token_secret
    # This class supports only 'HMAC-SHA1' as signature method at present.
    @signature_method = 'HMAC-SHA1'
  end

  def get(url, headers = {})
    request(:GET, url, nil, headers)
  end

  def head(url, headers = {})
    request(:HEAD, url, nil, headers)
  end

  def post(url, body = nil, headers = {})
    request(:POST, url, body, headers)
  end

  def put(url, body = nil, headers = {})
    request(:PUT, url, body, headers)
  end

  def delete(url, headers = {})
    request(:DELETE, url, nil, headers)
  end
end

特に言及すべき部分はありません。

HTTPリクエストの実行

getやpostなどのメソッドから呼び出されるrequestメソッドの実装です。

  def request(method, url, body = nil, headers = {})
    method = method.to_s
    url = URI.parse(url)
    request = create_http_request(method, url.request_uri, body, headers)
    request['Authorization'] = auth_header(method, url, request.body)
    Net::HTTP.new(url.host, url.port).request(request)
  end

HTTPリクエストオブジェクトの作成は少々手がかかるので別メソッドにしています。

auth_headerメソッドでOAuth用の認証ヘッダを生成し、Authorizationヘッダにセットしています。

あとはそのままHTTPリクエストを実行しているだけです。

汎用メソッド

次のメソッドの実装に移る前に、これ以降パラメータのエンコードや連結などの処理が頻繁に必要になってきますので、共通に使うために汎用メソッドとして実装しておきます。

まずパラメータのエンコードから。関連する仕様は5.1. Parameter Encodingです。

  RESERVED_CHARACTERS = /[^a-zA-Z0-9\-\.\_\~]/

  def escape(value)
    URI.escape(value.to_s, RESERVED_CHARACTERS)
  end

RESERVED_CHARACTERSは仕様に明記されているものをそのまま持ってきただけです。

次にパラメータの連結を。この処理は各パラメータのエスケープも含みます。

  def encode_parameters(params, delimiter = '&', quote = nil)
    if params.is_a?(Hash)
      params = params.map do |key, value|
        "#{escape(key)}=#{quote}#{escape(value)}#{quote}"
      end
    else
      params = params.map { |value| escape(value) }
    end
    delimiter ? params.join(delimiter) : params
  end

場所によって値を引用符で囲う必要があったり、連結する際の区切り文字が異なったりするので、全てに対応できるように柔軟な実装にしています。

HTTPリクエストオブジェクトの作成

では、先程のrequestメソッドから呼び出されていたメソッドの実装に戻ります。

  VERSION = '0.1'
  USER_AGENT = "SimpleOAuth/#{VERSION}"

  def create_http_request(method, path, body, headers)
    method = method.capitalize.to_sym
    request = Net::HTTP.const_get(method).new(path, headers)
    request['User-Agent'] = USER_AGENT
    if method == :Post || method == :Put
      request.body = body.is_a?(Hash) ? encode_parameters(body) : body.to_s
      request.content_type = 'application/x-www-form-urlencoded'
      request.content_length = (request.body || '').length
    end
    request
  end

引数で受け取ったメソッド名に対応するクラスをconst_getで取得し、インスタンスを生成します。

リクエストボディの形式は"application/x-www-form-urlencoded"しか想定していません。Twitter APIで使うなら他は特に必要ないかな、と思いますので…。

せっかくなのでUser-Agentも入れるようにしました。

OAuth認証用ヘッダの生成

もう一つ、requestメソッドから呼び出されていたメソッドの実装です。Authorizationヘッダにセットする認証用データを生成します。関連する仕様は5.4. OAuth HTTP Authorization Schemeです。

  def auth_header(method, url, body)
    parameters = oauth_parameters
    parameters[:oauth_signature] = signature(method, url, body, parameters)
    'OAuth ' + encode_parameters(parameters, ', ', '"')
  end

oauth_parametersメソッドでOAuthプロトコルパラメータを取得し、そこにメソッド名、URL、リクエストボディなどの情報を混ぜて、signatureメソッドで“署名”を生成します。署名をoauth_signatureパラメータにセットしたら、まとめてエンコードしてOAuth認証用ヘッダの完成です。

OAuthプロトコルパラメータの用意

では、そのoauth_parametersメソッドの実装です。OAuth認証に必要な一連のパラメータを用意します。関連する仕様は7. Accessing Protected Resourcesです。

  OAUTH_VERSION = '1.0'

  def oauth_parameters
    {
      :oauth_consumer_key => @consumer_key,
      :oauth_token => @token,
      :oauth_signature_method => @signature_method,
      :oauth_timestamp => timestamp,
      :oauth_nonce => nonce,
      :oauth_version => OAUTH_VERSION
    }
  end

必要なパラメータはコンシューマキー、アクセストークン、署名の形式、タイムスタンプ、ランダムな一意の文字列、そしてOAuthのバージョンです。

前者3つは既にインスタンス変数としてセット済みです。timestampとnonceメソッドは次に。OAuthのバージョンは現状'1.0'固定です。

タイムスタンプとNonce

timestampとnonceメソッドの実装です。関連する仕様は8. Nonce and Timestampです。

  def timestamp
    Time.now.to_i.to_s
  end

  def nonce
    OpenSSL::Digest::Digest.hexdigest('MD5', "#{Time.now.to_f}#{rand}")
  end

タイムスタンプはそのまま、現在時刻の基準時からの経過秒です。

Nonceはランダムな一意の文字列であれば、他に細かい指定はありません。リクエストのユニーク性を維持するためだけの値です。ここではとりあえず現在時刻にランダム数値を混ぜたもののMD5ハッシュ値としています。

MD5ハッシュ値の生成にDigest::MD5ではなくOpenSSLモジュールを利用しているのは、どちらにせよ後でOpenSSLモジュールが必要になるためです。

  def nonce
    Digest::MD5.hexdigest("#{Time.now.to_f}#{rand}")
  end

でも同じです。

署名

署名メソッドの実装に移ります。関連する仕様は9. Signing Requestsです。

  def signature(*args)
    base64(digest_hmac_sha1(signature_base_string(*args)))
  end

前述のように、今回はHMAC-SHA1による署名しかサポートしませんので、実装もHMAC-SHA1限定となっています。

署名の手順としては、

  1. 受け取ったパラメータ(メソッド名、URL、リクエストボディ、OAuthプロトコルパラメータ)からSignature Base String(署名基準文字列)を作成する。(signature_base_stringメソッド)
  2. Signature Base Stringと秘密鍵を組み合わせてHMAC-SHA1のダイジェスト値を生成する。(digest_hmac_sha1メソッド)
  3. ダイジェスト値をBase64エンコードする。(base64メソッド)

となります。

HMAC-SHA1ダイジェストとBase64エンコード

この二つは非常にシンプルですので、先に実装を済ませてしまいます。関連する仕様は9.2. HMAC-SHA1です。

  def base64(value)
    [ value ].pack('m').gsub(/\n/, '')
  end

  def digest_hmac_sha1(value)
    OpenSSL::HMAC.digest(OpenSSL::Digest::SHA1.new, secret, value)
  end

  def secret
    escape(@consumer_secret) + '&' + escape(@token_secret)
  end

base64メソッドは単にBase64エンコーディングを施しているだけです。値中の改行は不要なので全部削っています。

HMACの仕組みは以下のページが参考になります。ここではOpenSSLモジュールの力を借りてサクっと。

secretメソッドは秘密鍵の生成です。秘密鍵はコンシューマ秘密鍵とアクセストーク秘密鍵をそれぞれエスケープして&記号で連結したものになります。

Signature Base Stringの作成

ではsignature_base_stringメソッドの実装に移ります。関連する仕様は9.1. Signature Base Stringです。

  def signature_base_string(method, url, body, parameters)
    method = method.upcase
    base_url = signature_base_url(url)
    parameters = normalize_parameters(parameters, body, url.query)
    encode_parameters([ method, base_url, parameters ])
  end

メソッド名は大文字にします。

URLは次のような処理で必要な部分だけに削ぎ落とします。

  def signature_base_url(url)
    URI::HTTP.new(url.scheme, url.userinfo, url.host, nil, nil, url.path,
                  nil, nil, nil)
  end

OAuthプロトコルパラメータ、リクエストボディ、URL中のクエリデータは次のような処理で“正規化”を施します。

  def normalize_parameters(parameters, body, query)
    parameters = encode_parameters(parameters, nil)
    parameters += body.split('&') if body
    parameters += query.split('&') if query
    parameters.sort.join('&')
  end

これらのデータに含まれる“名前=値”のペア(それぞれエスケープ済み)をごちゃ混ぜにしてソートし、ひとかたまりに連結します。

以上のようにして得られた大文字メソッド名・基本URL・正規化パラメータをエスケープ&連結すればSignature Base Stringの完成です。

完成!

上から下へ実装していったので、どこまで実装が終わっているのかがわかりにくかったかもしれませんが、これで全ての実装が完了です。ここまでのコードをコピーしてまとめるだけで動くようになります。*4

使い方のサンプル

では実際に、今作ったライブラリ…simple-oauth.rbという名前で保存したとします、を使ってTwitterAPIへアクセスするサンプルコードを書いてみます。

#!/usr/bin/env ruby
# coding: utf-8

require 'simple-oauth'
require 'rubygems'
require 'json'

# ここを置き換える
CONSUMER_KEY = 'CONSUMER-KEY'
CONSUMER_SECRET = 'CONSUMER-SECRET'
TOKEN = 'ACCESS-TOKEN'
TOKEN_SECRET = 'ACCESS-TOKEN-SECRET'

simple_oauth = SimpleOAuth.new(CONSUMER_KEY, CONSUMER_SECRET, TOKEN, TOKEN_SECRET)

# Tweetの投稿
response = simple_oauth.post('http://twitter.com/statuses/update.json', {
  :status => "こんにちは!この投稿はテストです。 : #{Time.now}"
})
raise "Request failed: #{response.code}" unless response.code.to_i == 200

# TimeLineの取得
response = simple_oauth.get('http://twitter.com/statuses/friends_timeline.json?count=5')
raise "Request failed: #{response.code}" unless response.code.to_i == 200
JSON.parse(response.body).each do |status|
  puts "#{status['user']['screen_name']}: #{status['text']}"
end

コンシューマキーやアクセストークンは事前に取得しておいたものと置き換えてください。

これを実行すると、Tweetが一つ投稿され、またタイムライン中の最新5件のつぶやきが表示されます。

疑問点

とりあえず動くようにはなったものの、実はまだちょっと仕様の理解にあやふやな部分があるんですよね…。

それはURL中のクエリデータとリクエストボディの扱いです。

これまで見てきてわかる通り、これらはSignature Base Stringの生成のために一旦分解する必要があるのですが、この際「値の再エスケープまでする必要があるのかどうか」がわかっていません。

OAuthの仕様で定められているunreservedな文字は、Rubyで一般的にクエリデータのエスケープに使われているCGI#escapeのそれとは微妙に異なります。そのため、値をアンエスケープして再度エスケープした場合とそうでない場合とで、生成されるダイジェストが一致しないパターンが出てきます。

もちろん、再エスケープする必要があるのであれば、ダイジェストと実データに差異が出ないようにするため、リクエスト中のクエリデータとリクエストボディも再エスケープしたものに置き換える必要があります。

ダイジェストの生成に使ったデータと実際のクエリデータ・リクエストボディに差異さえなければ問題ないだろう(=再エスケープまでは要らないだろう)、とは思うのですが。この辺りご存知の方がいらっしゃいましたら是非ご教示ください。

まとめ

以上で「TwitterAPIを使うために最低限必要な機能を持ったOAuthライブラリ」*5はひとまず完成です。

今回書いたコードはGitHubに置いておきました。

中身はこの記事に書いてあるものと同一です。「分割されたコードをいちいちコピペするのが面倒臭い!」という場合はこちらから取得してください。

後はこれをJRuby for GAE/J用に書き直したいわけですが、それはまた長くなりそうなので次の機会に。

*1:実際動くかどうかは未確認です。

*2:ただし趣味の範囲に限る。

*3:ruby-oauthのコードサイズを見れば大体どの程度の規模かわかりますね。

*4:いくつかのrequireは追加する必要がありますが。

*5:なげぇ!