🕌

イチカラOAuthとOIDC理解#4 - アクセストークンを検証してみる

2023/10/26に公開

アクセストークンで利用するリソースサーバーを実際に作ってみよう

色んな本が出ているが初めてOAuthやOIDC(OpenID Connect)に触れる人にはアーキテクチャの世界観が掴みにくい。
リソースサーバーでアクセストークンを検証するとはどういうことなのか体験してみて理解するための備忘録。

注意事項


  • ※ Rancher Desktopではなく、Docker Desktopを使いたい場合
    Docker Desktopは条件に該当した場合のみ無料で利用できるように規約が変更になっている。
    会社から貸与されたPCなどでDocker Desktopを使用すると有償ライセンス条件に引っかかる可能性あります。
    このため、有償条件に該当する企業に所属している場合でDocker Desktopを使用したい場合は、自己学習として自宅のPCで実行することをおすすめします。

    Docker Desktop は、小規模なビジネス向け(従業員 250名未満、かつ収益 1 千万ドル未満)、個人利用、教育目的、非商用のオープンソースプロジェクトに対しては、無償提供が継続されます。 大規模なエンタープライズ向けのプロフェッショナル利用に対しては、有償サブスクリプションが必要です。

    出典: https://matsuand.github.io/docs.docker.jp.onthefly/desktop/faqs/

アクセストークンの検証方法には二つの方法がある

  • 手段1: 認可サーバー(AuthorizationSever)が提供しているイントロスペクションエンドポイントにリクエストを行い確認する。

  • 手段2: 認可サーバー(AuthorizationSever)から取得した公開鍵を利用し、access_tokenの署名を検証して確認する。

Auth0には手段1のイントロスペクションエンドポイントが存在しないため、手段2の方法でaccess_tokenの正当性を確認する。

やることのイメージ

今までの手順で、React.jsとAuth0を利用し、クライアントアプリケーション(Relyng Party)と認可サーバー(OpenID Provider)の作成はできている。
本章ではこれまでの章で発行したaccess_tokenでリクエストするリソースサーバーのAPIをRubyOnRailsで作成する。
API側ではリクエストしたaccess_tokenの検証を行なう。

ローカルの開発環境の用意

1.任意のディレクトリで、sample_apiの名前でフォルダを作成する

2.作成したフォルダをvisual studio codeで開き、Dockerfiledocker-compose.ymlファイルを新規作成する

  • Dockerfile
FROM ruby:3.2.2
# yarnをinstallするためのリポジトリーを取得
# https://classic.yarnpkg.com/en/docs/install#debian-stable
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
    echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list

# ライブラリをインストール
RUN apt-get update -qq && \
    apt-get install -y build-essential libpq-dev nodejs postgresql-client yarn vim

ENV EDITOR=vim
WORKDIR /sample_api

# Docker内でGemfileに記載のライブラリをインストールする
COPY Gemfile /sample_api/Gemfile
COPY Gemfile.lock /sample_api/Gemfile.lock
RUN bundle install && bundle update
COPY . /sample_api

RUN bundle install && bundle update

RUN rm -f tmp/pids/server.pid
  • docker-compose.yml
version: '3'
services:
  db:
    image: postgis/postgis:13-master
    volumes:
      - postgres:/var/lib/postgresql/data
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=password
      - PGPASSWORD=password
    ports:
      - "5433:5432"
  web:
    build: .
    command: tail -f /dev/null
    volumes:
      - .:/sample_api
      - bundle:/usr/local/bundle
      - rails-cache:/sample_api/tmp/cache
    ports:
      - "3001:3001"

volumes:
  postgres:
    driver: local
  bundle:
    driver: local
  rails-cache:
    driver: local


  • 実際の画像

3.PC上でDockerが起動していることを確認する

powershellなどコマンドラインでdocker psと打ったら応答が返ってくることを確認する。

C:\Users> docker ps

4.GemfileとGemfile.lockを作成する

  • Gemfile
source 'https://rubygems.org'
gem 'rails', '7.1.1'

  • Gemfile.lockは空のままでOK

5.ctrlキー@キーでTERMINALを開く

  • コマンドラインでdocker-compose buildを実行する。
    buildが始まり、正常終了したことを確認する

  • docker-compose up -dをTERMINALに入力しenter
    正常終了を確認する。

  • docker-compose ps -aをTERMINALに入力しenter
    docker-compose.ymlで定義したwebと付けたサービスとdbとつけたサービスが起動していることを確認する

  • docker-compose exec web bashをTERMINALに入力しenter
    webとつけたサービスの中に入り、シェルのbashを起動している

6.Railsアプリを作成する

  • terminal上で以下を入力し、enter
    docker-compose run --rm web rails new . --force --no-deps --database=postgresql --api

  • Dockerfileの中身が書き換わった場合は、手順2で作成したDockerfileの内容に戻す。

  • /sample_api/config/database.ymlを修正
    defaultの行の下に、hostusernamepassword行を追加する。

default: &default
  adapter: postgresql
  encoding: unicode
  host: db
  username: postgres
  password: password
  # For details on connection pooling, see Rails configuration guide
  # https://guides.rubyonrails.org/configuring.html#database-pooling
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

development:
  <<: *default
  database: sample_api_development

・・・(省略)

  • コマンドラインでもう一度docker-compose buildを実行する。
    ※もし …省略failed to fetch anonymous token…省略みたいなエラーが出たら、以下を実行後にdocker-compose buildを再実行する。

    # windowsの場合(Powershellの場合)
    set DOCKER_BUILDKIT=0
    set COMPOSE_DOCKER_CLI_BUILD=0
    
    # macの場合
    export DOCKER_BUILDKIT=0
    export COMPOSE_DOCKER_CLI_BUILD=0
    
    # docker-compose buildの再実行
    docker-compose build
    

    ※それでもerrorが出る場合は、docker loginを行なう。

    docker login
    
    # その後はDocker Hubに登録してあるユーザーとパスワードを入力しEnter
    
    # ログインできたら、docker-compose buildの再実行
    docker-compose build
    

    https://hub.docker.com/

  • docker-compose up -dをTERMINALに入力しenter

  • docker-compose ps -aをTERMINALに入力しenter
    docker-compose.ymlで定義したwebと付けたサービスとdbとつけたサービスが起動していることを確認する

  • docker-compose exec web bashをTERMINALに入力しenter
    webとつけたサービスの中に入り、シェルのbashを起動している

  • bash上で、bundle installを入力し、enter

  • bash上で、bundle updateを入力し、enter

  • bash上で、rails db:createを入力し、enter

  • bash上で、rails s -b 0.0.0.0 -p 3001を入力しenterすると、Railsサーバーが起動する。

  • ブラウザでhttp://localhost:3001と入力すると、Railsの起動画面が表示されることを確認する。

  • serverを停止したい場合はctrlキーcキーでサーバーを終了できる。

ここまでやったこと

リソースサーバーの起動が成功しました。
続いて、リソースサーバーでAPIの作成、access_tokenの検証を行う実装を行っていく。

APIの作成

今回はAPIを作ることが目的ではなく、APIで受け取ったアクセストークンをどのように検証しているかを確認することが目的。
このためAPIの中身の実装には深くはフォーカスしない。

1-a.APIの作成

  • bash上でrails g controller profilesを実行

  • /sample_api/routes.rbファイルを開き、以下の定義を設定する。
  root "profiles#index"
  resources :profiles

  • /sample_api/app/controllers/profiles_controller.rbを下記のように編集して保存。
class ProfilesController < ApplicationController
    def index

        render :json => {id: 123, address: "東京都タワーサンプルホテル1丁目3番地"}
    end
end

1-b.作成したAPIの確認

  • ブラウザでhttp://localhost:3001と入力し、Enter
    以下のようにJSONが表示されていることを確認できれば、APIが作成できた。

  • Postmanでも確認してみる。
    レスポンスに指定したJSONが返却されていることを確認できる。

2.ライブラリのインストール

編集するファイル: Gemfile

  • access_token検証のためのライブラリ
    gem "jwt"を記載する

  • ローカル開発環境で環境変数を扱いやすくするためのライブラリ
    gem "dotenv-rails"を記載する

source "https://rubygems.org"

ruby "3.2.2"

# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 7.1.1"

# Use postgresql as the database for Active Record
gem "pg", "~> 1.1"

# Use the Puma web server [https://github.com/puma/puma]
gem "puma", ">= 5.0"

# Build JSON APIs with ease [https://github.com/rails/jbuilder]
# gem "jbuilder"

# Use Redis adapter to run Action Cable in production
# gem "redis", ">= 4.0.1"

# Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis]
# gem "kredis"

# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
# gem "bcrypt", "~> 3.1.7"

# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]

# Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false

# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
# gem "image_processing", "~> 1.2"

# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin Ajax possible
# gem "rack-cors"

# https://github.com/jwt/ruby-jwt
gem "jwt"


group :development, :test do
  # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
  gem "debug", platforms: %i[ mri windows ]

  # https://github.com/bkeepers/dotenv
  gem "dotenv-rails"
end

group :development do
  # Speed up commands on slow machines / big apps [https://github.com/rails/spring]
  # gem "spring"

end


  • bash上でbundle installを実行

3.環境変数の定義を追加

.envファイルを作成する

  • AUTH0_DOMAINには以下の値を入れる。
    https://自分のAuth0のドメイン.us.auth0.com
    = 以下画像の赤字の部分を指定。

  • AUTH0_AUDIENCEにはAuth0に登録したAPI情報のhttp://localhost:3001

4.Auth0Client クラスを作成する

Create an Auth0Client classを参考にし、Auth0Clientには各APIで利用する共通のメソッドを定義する。

  • 作成するファイル
    app/lib/auth0_client.rb
# 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
    # Auth0のドメイン
    ENV.fetch("AUTH0_DOMAIN", nil)
  end

  # access_tokenのデコード
  ## JWT.decodeメソッドの中身は下記を参照
  ## https://github.com/jwt/ruby-jwt/blob/main/lib/jwt/decode.rb
  def self.decode_token(token, jwks_hash)

    JWT.decode(token, nil, true, {
                 algorithm: 'RS256',
                 iss: domain_url,
                 verify_iss: true,
                 aud: ENV.fetch("AUTH0_AUDIENCE", nil),
                 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

  # access_tokenの正当性を検証 
  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

    # access_tokenの署名を検証とデコード処理
    decoded_token = decode_token(token, jwks_hash)

    Response.new(decoded_token, nil)

    # 今回は理解促進のためライブラリruby-jwtのエラーをそのまま返す
    # 補足: 
    # 通常はAPI呼び出し側に何が原因でトークン検証がエラーになったかの理由を教えるべきではないので、
    # Bad Credentialのエラーメッセージを返したり、
    # それさえせずに401ステータスでunauthorizedメッセージだけを返す方が望ましい
  rescue JWT::VerificationError, JWT::DecodeError => e
    error = Error.new(e.message, :unauthorized)
    Response.new(nil, error)
  end
end

5.アクセストークンの検証処理をハンドリングする共通処理を作成

Define a Secured concernを参考にし、Auth0Clientには各APIで利用する共通のメソッドを定義する。

  • 作成するファイル
    app/controllers/concerns/secured.rb
# frozen_string_literal: true

# app/controllers/concerns/secured.rb
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

    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)
  
      # access_tokenの検証結果がNGだった場合はエラーを返す
      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
  

    # リクエストヘッダーのAuthorization値を取得
    def token_from_request
      authorization_header_elements = request.headers['Authorization']&.split
  
      # もしリクエストヘッダーにAuthorizationがない場合はエラーレスポンスを返す
      render json: REQUIRES_AUTHENTICATION, status: :unauthorized and return unless authorization_header_elements
  
      # もしリクエストヘッダーにAuthorizationの値の要素が2でなければ、エラーを返す
      # 想定しているAuthorizationの値の形式はBearer access-tokenの2つの要素のため
      unless authorization_header_elements.length == 2
        render json: MALFORMED_AUTHORIZATION_HEADER,
               status: :unauthorized and return
      end
  
      scheme, token = authorization_header_elements
  
      # もしリクエストヘッダーにAuthorizationの値の最初の要素の値がBearerでなければエラーを返す
      render json: BAD_CREDENTIALS, status: :unauthorized and return unless scheme.downcase == 'bearer'
  
      token
    end
  end

6.APIでaccess_tokenの検証処理が行われるように定義する

  • 編集するファイル

app/controllers/application_controller.rb

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
    # # app/controllers/concerns/secured.rbをincludeする
    include Secured
end

app/controllers/profiles_controller.rb
before_actionの定義を追加する

# app/controllers/profiles_controller.rb
class ProfilesController < ApplicationController
    # APIの処理が呼び出される前にaccess_tokenの検証処理を実行する
    before_action :authorize

    def index

        render :json => {id: 123, address: "東京都タワーサンプルホテル1丁目3番地"}
    end
end



これでaccess_tokenの検証処理まで作成が完了した!

access_tokenの検証が行われるかAPIを試してみる

正当なaccess_tokenの場合

APIにリクエストを行うと200ステータスで欲しい情報のレスポンスデータを取得できる。

正当でないaccess_tokenの場合

  • 例:有効期限が切れているaccess_token(JWT)

  • 例:Auth0の自身の環境の公開鍵に対応しない秘密鍵で署名されたaccess_token(JWT)

確認が終わりコンテナを停止したい場合

確認が終わりコンテナを停止したい場合は以下手順で行える。

  • 起動しているDockerコンテナを確認する。
docker-compose ps

以下が表示される。

sample_api-db-1 省略
sample_api-web-1 省略
  • 以下コマンドでRailsをDockerコンテナを終了する
docker compose down
  • 起動しているDockerコンテナを確認し、起動しているコンテナがないことを確認する
docker-compose ps

参考

1: 今回作成したAPIのコード

今回作成したAPIのコードは以下レポジトリに置いてあります。
https://github.com/sktaz/sample_api

2: ruby-jwtのaccess_token検証処理

  • ライブラリruby-jwtでaccess_tokenの検証(公開鍵での署名の検証等)のロジック部分は下記を参照。

https://github.com/jwt/ruby-jwt/blob/main/lib/jwt/decode.rb

3: ruby-jwtのaccess_token検証処理(Claimsの検証)

  • ライブラリruby-jwtでaccess_tokenのClaims(expの値など)を検証しているロジック部分は下記を参照。

https://github.com/jwt/ruby-jwt/blob/main/lib/jwt/verify.rb

Discussion