mybook

OAuth 2.0 と OpenID Connect — モダンな認証認可

「StockFlowのAPIを、他のサービスから利用したいという要望が来ている」

CTOのアキラがSlackに書いた。倉庫管理ソフトのベンダーが、在庫データをリアルタイムで取得したいという話だ。

「でも、うちのユーザーのパスワードを相手サービスに渡すわけにはいかない」

リョウはこの問題を考えた。答えはOAuth 2.0だった。

OAuth 2.0とは何か

OAuth 2.0は「認可」のプロトコルだ。ユーザーが自分のデータへのアクセスを、パスワードを渡さずにサードパーティアプリに許可できる。

Loading diagram...

主な登場人物:

  • リソースオーナー — ユーザー(データの持ち主)
  • クライアント — 倉庫管理ソフト(アクセスしたいアプリ)
  • 認可サーバー — StockFlow(アクセスを許可する)
  • リソースサーバー — StockFlow API(データを持っている)

doorkeeperでOAuth 2.0サーバーを構築

RailsでOAuth 2.0サーバーを実装するにはdoorkeeper gemが最適だ。

# Gemfile
gem 'doorkeeper'
gem 'doorkeeper-openid_connect'  # OpenID Connectサポートを追加
bundle install
rails generate doorkeeper:install
rails generate doorkeeper:migration
rails generate doorkeeper:openid_connect:install
rails db:migrate
# config/initializers/doorkeeper.rb
Doorkeeper.configure do
  orm :active_record
 
  # トークンの保存先
  access_token_generator "Doorkeeper::JWT"
 
  # クライアント認証
  resource_owner_from_credentials do |routes|
    user = User.find_by(email: params[:username])
    if user&.valid_for_authentication? { user.valid_password?(params[:password]) }
      user
    end
  end
 
  # リソースオーナーの取得(セッションから)
  resource_owner_authenticator do
    current_user || redirect_to(login_url)
  end
 
  # サポートするGrantタイプ
  grant_flows %w[authorization_code client_credentials refresh_token]
 
  # PKCEを強制(認可コードフロー)
  force_pkce_for_public_clients
 
  # スコープの定義
  default_scopes :read
  optional_scopes :write, :admin, :inventory_read, :inventory_write, :export
 
  # アクセストークンの有効期限
  access_token_expires_in 1.hour
 
  # リフレッシュトークンの有効期限
  refresh_token_expires_in 30.days
 
  # 許可するリダイレクトURI(localhostは開発環境のみ)
  allow_localhost_redirect_uri Rails.env.development?
 
  # トークンのローテーション(セキュリティ強化)
  reuse_access_token false
 
  # フロントエンドからのアクセスを考慮したCORS
  skip_authorization do |resource_owner, client|
    client.superapp?
  end
 
  # JWTの設定
  jwt do
    secret_key Rails.application.credentials.dig(:doorkeeper, :jwt_secret)
    expiration_time 1.hour
    token_payload do |opts|
      {
        sub: opts[:resource_owner_id],
        iat: Time.current.to_i,
        exp: opts[:token].expires_at.to_i,
        scope: opts[:scopes].to_s,
        jti: opts[:token].token
      }
    end
  end
end

PKCEによるセキュリティ強化

PKCE(Proof Key for Code Exchange)は、認可コードの横取り攻撃を防ぐ仕組みだ。

Loading diagram...
// クライアント側(JavaScript)でのPKCE実装例
async function generatePKCE() {
  // code_verifierの生成(43-128文字のランダム文字列)
  const codeVerifier = generateRandomString(64);
 
  // code_challengeの計算(SHA-256ハッシュ + Base64URL)
  const encoder = new TextEncoder();
  const data = encoder.encode(codeVerifier);
  const digest = await window.crypto.subtle.digest('SHA-256', data);
  const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
 
  return { codeVerifier, codeChallenge };
}
 
// 認証URLを構築
async function buildAuthUrl() {
  const { codeVerifier, codeChallenge } = await generatePKCE();
  sessionStorage.setItem('pkce_verifier', codeVerifier);
 
  const params = new URLSearchParams({
    client_id: 'your_client_id',
    redirect_uri: 'https://app.example.com/callback',
    response_type: 'code',
    scope: 'inventory_read',
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
    state: generateRandomString(32)  // CSRF対策
  });
 
  return `https://api.stockflow.example.com/oauth/authorize?${params}`;
}

INFO

PKCEは元々モバイルアプリ向けに設計されましたが、現在はすべてのパブリッククライアント(シングルページアプリ、モバイルアプリ)での使用が推奨されています。RFC 9700では、PKCEはOAuth 2.0の必須要件になりました。

スコープによる細かい権限制御

# app/controllers/api/v1/inventory_controller.rb
module Api
  module V1
    class InventoryController < ApplicationController
      before_action -> { doorkeeper_authorize! :inventory_read }, only: [:index, :show]
      before_action -> { doorkeeper_authorize! :inventory_write }, only: [:create, :update, :destroy]
      before_action -> { doorkeeper_authorize! :export }, only: [:export]
 
      def index
        # スコープに応じてデータを制限
        items = current_resource_owner.inventory_items
 
        # exportスコープがない場合は機密フィールドを除外
        unless doorkeeper_token.scopes.include?(:export)
          items = items.select(:id, :name, :quantity, :updated_at)
        end
 
        render json: items
      end
 
      private
 
      def current_resource_owner
        User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
      end
    end
  end
end

OpenID Connect(OIDC)の実装

OIDCはOAuth 2.0の上に「認証」を追加したプロトコルだ。IDトークンでユーザー情報を取得できる。

# config/initializers/doorkeeper_openid_connect.rb
Doorkeeper::OpenidConnect.configure do
  issuer do |resource_owner, application|
    "https://api.stockflow.example.com"
  end
 
  signing_key Rails.application.credentials.dig(:oidc, :private_key)
 
  subject_types_supported [:public]
 
  resource_owner_from_access_token do |access_token|
    User.find(access_token.resource_owner_id)
  end
 
  auth_time_from_resource_owner do |resource_owner|
    resource_owner.last_sign_in_at
  end
 
  reauthenticate_resource_owner do |resource_owner, return_to|
    store_location_for resource_owner, return_to
    sign_out resource_owner
    redirect_to new_user_session_url
  end
 
  # UserInfoエンドポイントで返すクレーム
  claims do
    claim(:email, scope: :openid) { |resource_owner| resource_owner.email }
    claim(:name, scope: :profile) { |resource_owner| resource_owner.full_name }
    claim(:preferred_username, scope: :profile) { |resource_owner| resource_owner.username }
    claim(:updated_at, scope: :profile) { |resource_owner| resource_owner.updated_at.to_i }
 
    claim(:email_verified, scope: :email) { |resource_owner| resource_owner.email_confirmed? }
 
    # カスタムクレーム
    claim(:company_id, scope: :openid) { |resource_owner| resource_owner.company_id }
    claim(:roles, scope: :openid) { |resource_owner| resource_owner.roles.pluck(:name) }
  end
end

OAuthクライアントの管理画面

# app/controllers/oauth/applications_controller.rb
class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
  before_action :authenticate_user!
 
  def index
    # ユーザー自身が作成したアプリのみ表示
    @applications = current_user.oauth_applications.ordered_by(:created_at)
  end
 
  def create
    @application = Doorkeeper::Application.new(application_params)
    @application.owner = current_user
 
    if @application.save
      # シークレットは一度しか表示しない
      flash[:notice] = "Application created. Secret: #{@application.plaintext_secret}"
      redirect_to oauth_application_path(@application)
    else
      render :new
    end
  end
 
  private
 
  def application_params
    params.require(:doorkeeper_application).permit(
      :name,
      :redirect_uri,
      :scopes,
      :confidential
    )
  end
end

トークンの失効処理

# app/controllers/api/v1/oauth/revocations_controller.rb
module Api
  module V1
    module Oauth
      class RevocationsController < ApplicationController
        def create
          token = Doorkeeper::AccessToken.find_by(token: params[:token])
 
          if token&.resource_owner_id == current_user.id
            token.revoke
            render json: { message: "Token revoked successfully" }
          else
            render json: { error: "Token not found or unauthorized" }, status: :unprocessable_entity
          end
        end
 
        # ユーザーがアプリへのアクセスを全部取り消す
        def revoke_all
          application = Doorkeeper::Application.find(params[:application_id])
          Doorkeeper::AccessToken.revoke_all_for(application, current_user)
          render json: { message: "All tokens revoked" }
        end
      end
    end
  end
end

AWS Cognito との統合

外部のOAuthプロバイダーとして、AWS CognitoのUser Poolを使う構成もある。

# config/aws.yml
production:
  cognito:
    user_pool_id: "ap-northeast-1_XXXXXXXXX"
    client_id: "xxxxxxxxxxxxxxxxxxxxxxxxxx"
    client_secret: "<%= ENV['COGNITO_CLIENT_SECRET'] %>"
    region: "ap-northeast-1"
    domain: "auth.stockflow.example.com"
# app/services/cognito_oauth_service.rb
class CognitoOauthService
  COGNITO_BASE_URL = "https://#{ENV['COGNITO_DOMAIN']}.auth.#{ENV['AWS_REGION']}.amazoncognito.com"
 
  def self.exchange_code(code, redirect_uri)
    response = Faraday.post("#{COGNITO_BASE_URL}/oauth2/token") do |req|
      req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
      req.body = {
        grant_type: 'authorization_code',
        client_id: ENV['COGNITO_CLIENT_ID'],
        client_secret: ENV['COGNITO_CLIENT_SECRET'],
        code: code,
        redirect_uri: redirect_uri
      }.to_query
    end
 
    JSON.parse(response.body)
  end
 
  def self.refresh_tokens(refresh_token)
    response = Faraday.post("#{COGNITO_BASE_URL}/oauth2/token") do |req|
      req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
      req.body = {
        grant_type: 'refresh_token',
        client_id: ENV['COGNITO_CLIENT_ID'],
        client_secret: ENV['COGNITO_CLIENT_SECRET'],
        refresh_token: refresh_token
      }.to_query
    end
 
    JSON.parse(response.body)
  end
end

INFO

AWS Cognitoを使うと、MFA、パスワードリセット、ソーシャルログイン(Google、Facebook等)などの機能を自前で実装せずに提供できます。コンプライアンス要件(SOC 2、ISO 27001等)も満たしやすくなります。

チェックリスト

  • 認可コードフローでPKCEを強制している
  • インプリシットフローは無効化している(非推奨)
  • stateパラメータでCSRF攻撃を防いでいる
  • スコープが細かく定義されており、最小権限の原則を守っている
  • アクセストークンの有効期限は短い(1時間以内)
  • リフレッシュトークンのローテーションが設定されている
  • トークンの失効APIが実装されている
  • クライアントの認証情報(client_secret)が安全に管理されている
  • OIDCのIDトークンでユーザー情報を安全に取得できる