🌐

RailsをAPIとして使用して、 Auth0の認証機能を実装しよう!!

2024/05/21に公開

はじめに

前回、ひとまずRailsアプリケーションにAoth0という認証機能を実装し、Renderというサービスを利用してデプロイまで成功したので、勢いに乗って、今度はAuth0をAPIで使用する実装に挑戦してみました
!!!
RailsにAuth0の認証機能を追加、ついでにRenderでデプロイしてみた

この仕組みを理解できたら、フロントをReactで、バックエンドでRailsをAPIとして使用する、今流行りのアプリケーションを作ることができそう🎶

「Auth0をRailsのAPIで使用する」って、結局どういうこと?簡単に説明!!

Auth0とは?

認証・認可プラットフォームの一つです。
Auth0を利用すると、自分で開発したアプリ側では特に特別なコーディングをすることなく、スライドボタンをオンにするだけでfacebookやGoogle、Xなど30種類以上のソーシャルアカウントで簡単サインインができます。複雑なOAuthの実装やトークンの管理などに煩わされることなく、機能の開発に専念することができます。

APIとしてのRailsの役割

RailsをAPIとして使用する際は、フロントエンド(ユーザーインターフェース)とは独立したバックエンド(データ処理や保存を行う部分)をRailsで構築します。
従来、Railsを使えばユーザーインターフェースからデータベースまで、アプリケーション全体を一つのフレームワークで開発できるのですが、フロントエンド技術の発展に伴い、Railsをバックエンド(API)としてのみ使用し、フロントエンドはReactなどの別の技術を使って開発するケースが増えてきました。
RailsをAPIとして使う場合、Railsはクライアント(フロントエンド)からのリクエストを受け取り、それに応じてデータベースからデータを取得したり、データを保存したりといった処理を行います。そして、処理の結果をJSONなどの形式でクライアントに返します。

RailsをAPIとして使うメリットって何?

RailsをAPIとして使うことで、フロントエンドとバックエンドを分離し、それぞれを独立して開発・運用することができるため、フロントエンドとバックエンドで異なる技術を使うことができ、より柔軟で拡張性の高いアプリケーション開発が可能になります。
また、モバイルアプリやデスクトップアプリなど、ウェブアプリ以外のクライアントにもデータを提供できるようになるというメリットもあります。

では、実際にRailsをAPIとして使用する時の開発手順と、リクエストとレスポンスを確認する作業を行なっていきます!!

開発の手順

Auth0を設定する

Auth0アカウントを作成する

アカウントがない場合は、Auth0のアカウントを作成します。

Auth0にAPIを作成する

  • 左側のメニューバーからApplicationsの中からAPIsを選択し、+ Create APIのボタンをクリックします。
  • フォームに必要事項を入力していきます。
    • Name:アプリケーションの名前(Rails API appなどわかりやすいものにします)
    • Identifire:APIのエンドポイントURLを設定するのが良いようです(今回はhttps://api-rails-auth-app
    • Signing Algorithm:デフォルトでRS256が入っているのでそのままで大丈夫です。
      • RS256は秘密鍵と公開鍵のペアを使用するため、Auth0アカウントの公開鍵に対してトークンを検証します

CreateをクリックするとAuth0にAPIが作成されます。

  • ダッシュボードから権限の設定などを行うことができます。

権限を使用すると、特定のアクセス トークンを使用してユーザーに代わってリソースにアクセスする方法を定義できます。

今回はread:messagesスコープを使用してmessagesユーザーが管理者アクセス レベルを持っている場合はリソースへの読み取りアクセスを許可するよう設定します。

Rails側の環境構築をする

Railsアプリケーションを新規作成する

  • api_rails_auth_appという名前でアプリケーションを作成します。
rails new api_rails_auth_app --api

今回はapiでのみ使用するため、お尻に--apiのオプションをつけます。
--apiオプションを使用すると、APIに特化した軽量なRailsアプリケーションを作成できます。
不要なミドルウェアやファイルが除外され、APIの開発に最適化された環境が整う一方、フルスタックのWebアプリケーションを開発する場合に必要な機能が自動生成されないため注意が必要です。

Rails側でAuth0認証を追加するための設定を行う

アクセストークンの検証に必要なjwtをGemとしてインストールする

ruby-jwtはSecured受信アクセストークンによる認証を必要とするエンドポイントを承認するために使用されます。
jwtを使うことでAuth0のアクセストークンを正しく検証し、APIエンドポイントを保護することができるようになります。

gem 'jwt'
bundle install
JWTとアクセストークンの検証
  • Webアプリケーションでユーザーがログインすると、サーバはユーザーを識別するための情報を含んだアクセストークンを発行します。
  • アクセストークンは、ユーザーがログイン後に行うリクエストに含められ、サーバはこのトークンを確認することでユーザーを認証します。
  • RailsアプリケーションでAuth0を使用する場合、Auth0がアクセストークンを発行します。
    • このトークンは、JSON Web Token(JWT)という形式で作成されます。
    • JWTは、トークンの内容を暗号化し、改ざんを防ぐために電子署名が付与されています。
  • アプリケーション側では、ユーザーから受信したアクセストークンが正当なものであるかを検証する必要があります
    • トークンの署名が正当であること(Auth0の秘密鍵で署名されていること)
    • トークンが期限切れでないこと
    • 対象のアプリケーション向けに発行されたトークンであること
  • jwtを使うことで、トークンの復号と署名の検証、クレームの確認などを簡単に行うことができます。
    • Securedモジュールを定義したコントローラにbefore_action :authorizeを追加することで、アクセストークンによる認証が必要なエンドポイント(アクション)を指定できます。
    • authorizeメソッドの中でjwtを使ってトークンの検証を行います。
      • トークンの検証に成功した場合はリクエストを処理し、失敗した場合は適切なエラーレスポンスを返します。

Auth0Clientクラスを作成する

  • app/libディレクトの配下にAuth0Clientというクラスを作成します。

リクエストのヘッダーから取得した受信アクセストークンを検証するためのクラスになります。

アクセストークンの検証のながれ

Auth0が発行したアクセストークンについて、正当なものであるかをアプリケーション側で検証する必要があります。

  • リクエストのヘッダーから、Authorizationという名前のヘッダーを探します。
  • Authorizationヘッダーの値として設定されているアクセストークンを取り出します。
  • アクセストークンをデコード(復号)します。
  • デコードしたトークンの内容を検証します。
    • トークンの署名が正当であるか(Auth0の秘密鍵で署名されているか)
    • トークンが期限切れでないか
    • 対象のアプリケーション向けに発行されたトークンであるか
      app/libディレクトリの下にAuth0Clientクラスを定義することで、コントローラからトークンの検証機能を呼び出しやすくなり、コントローラのアクションでトークンの検証を行うことができます。
      トークンの検証に成功した場合はリクエストを処理し、失敗した場合は適切なエラーレスポンスを返します。
# app/lib/auth0_client.rb

# frozen_string_literal: true

require 'jwt'
require 'net/http'

class Auth0Client 

  # Auth0 Client Objects 
  Error = Struct.new(:message, :status)
  Response = Struct.new(:decoded_token, :error)

  # Helper Functions 
  def self.domain_url
    "https://#{Rails.configuration.auth0.domain}/"
  end

  def self.decode_token(token, jwks_hash)
    JWT.decode(token, nil, true, {
                 algorithm: 'RS256',
                 iss: domain_url,
                 verify_iss: true,
                 aud: Rails.configuration.auth0.audience,
                 verify_aud: true,
                 jwks: { keys: jwks_hash[:keys] }
               })
  end

  def self.get_jwks
    jwks_uri = URI("#{domain_url}.well-known/jwks.json")
    Net::HTTP.get_response jwks_uri
  end

  # Token Validation 
  def self.validate_token(token)
    jwks_response = get_jwks

    unless jwks_response.is_a? Net::HTTPSuccess
      error = Error.new(message: 'Unable to verify credentials', status: :internal_server_error)
      return Response.new(nil, error)
    end

    jwks_hash = JSON.parse(jwks_response.body).deep_symbolize_keys

    decoded_token = decode_token(token, jwks_hash)

    Response.new(decoded_token, nil)
  rescue JWT::VerificationError, JWT::DecodeError => e
    error = Error.new('Bad credentials', :unauthorized)
    Response.new(nil, error)
  end
end
  • config/application.rbを変更してlibフォルダ内のファイルを読み込む処理を追加します
# config/application.rb

module RailsAuth0
  class Application < Rails::Application
<中略>
    config.autoload_paths << "#{Rails.root}/lib"
  end
end

Securedモジュールを定義する

app/controllers/concerns配下にsecured.rbファイルを作成し、受信リクエストのAuthorizationヘッダーからアクセストークンを検索するSecuredモジュールを定義します。

Securedモジュールの役割

WebアプリケーションでAuth0を使用する場合、アクセストークンによる認証が必要なエンドポイント(APIのURL)にアクセスするにはリクエストに有効なアクセストークンを含める必要があります。
RailsでAPIを作成する場合、エンドポイントに対応するアクションはコントローラに定義します。
つまり、アクセストークンによる認証が必要なコントローラアクションでは、トークンの検証処理を行う必要があります。

Securedモジュールは、アクセストークンの検証処理を実装したモジュールで、各コントローラにインクルード(読み込み)することで、アクセストークンの検証機能が使えるようになります。

具体的なSecuredモジュールの処理

  • リクエストのヘッダーからAuthorizationヘッダーを取得する
  • Authorizationヘッダーの値からアクセストークンを取り出す
  • アクセストークンをAuth0Clientクラスに渡して検証する
  • トークンの検証結果に応じて、以下のような処理を行う
    • 検証成功:リクエストを処理する
    • 検証失敗:エラーレスポンスを返す

Securedモジュールをコントローラにインクルードし、アクションにbefore_action :authorizeを追加することで、そのコントローラアクションではアクセストークンの検証が行われるようになります。

# app/controllers/concerns/secured.rb

# frozen_string_literal: true

module Secured
  extend ActiveSupport::Concern

  REQUIRES_AUTHENTICATION = { message: 'Requires authentication' }.freeze
  BAD_CREDENTIALS = {
    message: 'Bad credentials'
  }.freeze
  MALFORMED_AUTHORIZATION_HEADER = {
    error: 'invalid_request',
    error_description: 'Authorization header value must follow this format: Bearer access-token',
    message: 'Bad credentials'
  }.freeze

  def authorize
    token = token_from_request
    return if performed?
    validation_response = Auth0Client.validate_token(token)
    return unless (error = validation_response.error)
    render json: { message: error.message }, status: error.status
  end

  private

  def token_from_request
    authorization_header_elements = request.headers['Authorization']&.split

    render json: REQUIRES_AUTHENTICATION, status: :unauthorized and return unless authorization_header_elements
    unless authorization_header_elements.length == 2
      render json: MALFORMED_AUTHORIZATION_HEADER, status: :unauthorized and return
    end
    scheme, token = authorization_header_elements
    render json: BAD_CREDENTIALS, status: :unauthorized and return unless scheme.downcase == 'bearer'
    token
  end
end

スコープの検証をするための記述をする

  • Auth0Clientクラスに、アクセストークンが要求されたリソースへのアクセスに必要十分なスコープがトークンにあるかどうかを検証するメソッドを追加します。
# app/lib/auth0_client.rb

class Auth0Client

Token = Struct.new(:token) do
    def validate_permissions(permissions)
      # スコープの検証ロジック
      required_permissions = Set.new permissions
      scopes = token[0]['scope']
      token_permissions = scopes.present? ? Set.new(scopes.split(" ")) : Set.new
      required_permissions <= token_permissions
    end
  end
  
  # ... 他のメソッド ...
end
アクセストークンのスコープとは

アクセストークンには、そのトークンが持つ権限を表す「スコープ」という情報が含まれています。
スコープは、トークンを使ってアクセスできるリソース(APIのエンドポイントなど)や、実行できる操作(読み取り、書き込みなど)を定義します。
例えば、read:messagesというスコープを持つトークンは、メッセージを読み取る権限を持っていますが、メッセージを書き込む権限は持っていません。

RailsアプリケーションでAPIを作成する場合、エンドポイントごとに必要なスコープを定義することがあります。
例えば、/api/privateエンドポイントにアクセスするには、read:privateスコープが必要だと定義したとします。この場合、/api/privateにアクセスするリクエストに含まれるアクセストークンがread:privateスコープを持っていない場合、アクセスを拒否する必要があります。

Auth0Clientクラスに定義したvalidate_permissionsメソッドによって、アクセストークンが特定のスコープを持っているかどうかを検証します。

  • validate_permissionsメソッドに、必要なスコープの配列を渡す
  • アクセストークンのペイロード(デコードされたトークンの中身)からスコープの情報を取り出す
  • 必要なスコープがすべてトークンのスコープに含まれているかどうかを確認する
  • すべてのスコープが含まれている場合はtrue、そうでない場合はfalseを返す

これにより、APIエンドポイントごとに必要なスコープを定義し、適切な権限を持つトークンでのみアクセスを許可することができます。

  • 適切な権限なしでリソースを要求しようとした場合に適切なエラーメッセージを返すためのSecuredエラー定数を定義します。

呼び出しの戻り値を更新し、最後にトークンに適切な権限があるかどうかを確認する新しいメソッドを作成し、そうでない場合はエラーメッセージとともにステータスコードを返します。

secured.rbに下記のコードを追加します。

# app/controllers/concerns/secured.rb

# frozen_string_literal: true

module Secured
  extend ActiveSupport::Concern

  # ... 

INSUFFICIENT_PERMISSIONS = {
    error: 'insufficient_permissions',
    error_description: 'The access token does not contain the required permissions',
    message: 'Permission denied'
  }.freeze


  def authorize
    token = token_from_request
    return if performed?
    validation_response = Auth0Client.validate_token(token)
    @decoded_token = validation_response.decoded_token # ここを追加する
    return unless (error = validation_response.error)
    render json: { message: error.message }, status: error.status
  end

  def validate_permissions(permissions)
    raise 'validate_permissions needs to be called with a block' unless block_given?
    return yield if @decoded_token.validate_permissions(permissions)

    render json: INSUFFICIENT_PERMISSIONS, status: :forbidden
  end

  private
  # ... 

Securedモジュールで定義されている処理を、各コントローラで呼び出せるようにする

  • ApplicationControllerにSecuredモジュールをインクルードします。

ApplicationControllerでSecuredモジュールをインクルードすることで、ApplicationControllerを継承しているすべてのコントローラでSecuredモジュールの機能を使えるようになります。

class ApplicationController < ActionController::API
  include Secured
end

コントローラを作成する

JWT付きのリクエストのみ許可する(認証を必要とする)privateのAPIコントローラを作成する

  • app/controllersの配下にprivate_controller.rbファイルを作成します。
# app/controllers/private_controller.rb

# frozen_string_literal: true
class PrivateController < ApplicationController

  def private
    render json: 'Hello from a private endpoint! You need to be authenticated to see this.'
  end
end
  • authorizeメソッドを追加します
    before_action :authorizeとすることで保護することができます。
    アクセストークンに適切な権限があることを確認するには、private-scopedアクション内でvalidate_permissionsメソッドを呼び出します。
# app/controllers/private_controller.rb

class PrivateController < ActionController::API
  before_action :authorize

  def private
    # スコープの検証に成功した場合の処理
    render json: { message: 'Hello from a private endpoint! You need to be authenticated to see this.' }
  end

  def private_scoped
    validate_permissions ['read:messages'] do
      # スコープの検証に成功した場合の処理
      render json: { message: 'Hello from a private endpoint! You need to be authenticated and have a scope of read:messages to see this.' }
    end
  end
end

認証を必要とせずに、一般公開するpublicのAPIコントローラを作成する

app/controllersの配下にpublic_controller.rbファイルを作成します。

# app/controllers/public_controller.rb

# frozen_string_literal: true
class PublicController < ApplicationController
  # こちらには authentication を使用するための`before_action :authorize`の記述は必要ありません
  def public
    render json: { message: "Hello from a public endpoint! You don't need to be authenticated to see this." }
  end
end

作成したコントローラに対するルーティングを定義します

Rails.application.routes.draw do
  get '/public', to: 'public#public'
  get '/private', to: 'private#private'
end

Railsの開発サーバ起動

以上で Rails API の構築は完了です!!!
rails sでRailsの開発サーバを起動しましょう。
おなじみのRailsの初期画面が表示されればOKです🎶

APIコールを試す

一般公開用APIへアクセスする

ブラウザから http://localhost:3000/public へアクセスします。
正常にレスポンスが返ってきている場合

{"message":"Hello from a public endpoint! You don't need to be authenticated to see this."}

というメッセージがJSONで返ってきます。

curlコマンドでの確認はこちら。

$ curl http://localhost:3000/public
{"message":"Hello from a public endpoint! You don't need to be authenticated to see this."}

認可機能付きAPIへアクセスする

認証なしでアクセスしてみる

ブラウザから http://localhost:3000/private へアクセスします。
ブラウザからの単純なアクセスではJWTを付与できないので、エラーメッセージが表示されます。

{"message":"Requires authentication"}

curlコマンドでの確認はこちら。
HTTPレスポンスコードとして401(Unauthorized)が返ってきているのがわかります。

curl -v http://localhost:3000/private
*   Trying [::1]:3000...
* Connected to localhost (::1) port 3000
> GET /private HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/8.4.0
> Accept: */*
> 
< HTTP/1.1 401 Unauthorized
< x-frame-options: SAMEORIGIN
< x-xss-protection: 0
< x-content-type-options: nosniff
< x-permitted-cross-domain-policies: none
< referrer-policy: strict-origin-when-cross-origin
< content-type: application/json; charset=utf-8
< vary: Accept
< cache-control: no-cache
< x-request-id: 0d6cf71f-0503-4762-85bd-f5de7feec158
< x-runtime: 0.012781
< server-timing: start_processing.action_controller;dur=0.02, halted_callback.action_controller;dur=0.01, process_action.action_controller;dur=0.74
< Content-Length: 37
< 
* Connection #0 to host localhost left intact
{"message":"Requires authentication"}
-v オプションをつける意味

-vオプションは、curlコマンドでリクエストを送信する際に、リクエストとレスポンスのより詳細な情報(verbose output)を表示するために使用します。

  • リクエストの詳細
    • 使用するHTTPメソッド(GET, POST, など)
    • リクエストのURL
    • リクエストのヘッダー
  • レスポンスの詳細
    • HTTPステータスコード(200 OK, 404 Not Found, など)
    • レスポンスのヘッダー
    • レスポンスのボディ

-vオプションをつけることで、APIのデバッグやトラブルシューティングに非常に役立ちます。

  • APIの認証が正しく機能しているかどうかを確認する
    認証が必要なエンドポイントにアクセスする際に、適切な認証ヘッダーが送信されているかどうか確認する
  • APIにアクセスしたときに期待した結果が返ってこない場合に問題の原因を特定する

認証ありでアクセスしてみる

curlでJWT付きのリクエストを送ってみます。

  • access_token(JWT)を取得します
    Auth0でAPIを作成すると自動的にM2M用のApplicationが作成されます。
    Auth0の「Applications」画面からAPIの名前のApplicationの詳細を開き「Settings」から情報を取得します。
DOMAIN=dev-40o731wzgfxo3z3h.us.auth0.com
CLIENT_ID=a0jcUmirzvlN3nZJStvIK1dhbGUiBE8A
CLIENT_SECRET=*****************************
AUDIENCE=https://api-rails-auth-app # API作成時に指定した`Identifier`の部分です
curl --url https://${DOMAIN}/oauth/token \
  --header 'content-type: application/json' \
  --data "{\"client_id\":\"${CLIENT_ID}\",\"client_secret\":\"${CLIENT_SECRET}\",\"audience\":\"${AUDIENCE}\",\"grant_type\":\"client_credentials\"}"
  • curl実行後のレスポンスで返ってきたaccess_tokenを添付してRailsへAPIコールを行います
curl http://localhost:3000/private \
>   -H "authorization: Bearer ${ACCESS_TOKEN}"
Auth0のAPIを直接呼び出す手順

Auth0のトークンエンドポイント(通常はhttps://YOUR_DOMAIN/oauth/token)に対して、POSTリクエストを送信します。
リクエストのボディに以下のパラメータを含めます:

`grant_type`: # トークンの取得方法を指定します。クライアント認証の場合は`client_credentials`を使用します
`client_id`: # Auth0ダッシュボードで作成したAPIのクライアントIDを指定します
`client_secret`: # Auth0ダッシュボードで作成したAPIのクライアントシークレットを指定します
`audience`: # APIのオーディエンス(識別子)を指定します。Auth0ダッシュボードのAPIの設定ページで確認できます。

リクエストを送信すると、Auth0からaccess_tokenの値が返されます。

取得したアクセストークンを使用して、認証が必要なエンドポイントに対して、リクエストヘッダーにAuthorization: Bearer <access_token>を含めたGETリクエストを送信します。

これらの方法を使用することで、フロントエンドを作成していない場合でも、Auth0からアクセストークンを取得し、APIの認証をテストすることができます。

うまくいかないこと

これでスッキリ、APIでAuth0の認証実装できた〜!!!
といくはずだったんですが。。。
Privateのほうは認証で401エラーになってしまいます・・・😭

Started GET "/private" for ::1 at 2024-05-25 23:47:28 +0900
  ActiveRecord::SchemaMigration Load (0.9ms)  SELECT "schema_migrations"."version" FROM "schema_migrations" ORDER BY "schema_migrations"."version" ASC
Processing by PrivateController#private as */*
  Parameters: {"private"=>{}}
Filter chain halted as :authorize rendered or redirected
Completed 401 Unauthorized in 1ms (Views: 0.3ms | ActiveRecord: 0.0ms | Allocations: 313)

Postmanやcurlを使って、アクセストークンを取得するところまではうまくいっていそうですが、authorizeフィルタ内で認証が失敗しているようです。

原因として考えられることとして

  • リクエストに有効なアクセストークンが含まれていない
  • アクセストークンが無効または期限切れになっている
  • アクセストークンに必要な権限(スコープ)が不足している
    など??
    Auth0のサンプルAPIと比較したり、AIさんにも聞いてみたりといろいろやってみてますが、解決方法がみつからずです。

ちなみに、サンプルAPIを見ながら追加した記述は以下の箇所です。

  • config/routes.rbにprivate-scopedのルーティングを追加
get '/private-scoped', to: 'private#private_scoped'
  • config/application.rbに追加
config.auth0 = config_for(:auth0)

config_for(:auth0)は、config/auth0.ymlファイルのをに記載されている設定値を読み込み、環境ごとの設定値を取得します。
取得した設定値は、Rails.application.config.auth0に格納されます。

  • config/auth0.ymlに追加
development:
  domain: <%= ENV["AUTH0_DOMAIN"] %>
  audience: <%= ENV["AUTH0_API_AUDIENCE"] %>

環境ごとのAuth0の設定値を定義するために使用されます。
Auth0のドメインとオーディエンスの値を環境変数から取得し、アプリケーションの設定に反映させることができます。

  • .env ファイルを作成します
    Gemfile に dotenv-rails gemを追加しbundle installします。
    .envの中身を記述します。
AUTH0_DOMAIN=dev-40o731wzgfxo3z3h.us.auth0.com
AUTH0_AUDIENCE=https://api-rails-auth-app

今回の実装で学んだこと

便利機能をサクッと使おうと思っても、基礎がわかっていないとやはりスムーズにいかないです。
Web周りの技術書や記事を読んだりしながら、リクエストとレスポンスの流れなどについても勉強しておかなければとつくづく実感しました。

今後の展望

このAPIをDocker環境に移し、本番環境で動かすことが最終目標です!!!
そうすれば、チーム開発において開発環境の構築がスムーズに行くはず。
Privateの認証エラーと戦いつつ、そちらも勉強していきます💪

参考資料

Auth0公式ドキュメント
Rails 7 と Auth0 による認可付きAPIの構築
Webを支える技術 -HTTP、URI、HTML、そしてREST

Discussion