RBAC と JWT 検証で Sinatra API を保護する
このガイドでは、Logto が発行する ロールベースのアクセス制御 (RBAC) と JSON Web Token (JWT) を利用して Sinatra API に認可 (Authorization) を実装し、API を安全に保護する方法を説明します。
始める前に
クライアントアプリケーションは Logto から アクセス トークン (Access token) を取得する必要があります。まだクライアント統合を設定していない場合は、React、Vue、Angular などのクライアントフレームワーク向け クイックスタート や、サーバー間アクセス用の マシン間通信 (M2M) ガイド をご覧ください。
このガイドは、Sinatra アプリケーションにおけるこれらのトークンの サーバーサイド検証 に焦点を当てています。
学べること
- JWT 検証:アクセス トークン (Access token) を検証し、認証 (Authentication) 情報を抽出する方法
 - ミドルウェア実装:API 保護のための再利用可能なミドルウェアの作成
 - 権限モデル:さまざまな認可 (Authorization) パターンの理解と実装
- アプリケーション全体のエンドポイント向けグローバル API リソース
 - テナント固有の機能制御のための組織 (Organization) 権限
 - マルチテナントデータアクセスのための組織レベル API リソース
 
 - RBAC 統合:API エンドポイントでロールベースの権限 (Permission) とスコープ (Scope) を強制する方法
 
前提条件
- Ruby の最新安定版がインストールされていること
 - Sinatra および Web API 開発の基礎知識
 - Logto アプリケーションが設定済み(必要に応じて クイックスタート を参照)
 
権限モデルの概要
保護を実装する前に、アプリケーションアーキテクチャに適した権限モデルを選択してください。これは Logto の 3 つの主要な 認可 (Authorization) シナリオ に対応しています:
- グローバル API リソース
 - 組織(非 API)権限
 - 組織レベル API リソース
 

- ユースケース: アプリケーション全体で共有される API リソースを保護する(組織固有ではない)
 - トークンタイプ: グローバルオーディエンスを持つアクセス トークン
 - 例: パブリック API、コアプロダクトサービス、管理エンドポイント
 - 最適: すべての顧客が利用する API を持つ SaaS プロダクト、テナント分離のないマイクロサービス
 - 詳細: グローバル API リソースの保護
 

- ユースケース: 組織固有のアクション、UI 機能、ビジネスロジックを制御する(API ではない)
 - トークンタイプ: 組織固有オーディエンスを持つ組織トークン
 - 例: 機能ゲーティング、ダッシュボード権限、メンバー招待コントロール
 - 最適: 組織固有の機能やワークフローを持つマルチテナント SaaS
 - 詳細: 組織(非 API)権限の保護
 

- ユースケース: 特定の組織コンテキスト内でアクセス可能な API リソースを保護する
 - トークンタイプ: API リソースオーディエンス + 組織コンテキストを持つ組織トークン
 - 例: マルチテナント API、組織スコープのデータエンドポイント、テナント固有のマイクロサービス
 - 最適: API データが組織スコープとなるマルチテナント SaaS
 - 詳細: 組織レベル API リソースの保護
 
💡 進める前にモデルを選択してください — このガイド全体で選択したアプローチを参照します。
クイック準備手順
Logto リソースと権限の設定
- グローバル API リソース
 - 組織(非 API)権限
 - 組織レベル API リソース
 
- API リソースの作成: コンソール → API リソース にアクセスし、API を登録します(例: 
https://api.yourapp.com) - 権限の定義: 
read:productsやwrite:ordersなどのスコープを追加します – 権限付き API リソースの定義 を参照 - グローバルロールの作成: コンソール → ロール にアクセスし、API 権限を含むロールを作成します – グローバルロールの設定 を参照
 - ロールの割り当て: API アクセスが必要なユーザーまたは M2M アプリケーションにロールを割り当てます
 
- 組織権限の定義: 組織テンプレートで 
invite:memberやmanage:billingなどの非 API 組織権限を作成します - 組織ロールの設定: 組織テンプレートで組織固有のロールを設定し、それらに権限を割り当てます
 - 組織ロールの割り当て: 各組織コンテキスト内でユーザーに組織ロールを割り当てます
 
- API リソースの作成: 上記と同様に API リソースを登録しますが、組織コンテキストで使用します
 - 権限の定義: 組織コンテキストにスコープされた 
read:dataやwrite:settingsなどのスコープを追加します - 組織テンプレートの設定: API リソース権限を含む組織ロールを設定します
 - 組織ロールの割り当て: API 権限を含む組織ロールにユーザーまたは M2M アプリケーションを割り当てます
 - マルチテナント設定: API が組織スコープのデータとバリデーションを処理できることを確認します
 
ロールベースのアクセス制御ガイド からステップバイステップのセットアップ手順を始めましょう。
クライアントアプリケーションの更新
クライアントで適切なスコープをリクエストする:
- ユーザー認証 (Authentication): アプリの更新 → で API スコープや組織コンテキストをリクエスト
 - マシン間通信: M2M スコープの設定 → でサーバー間アクセスを設定
 
通常、クライアント設定を次のいずれか、または複数を含めるように更新します:
- OAuth フローでの 
scopeパラメーター - API リソースアクセス用の 
resourceパラメーター - 組織コンテキスト用の 
organization_id 
テストするユーザーまたは M2M アプリが、API に必要な権限を含む適切なロールまたは組織ロールに割り当てられていることを確認してください。
API プロジェクトの初期化
新しい Sinatra プロジェクトを初期化するには、ディレクトリを作成し、基本的な構造をセットアップします:
mkdir your-api-name
cd your-api-name
Gemfile を作成します:
source 'https://rubygems.org'
gem 'sinatra'
依存関係をインストールします:
bundle install
基本的な Sinatra アプリケーションを作成します:
require 'sinatra'
require 'json'
get '/' do
  content_type :json
  { message: 'Hello from Sinatra API' }.to_json
end
開発サーバーを起動します:
ruby app.rb
ルート、ミドルウェア、その他の機能のセットアップ方法については、Sinatra のドキュメントを参照してください。
定数とユーティリティの初期化
トークンの抽出と検証を処理するために、コード内で必要な定数やユーティリティを定義してください。有効なリクエストには、Authorization ヘッダーが Bearer <アクセス トークン (Access token)> の形式で含まれている必要があります。
module AuthConstants
  JWKS_URI = 'https://your-tenant.logto.app/oidc/jwks'
  ISSUER = 'https://your-tenant.logto.app/oidc'
end
class AuthInfo
  attr_accessor :sub, :client_id, :organization_id, :scopes, :audience
  def initialize(sub, client_id = nil, organization_id = nil, scopes = [], audience = [])
    @sub = sub
    @client_id = client_id
    @organization_id = organization_id
    @scopes = scopes
    @audience = audience
  end
  def to_h
    {
      sub: @sub,
      client_id: @client_id,
      organization_id: @organization_id,
      scopes: @scopes,
      audience: @audience
    }
  end
end
class AuthorizationError < StandardError
  attr_reader :status
  def initialize(message, status = 403)
    super(message)
    @status = status
  end
end
module AuthHelpers
  def extract_bearer_token(request)
    authorization = request.headers['Authorization']
    raise AuthorizationError.new('Authorization header is missing', 401) unless authorization
    raise AuthorizationError.new('Authorization header must start with "Bearer "', 401) unless authorization.start_with?('Bearer ')
    authorization[7..-1] # 'Bearer ' プレフィックスを削除
  end
end
Logto テナント情報の取得
Logto が発行したトークンを検証するには、次の値が必要です:
- JSON Web Key Set (JWKS) URI:JWT 署名を検証するために使用される Logto の公開鍵の URL。
 - 発行者 (Issuer):期待される発行者値(Logto の OIDC URL)。
 
まず、Logto テナントのエンドポイントを見つけます。これはさまざまな場所で確認できます:
- Logto コンソールの 設定 → ドメイン で確認できます。
 - Logto で設定した任意のアプリケーション設定内の 設定 → エンドポイント & 資格情報 で確認できます。
 
OpenID Connect ディスカバリーエンドポイントから取得する
これらの値は、Logto の OpenID Connect ディスカバリーエンドポイントから取得できます:
https://<your-logto-endpoint>/oidc/.well-known/openid-configuration
以下はレスポンス例です(他のフィールドは省略しています):
{
  "jwks_uri": "https://your-tenant.logto.app/oidc/jwks",
  "issuer": "https://your-tenant.logto.app/oidc"
}
コード内にハードコーディングする(推奨されません)
Logto では JWKS URI や発行者 (Issuer) のカスタマイズができないため、これらの値をコード内にハードコーディングすることも可能です。ただし、将来的に設定が変更された場合のメンテナンス負荷が増加する可能性があるため、本番アプリケーションでは推奨されません。
- JWKS URI:
https://<your-logto-endpoint>/oidc/jwks - 発行者 (Issuer):
https://<your-logto-endpoint>/oidc 
トークンと権限の検証
トークンを抽出し OIDC 設定を取得した後、次の点を検証してください:
- 署名:JWT は有効であり、Logto(JWKS 経由)によって署名されている必要があります。
 - 発行者 (Issuer):Logto テナントの発行者 (Issuer) と一致している必要があります。
 - オーディエンス (Audience):Logto に登録された API のリソースインジケーター、または該当する場合は組織コンテキストと一致している必要があります。
 - 有効期限:トークンが有効期限切れでないこと。
 - 権限 (スコープ) (Permissions (scopes)):トークンに API / アクションに必要なスコープが含まれている必要があります。スコープは 
scopeクレーム内のスペース区切り文字列です。 - 組織コンテキスト:組織レベルの API リソースを保護する場合、
organization_idクレームを検証してください。 
JWT の構造やクレームについて詳しくは JSON Web Token を参照してください。
各権限モデルで確認すべきこと
クレームや検証ルールは権限モデルによって異なります:
- グローバル API リソース
 - 組織(非 API)権限
 - 組織レベル API リソース
 
- オーディエンスクレーム (
aud): API リソースインジケーター - 組織クレーム (
organization_id): なし - チェックするスコープ(権限) (
scope): API リソース権限 
- オーディエンスクレーム (
aud):urn:logto:organization:<id>(組織コンテキストがaudクレームに含まれる) - 組織クレーム (
organization_id): なし - チェックするスコープ(権限) (
scope): 組織権限 
- オーディエンスクレーム (
aud): API リソースインジケーター - 組織クレーム (
organization_id): 組織 ID(リクエストと一致する必要あり) - チェックするスコープ(権限) (
scope): API リソース権限 
非 API 組織権限の場合、組織コンテキストは aud
クレーム(例:urn:logto:organization:abc123)で表されます。organization_id
クレームは組織レベル API リソーストークンにのみ存在します。
セキュアなマルチテナント API のため、必ず権限(スコープ)とコンテキスト(オーディエンス、組織)の両方を検証してください。
検証ロジックの追加
jwt gem を使用して JWT を検証します。Gemfile に追加してください:
gem 'jwt'
# net-http は Ruby 2.7 以降の標準ライブラリの一部なので、明示的に追加する必要はありません
次に、以下を実行します:
bundle install
まず、JWKS とトークン検証を扱うための共通ユーティリティを追加します:
require 'jwt'
require 'net/http'
require 'json'
class JwtValidator
  include AuthHelpers
  def self.fetch_jwks
    @jwks ||= begin
      uri = URI(AuthConstants::JWKS_URI)
      response = Net::HTTP.get_response(uri)
      raise AuthorizationError.new('Failed to fetch JWKS', 401) unless response.is_a?(Net::HTTPSuccess)
      jwks_data = JSON.parse(response.body)
      JWT::JWK::Set.new(jwks_data)
    end
  end
  def self.validate_jwt(token)
    jwks = fetch_jwks
    # JWT ライブラリに JWKS からアルゴリズム検出を任せる
    decoded_token = JWT.decode(token, nil, true, {
      iss: AuthConstants::ISSUER,
      verify_iss: true,
      verify_aud: false, # 権限モデルに基づきオーディエンスは手動で検証します
      jwks: jwks
    })[0]
    verify_payload(decoded_token)
    decoded_token
  end
  def self.create_auth_info(payload)
    scopes = payload['scope']&.split(' ') || []
    audience = payload['aud'] || []
    AuthInfo.new(
      payload['sub'],
      payload['client_id'],
      payload['organization_id'],
      scopes,
      audience
    )
  end
  def self.verify_payload(payload)
    # 権限モデルに基づく検証ロジックをここに実装してください
    # この内容は下記の権限モデルセクションで示します
  end
end
次に、アクセス トークン (Access token) を検証するミドルウェアを実装します:
class AuthMiddleware
  include AuthHelpers
  def initialize(app)
    @app = app
  end
  def call(env)
    request = Rack::Request.new(env)
    # 特定のルートのみを保護
    if request.path.start_with?('/api/protected')
      begin
        token = extract_bearer_token(request)
        decoded_token = JwtValidator.validate_jwt(token)
        # 認証情報を env に保存し、汎用的に利用できるようにする
        env['auth'] = JwtValidator.create_auth_info(decoded_token)
      rescue AuthorizationError => e
        return [e.status, { 'Content-Type' => 'application/json' }, [{ error: e.message }.to_json]]
      rescue JWT::DecodeError, JWT::VerificationError, JWT::ExpiredSignature => e
        return [401, { 'Content-Type' => 'application/json' }, [{ error: 'Invalid token' }.to_json]]
      end
    end
    @app.call(env)
  end
end
権限モデルに従い、JwtValidator 内で適切な検証ロジックを実装してください:
- グローバル API リソース
 - 組織 (Organization) 権限(非 API)
 - 組織レベル API リソース
 
def self.verify_payload(payload)
  # audience クレームが API リソースインジケーターと一致するか確認
  audiences = payload['aud'] || []
  unless audiences.include?('https://your-api-resource-indicator')
    raise AuthorizationError.new('Invalid audience')
  end
  # グローバル API リソースに必要なスコープを確認
  required_scopes = ['api:read', 'api:write'] # 実際に必要なスコープに置き換えてください
  token_scopes = payload['scope']&.split(' ') || []
  unless required_scopes.all? { |scope| token_scopes.include?(scope) }
    raise AuthorizationError.new('Insufficient scope')
  end
end
def self.verify_payload(payload)
  # audience クレームが組織 (Organization) 形式と一致するか確認
  audiences = payload['aud'] || []
  has_org_audience = audiences.any? { |aud| aud.start_with?('urn:logto:organization:') }
  unless has_org_audience
    raise AuthorizationError.new('Invalid audience for organization permissions')
  end
  # 組織 ID がコンテキストと一致するか確認(リクエストコンテキストから抽出が必要な場合あり)
  expected_org_id = 'your-organization-id' # リクエストコンテキストから抽出
  expected_aud = "urn:logto:organization:#{expected_org_id}"
  unless audiences.include?(expected_aud)
    raise AuthorizationError.new('Organization ID mismatch')
  end
  # 必要な組織 (Organization) スコープを確認
  required_scopes = ['invite:users', 'manage:settings'] # 実際に必要なスコープに置き換えてください
  token_scopes = payload['scope']&.split(' ') || []
  unless required_scopes.all? { |scope| token_scopes.include?(scope) }
    raise AuthorizationError.new('Insufficient organization scope')
  end
end
def self.verify_payload(payload)
  # audience クレームが API リソースインジケーターと一致するか確認
  audiences = payload['aud'] || []
  unless audiences.include?('https://your-api-resource-indicator')
    raise AuthorizationError.new('Invalid audience for organization-level API resources')
  end
  # 組織 ID がコンテキストと一致するか確認(リクエストコンテキストから抽出が必要な場合あり)
  expected_org_id = 'your-organization-id' # リクエストコンテキストから抽出
  org_id = payload['organization_id']
  unless expected_org_id == org_id
    raise AuthorizationError.new('Organization ID mismatch')
  end
  # 組織レベル API リソースに必要なスコープを確認
  required_scopes = ['api:read', 'api:write'] # 実際に必要なスコープに置き換えてください
  token_scopes = payload['scope']&.split(' ') || []
  unless required_scopes.all? { |scope| token_scopes.include?(scope) }
    raise AuthorizationError.new('Insufficient organization-level API scopes')
  end
end
ミドルウェアを API に適用する
これで、保護された API ルートにミドルウェアを適用します。
require 'sinatra'
require 'json'
require_relative 'auth_middleware'
require_relative 'auth_constants'
require_relative 'auth_info'
require_relative 'authorization_error'
require_relative 'auth_helpers'
require_relative 'jwt_validator'
# ミドルウェアを適用
use AuthMiddleware
get '/api/protected' do
  content_type :json
  # env から認証情報にアクセス
  auth = env['auth']
  { auth: auth.to_h }.to_json
end
# パブリックエンドポイント(ミドルウェアによる保護なし)
get '/' do
  content_type :json
  { message: "パブリックエンドポイント" }.to_json
end
保護された API のテスト
アクセス トークン (Access tokens) の取得
クライアントアプリケーションから: クライアント統合を設定している場合、アプリは自動的にトークンを取得できます。アクセス トークン (Access token) を抽出し、API リクエストで使用してください。
curl / Postman でのテスト用:
- 
ユーザートークン: クライアントアプリの開発者ツールを使い、localStorage またはネットワークタブからアクセス トークン (Access token) をコピーします。
 - 
マシン間通信トークン: クライアントクレデンシャルフローを使用します。以下は curl を使った非公式な例です:
curl -X POST https://your-tenant.logto.app/oidc/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=your-m2m-client-id" \
-d "client_secret=your-m2m-client-secret" \
-d "resource=https://your-api-resource-indicator" \
-d "scope=api:read api:write"resourceやscopeパラメーターは API リソースや権限に応じて調整が必要です。API が組織スコープの場合はorganization_idパラメーターも必要になる場合があります。 
トークンの内容を確認したい場合は、 JWT デコーダー を使って JWT をデコード・検証できます。
保護されたエンドポイントのテスト
有効なトークンリクエスト
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
  http://localhost:3000/api/protected
期待されるレスポンス:
{
  "auth": {
    "sub": "user123",
    "clientId": "app456",
    "organizationId": "org789",
    "scopes": ["api:read", "api:write"],
    "audience": ["https://your-api-resource-indicator"]
  }
}
トークンなし
curl http://localhost:3000/api/protected
期待されるレスポンス (401):
{
  "error": "Authorization header is missing"
}
無効なトークン
curl -H "Authorization: Bearer invalid-token" \
  http://localhost:3000/api/protected
期待されるレスポンス (401):
{
  "error": "Invalid token"
}
権限モデルごとのテスト
- グローバル API リソース
 - 組織 (非 API) 権限
 - 組織レベル API リソース
 
グローバルスコープで保護された API のテストシナリオ:
- 有効なスコープ: 必要な API スコープ(例: 
api:read,api:write)を含むトークンでテスト - スコープ不足: 必要なスコープがない場合は 403 Forbidden を期待
 - 誤ったオーディエンス: オーディエンスが API リソースと一致しない場合は 403 Forbidden を期待
 
# 必要なスコープがないトークン - 403 を期待
curl -H "Authorization: Bearer token-without-required-scopes" \
  http://localhost:3000/api/protected
組織固有のアクセス制御のテストシナリオ:
- 有効な組織トークン: 正しい組織コンテキスト(組織 ID とスコープ)を含むトークンでテスト
 - スコープ不足: ユーザーが要求された操作の権限を持たない場合は 403 Forbidden を期待
 - 誤った組織: オーディエンスが組織コンテキスト(
urn:logto:organization:<organization_id>)と一致しない場合は 403 Forbidden を期待 
# 別の組織用トークン - 403 を期待
curl -H "Authorization: Bearer token-for-different-organization" \
  http://localhost:3000/api/protected
API リソース検証と組織コンテキストを組み合わせたテストシナリオ:
- 有効な組織 + API スコープ: 組織コンテキストと必要な API スコープの両方を持つトークンでテスト
 - API スコープ不足: 組織トークンに必要な API 権限がない場合は 403 Forbidden を期待
 - 誤った組織: 別の組織のトークンで API にアクセスした場合は 403 Forbidden を期待
 - 誤ったオーディエンス: オーディエンスが組織レベルの API リソースと一致しない場合は 403 Forbidden を期待
 
# API スコープがない組織トークン - 403 を期待
curl -H "Authorization: Bearer organization-token-without-api-scopes" \
  http://localhost:3000/api/protected
さらに詳しく
実践でのロールベースのアクセス制御 (RBAC):アプリケーションのための安全な認可 (Authorization) の実装
マルチテナント SaaS アプリケーションの構築:設計から実装までの完全ガイド