サーバー間API連携のOAuth 2.0設計|Client Credentials・scope・token管理

「サーバー間API連携のOAuth 2.0設計|Client Credentials・scope・token管理」の内容を表す技術イラスト

サーバーから外部APIを呼び出す処理で、API keyを固定文字列として埋め込むと、権限の分離、期限管理、失効、監査が難しくなります。OAuth 2.0のClient Credentials Grantを使うと、利用者の操作を介さず、登録済みclientが自分の資格情報で短期間のaccess tokenを取得できます。

ただし、client_idとclient_secretを送ってtokenを受け取るだけでは安定した連携になりません。scope、期限、cache、再取得、401・403の処理、秘密情報の保存場所までを一つのデータ契約として設計する必要があります。

目次

Client Credentialsが解決すること

RFC 6749のClient Credentials Grantは、clientが自分自身として保護されたresourceへアクセスする用途に使います。人のログインや同意画面を必要としません。

代表的な利用例は次のとおりです。

  • 夜間処理が部品マスターAPIを取得する
  • CAD変換serverがfile保管APIへ結果を登録する
  • ETL処理がクラウド上のデータを定期同期する
  • 社内service同士が権限を限定して通信する

この方式はsecretを安全に保持できるconfidential client専用です。ブラウザ内JavaScript、配布するdesktop application、mobile appなど、利用者がsecretを取り出せる環境へ固定secretを埋め込む用途には適しません。

OAuth 2.0はauthorizationの枠組みです。Client Credentialsでは、access tokenが「人のログイン状態」ではなく、clientへ許可された権限を表します。

3つの登場主体を分ける

最小構成には、次の3つがあります。

主体役割保持するもの
ClientAPIを利用する処理client ID、secret参照、必要scope
Authorization Serverclientを認証してtokenを発行client登録、許可scope、token policy
Resource ServerBearer tokenを検証して業務APIを提供resource、scope・audience検証規則

Authorization ServerとResource Serverは同じ製品内に存在する場合もありますが、役割は別です。token取得先へ業務データを送ったり、Resource Serverへclient secretを送ったりしないようにします。

入力からAPI呼び出しまでの流れ

サーバー間連携は次の順序になります。

client設定を読み込む
  ↓
利用可能なaccess tokenがあるか確認
  ↓ ない・期限が近い
token endpointへClient Credentials request
  ↓
token responseの構造・型・scopeを検証
  ↓
期限付きでmemory cache
  ↓
Authorization headerでResource Serverを呼ぶ
  ↓
401・403・一時障害を分類して処理

毎回tokenを取得すると、token endpointの負荷やrate limit、障害点が増えます。発行済みtokenを期限内で再利用し、期限直前だけ再取得します。

Token requestのインターフェース契約

RFC 6749では、Client Credentialsのrequest bodyをapplication/x-www-form-urlencoded、文字コードをUTF-8としています。grant_typeはclient_credentials、scopeは任意です。

POST /token HTTP/1.1
Host: auth.example.com
Authorization: Basic <client credentials>
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&scope=assets.read%20drawings.write

ここで示すendpointとscope名は説明用です。実装時は接続先の公式仕様、Authorization Server Metadata、登録内容を確認します。client認証方式もclient_secret_basic、証明書、署名付きassertionなどがあり、独自判断で混在させません。

scopeは空白区切りの文字列として送るのがOAuth 2.0の基本形です。必要以上のscopeを要求せず、読取処理には書込権限を与えないようにします。

Token responseをデータとして検証する

成功時のresponseはJSON objectです。

{
  "access_token": "opaque-access-token",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "assets.read drawings.write"
}

各fieldの役割は次のとおりです。

field型扱い
access_tokenstring必須。Resource Serverへ提示する秘密値
token_typestring必須。Bearerであることを確認する
expires_innumber発行時点から有効な秒数
scopestring実際に発行されたscope。requestと異なる場合は確認する

access_tokenの長さや内部形式を仮定してはいけません。見た目がJWTでも、接続契約で求められていない限りclient側でclaimを業務判定に使わず、不透明な文字列として扱います。

Client Credentialsのresponseにはrefresh tokenを含めないことがRFC 6749で推奨されています。通常はaccess tokenの期限が近づいたら、同じgrantで新しいtokenを取得します。

未知のresponse fieldは無視できるようにしつつ、既知fieldの型と意味は厳密に検証します。

期限は受信時刻から計算する

expires_in: 3600は絶対日時ではなく、response生成時点からの秒数です。clientは受信時刻を基準に使用期限を計算します。

usable_until = received_at + expires_in - safety_margin

たとえば有効期間が3600秒なら、30〜120秒程度の安全余裕を引いて再取得する設計が考えられます。余裕値は通信時間、時計差、処理時間、接続先仕様に合わせて決めます。

process内だけでcacheする場合は、時刻補正の影響を受けにくいmonotonic clockを利用できます。期限をDBへ永続化して複数processで共有する場合は、取得日時、絶対期限、発行元、scopeを明確にし、token本体は暗号化や専用secret保管を検討します。

Pythonでtoken responseを正規化する

次の例は、token responseを検証し、monotonic clock上の使用期限へ変換します。

from dataclasses import dataclass
import time


@dataclass(frozen=True)
class AccessToken:
    value: str
    expires_at: float
    scopes: frozenset[str]

    def is_usable(self, *, margin_seconds: int = 60) -> bool:
        return time.monotonic() < self.expires_at - margin_seconds


def parse_token_response(data: dict) -> AccessToken:
    if not isinstance(data, dict):
        raise TypeError("token responseはobjectである必要があります")

    access_token = data.get("access_token")
    if not isinstance(access_token, str) or not access_token:
        raise ValueError("access_tokenがありません")

    token_type = data.get("token_type")
    if not isinstance(token_type, str) or token_type.lower() != "bearer":
        raise ValueError("未対応のtoken_typeです")

    expires_in = data.get("expires_in")
    if type(expires_in) is not int or expires_in <= 0:
        raise ValueError("expires_inは正の整数が必要です")

    scope_text = data.get("scope", "")
    if not isinstance(scope_text, str):
        raise TypeError("scopeは文字列である必要があります")

    scopes = frozenset(part for part in scope_text.split(" ") if part)
    return AccessToken(
        value=access_token,
        expires_at=time.monotonic() + expires_in,
        scopes=scopes,
    )

Pythonではboolがintのsubclassなので、type(expires_in) is not intとしてtrueを1秒として通さないようにしています。実装する接続先が小数や文字列の期限を定義している場合は、その公式契約に合わせます。

Bearer tokenはAuthorization headerへ入れる

RFC 6750は、Bearer tokenをAuthorization request headerで送る方法を定義しています。

GET /v1/assets/asset-0042 HTTP/1.1
Host: api.example.com
Authorization: Bearer <access token>
Accept: application/json

Bearer tokenは、値を持っている主体が利用できるcredentialです。URL queryへ入れると、browser history、proxy、access log、Refererなどへ残る可能性があります。通常はAuthorization headerを使い、tokenをpayload、URL、通常logへ含めません。

scopeとaudienceを最小化する

scopeはclientができる操作を制限します。

assets.read
assets.write
drawings.read
drawings.write

「管理者」のような大きなscopeを全連携で共有せず、用途と環境ごとにclient登録を分けます。開発、検証、本番でもcredentialを分離します。

RFC 9700は、access tokenの権限を用途に必要な最小範囲へ制限し、特定のResource Serverまたは小さな集合へaudienceを制限することを推奨しています。tokenが漏れた場合の影響範囲を小さくできます。

scope文字列だけを見てclient側が最終認可を決めるのではなく、Resource Serverがtokenの署名・失効・issuer・audience・scopeを検証します。

401と403を分けて処理する

API呼び出しが失敗したとき、すべてをtoken再取得で解決しようとすると無限loopになります。

状況基本対応
token未送信・期限切れ・無効401を確認し、cacheを破棄して1回だけ再取得
scope不足403またはinsufficient_scopeとして設定・権限を見直す
token endpointでinvalid_clientclient ID、secret、認証方式を確認。自動連続再試行しない
invalid_scope登録済みscopeとrequestを確認
429・503Retry-Afterや接続先仕様に従い、上限付きで再試行

401後の再送でも、元のAPI操作がPOSTや増分処理なら二重実行へ注意します。OAuthのtoken再取得と、業務requestの冪等性は別の問題です。request ID、idempotency key、一意制約などを組み合わせます。

403で新しいtokenを取り続けても、clientに必要scopeが許可されていなければ成功しません。認証失敗、認可不足、一時障害を分類します。

Secretと連携設定を分離する

接続設定には、公開可能な値と秘密値が混在します。

integration_id: parts-master-production
token_url: https://auth.example.com/token
resource_base_url: https://api.example.com/v1
client_id_ref: secret://parts-master/client-id
client_secret_ref: secret://parts-master/client-secret
scopes:
  - assets.read
audience: parts-api
timeout_seconds: 10

設定fileやDBにはsecretそのものではなく参照先を保持し、環境変数やsecret managerから実行時に取得します。source code、Git、記事、画面capture、例外文へ実値を入れません。

client secretを更新するときは、旧secretと新secretを短期間並行利用できるかを確認し、先に新secretを配布してから旧secretを無効化します。失効手順と担当を決めておくことも契約の一部です。

正常系と異常系をテストする

最低限、次のケースを自動テストへ含めます。

  • 正常なBearer tokenと正のexpires_inを受理する
  • access_token欠落、空文字、型違いを拒否する
  • 未対応token_typeを拒否する
  • expires_inが0、負数、boolean、文字列なら拒否する
  • 未知fieldが追加されても処理を継続する
  • 発行scopeが必要scopeを満たすか確認する
  • 期限前はcacheを利用し、期限接近時だけ再取得する
  • 401後の再取得を1回に制限する
  • 403ではtoken取得loopへ入らない
  • log、例外、監視eventにtokenとsecretが含まれない

token endpointとResource Serverをtest doubleに分けると、発行失敗と業務API失敗を独立して検証できます。

CAD・DB・自動化へ再利用する

CAD変換、BOM同期、設計計算、定期ETLでも、認証処理を各scriptへ複製せず共通のtoken providerへ切り出します。

業務moduleは「必要scope」と「呼び出したいAPI」を指定し、token providerがcache、期限、再取得、秘密情報の取得を担当します。認証方式がclient secretから証明書やprivate keyへ変わっても、業務データ変換のロジックを変更せずに済みます。

連携recordにはtokenを保存せず、integration_id、request ID、対象resource ID、実行結果、発生日時を記録します。秘密情報と再処理に必要な業務情報を分離します。

まとめ

Client Credentialsは、単にaccess tokenを取得する手順ではなく、機械同士の権限と秘密情報を管理するデータ契約です。

  • secretを保持できるconfidential clientで使う
  • token endpointとResource Serverの役割を分ける
  • requestの形式、認証方式、scopeを接続先仕様で固定する
  • token responseの型、期限、発行scopeを検証する
  • access tokenを不透明な秘密値として扱う
  • 期限内はcacheし、安全余裕を取って再取得する
  • Bearer tokenはAuthorization headerへ入れる
  • scopeとaudienceを必要最小限へ制限する
  • 401、403、一時障害を分けて処理する
  • secret、token、公開可能な連携設定を分離する
  • OAuthの再認証と業務requestの冪等性を別々に設計する

この構造を共通moduleにすれば、API、Python、DB、CAD、自動化処理が同じtoken管理規則を共有し、credential更新や接続先変更にも対応しやすくなります。

参考情報

参考になったらシェアしてください
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

機械設計・油圧・CAD・Python・AIなど、ものづくりに関わる技術を扱っています。工学知識を整理・構造化し、設計や自動化に再利用できる形へ変えていくことを目指しています。

目次