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
endPKCEによるセキュリティ強化
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
endOpenID 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
endOAuthクライアントの管理画面
# 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
endAWS 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
endINFO
AWS Cognitoを使うと、MFA、パスワードリセット、ソーシャルログイン(Google、Facebook等)などの機能を自前で実装せずに提供できます。コンプライアンス要件(SOC 2、ISO 27001等)も満たしやすくなります。
チェックリスト
- 認可コードフローでPKCEを強制している
- インプリシットフローは無効化している(非推奨)
- stateパラメータでCSRF攻撃を防いでいる
- スコープが細かく定義されており、最小権限の原則を守っている
- アクセストークンの有効期限は短い(1時間以内)
- リフレッシュトークンのローテーションが設定されている
- トークンの失効APIが実装されている
- クライアントの認証情報(client_secret)が安全に管理されている
- OIDCのIDトークンでユーザー情報を安全に取得できる