Heroku Review Apps で OAuth 認証する

[Heroku](heroku.com) の [Review Apps](https://devcenter.heroku.com/articles/github-integration-review-apps) を使うと Pull Request ごとに新しい環境が作成されてレビューも捗るしとても便利なんだけど、 Review Apps はそれぞれ固有のドメイン名を割り当てられるため、特に OAuth 周りで問題になる。

OAuth では事前に登録したコールバック URL にリダイレクトされるので、そこら辺をちょろまかそうとしても、まともな OAuth ライブラリではセキュリティ機構が働いで認証できない。

この問題の一つの解決策として Review Apps ごとに OAuth アプリケーションを登録することが考えられるが、 OAuth プロバイダーごとにセットアップするのは大変だし、 Heroku のデプロイプロセスで OAuth プロバイダーの認証を通すのも単純に面倒だと思う。と言うか無理。

この記事ではこれまでに著者が試した 2 つの解決策を紹介する。

## バックドア

Review Apps で OAuth を使うのが面倒なら、 OAuth を使うのをやめればいいじゃない、と言うアプローチ。

具体的には開発者のユーザ情報を fixture に入れておき、 `https://example-pr-10.herokuapp.com/login_as?name=xxx` にアクセスしたら fixture の中の `xxx` として Review Apps にログインできるようにバックドアを用意する。当然ながらこのバックドアは Review Apps でのみ有効になるようにする。

### Pros.

- 実装が容易。

### Cons.

- 認証を行なっていないので、 Basic 認証など他の認証機構を使って開発メンバー以外が Review Apps にアクセスできないようにすることが必須。
- 実際には OAuth 認証をしていないので、アクセストークンなどは発行されない。単なるログイン目的ならいいが、 OAuth 経由で何かしらの API を実行するような場合はこの方法ではダメ。

## プロキシ

毎回ドメインが変わるのが問題なら、ドメインを固定したプロキシを立てればいいじゃない、と言うアプローチ。

OAuth アプリケーションのコールバック URL にはプロキシアプリを指定し、プロキシアプリは認証情報を Review Apps に渡す。認証フローはこんな感じになる。

1. Review Apps を表示すると、プロキシアプリへのリンクがある。
2. プロキシアプリにアクセスすると OAuth 認証ページにリダイレクトされる。
3. プロキシアプリに認証情報が渡され、プロキシアプリは Review Apps にその認証情報を渡す。
4. Review Apps は受け取った情報を格納する。

以下の点に注意が必要。

- 2 の時点でプロキシアプリは認証を要求している Review Apps が誰なのか記憶する必要があるが、リダイレクト先を直接外から指定できるようにするとオープンリダイレクタ脆弱性になる。
- Heroku はたとえ example というアプリが存在していても example-pr-10 みたいなアプリを登録することができる。そのような第三者のアプリに 4 で情報を渡さないように、 2 の時点で Review App か確認しなければならない。
- 3 はクエリパラメータで情報をやり取りするので、パラメータを暗号化するか HTTPS を使う。
- 4 の時 Review Apps は渡された認証情報が第三者によって偽装されていないことを確認しなければならない。

### 実装例

簡単な実装イメージ。これがそのまま動くわけではない。ここでは簡単のため omniauth gem を使って Qiita の OAuth だけを行う前提で書く。

```rb:プロキシアプリのコントローラ
class ProxyController < ApplicationController
  KEY = :_review_app_pr_number

  # GET /auth/qiita/proxy/:pr
  #
  # Review Apps の認証ボタンのリンク先。 pr には Review Apps の PR 番号が入っている。
  # PR 番号を記憶したのち、通常の OAuth 認証に移る（omniauth が行う）。
  # Heroku は example-pr-100 のようなアプリも登録可能なので、トークンを使って
  # Review Apps そのものの認証も行っている。
  def proxy
    if params[:token] != ENV['PROXY_TOKEN']
      head :forbidden
    else
      sessions[KEY] = params[:pr]
      redirect_to '/auth/qiita'
    end
  end

  # GET /auth/qiita/callback
  #
  # OAuth 認証から帰ってきた時。
  # 認証情報を payload として Review App に渡す。その際共通の SECRET_KEY を使って
  # ハッシュ値を計算し添付する。
  def callback
    payload = {
      auth: request.env['omniauth.env'],
      timestamp: Time.now.to_i,
    }.to_json
    url_parameters = {
      host: "example-pr-#{sessions[KEY]}.herokuapp.com",
      payload: payload,
      signature: OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), ENV['SECRET_KEY'], payload)
    }
    sessions.delete(KEY)
    # Review App の review_app#login アクションにリダイレクトする
    redirect_to review_app_login_url(url_parameters)
  end
end
```

```rb:ReviewAppのコントローラ
class ReviewAppController < ApplicationController
  # GET /auth/qiita/login
  #
  # proxy/callback から渡ってくる。
  # データの認証をしてから、ログイン処理その他を行う。
  def login
    @payload = JSON.parse(params[:payload])
    if expired?
      redirect_to root_path, error: '失効した認証情報です'
    elsif !valid_signature?
      redirect_to root_path, error: '不正な認証情報です'
    else
      # @payload['auth'] を使って何かする
      redirect_to root_path, notice: '認証成功'
    end
  end

  private

  # 正規の signature かどうか
  def valid_signature?
    Rack::Utils.secure_compare(
      OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), ENV['SECRET_KEY'], params[:payload])
      params[:signature]
    )
  end

  # 古い認証情報じゃないか。ここでは 10 秒間だけ有効とする。
  def expired?
    @payload['timestamp'] + 10 < Time.now.to_i
  end
end
```

### Pros.

- OAuth 認証を行うので、アクセストークンを取得できる。

### Cons.

- 複数のアプリにまたがるので実装を追いづらい。
- 実装するのが面倒くさい。