Client Credentials でサービス間通信を認証する — マイクロサービス基盤での設計と実装

OAuth 2.0 の Client Credentials Grant をマイクロサービス間通信に適用する方法。トークンの中身、スコープ設計、キャッシュ戦略、OIDC Provider への組み込み方を実務レベルで整理。

はじめに

マイクロサービスが増えると「サービス A がサービス B の API を叩く」場面が必然的に出てきます。このとき「サービス A は本物か?」「どの API まで呼んでいいのか?」を制御するのが Client Credentials Grant です。

ユーザーが介在しない、マシン対マシンの認証に特化したフローで、他の OAuth フローとは性質が大きく異なります。

他のフローとの決定的な違い

Authorization CodeClient Credentials
誰の権限?ユーザーの権限サービス自体の権限
ブラウザ必要(リダイレクト)不要
認可コードあるない
JWT の subユーザー IDサービス ID
リクエスト数2(認可 → トークン交換)1(トークン直接取得)

フロー

サービス A

  │ POST /oidc/token
  │ {
  │   grant_type: "client_credentials",
  │   client_id: "service-a",
  │   client_secret: "xxx",
  │   scope: "billing:read usage:write"
  │ }


OIDC Provider

  │ 1. client_id + secret を検証
  │ 2. 要求された scope が許可されてるか確認
  │ 3. Access Token を発行


サービス A

  │ GET /api/orgs/org-123/plan
  │ Authorization: Bearer eyJhbGciOi...


サービス B(rellf-billing 等)

  │ 1. JWT の署名検証(JWKS)
  │ 2. scope に "billing:read" があるか確認
  │ 3. レスポンスを返す


サービス A

HTTP リクエスト 1 本でトークンが返る。ブラウザもリダイレクトもありません。

トークンの中身

{
  "iss": "https://auth.example.com",
  "sub": "service-a",
  "aud": "https://auth.example.com",
  "exp": 1712800000,
  "iat": 1712796400,
  "scope": "billing:read usage:write",
  "token_use": "access",
  "client_id": "service-a"
}
クレーム意味
subサービスの識別子(ユーザー ID ではない)
scopeこのサービスに許可された操作
client_idクライアント ID(sub と同じことが多い)

ID Token は発行しないのが一般的です。ID Token は「ユーザーは誰か」を表すものなので、ユーザー不在の Client Credentials では意味がありません。

スコープ設計

Client Credentials の肝は スコープでサービスごとのアクセス範囲を制御することです。

スコープの命名規則

{リソース}:{操作}

例:

スコープ意味
billing:read課金情報の読み取り
billing:write課金情報の書き込み
usage:write使用量の記録
authz:check認可判定の実行
users:readユーザー情報の読み取り

サービスごとのスコープ割り当て

service: cases-api
  allowed_scopes: [billing:read, usage:write, authz:check]

service: admin-batch
  allowed_scopes: [billing:read, billing:write, users:read]

service: notification-worker
  allowed_scopes: [users:read]

最小権限の原則で、各サービスに必要なスコープだけを割り当てます。

マルチプロダクト基盤での具体例

┌─────────────┐    Client Credentials     ┌──────────────┐
│ cases-api    │──── billing:read ────────→│ rellf-billing │
│ (プロダクト)  │──── authz:check ─────────→│ rellf-authz   │
└─────────────┘                           └──────────────┘

       │  Authorization Code(ユーザーのリクエスト)


┌─────────────┐
│ ブラウザ      │
└─────────────┘

1 つのプロダクトが 2 種類のトークンを使い分けます:

トークン取得方法用途
ユーザーの JWTAuthorization Code「ユーザーの代わりに」操作
サービスの JWTClient Credentials「サービスとして」他サービスの API を呼ぶ

いつどちらを使うか

[ユーザーがリクエスト]

  ├─ プラン情報を取得(サービスとして billing API を叩く)
  │  → Client Credentials トークンを使う

  ├─ 認可判定(サービスとして authz API を叩く)
  │  → Client Credentials トークンを使う

  └─ ユーザー情報を返す
     → ユーザーの JWT から取得

プラン情報や認可判定は「サービス A がサービス B に聞く」話なので、ユーザーのトークンではなくサービスのトークンを使います。

実装例

OIDC Provider 側(トークン発行)

func (h *TokenHandler) handleClientCredentials(c *gin.Context) {
    clientID := c.PostForm("client_id")
    clientSecret := c.PostForm("client_secret")
    requestedScope := c.PostForm("scope")

    // クライアント認証
    client, err := h.clients.ValidateSecret(clientID, clientSecret)
    if err != nil {
        c.JSON(401, gin.H{"error": "invalid_client"})
        return
    }

    // Client Credentials が許可されたクライアントか
    if !client.AllowsGrantType("client_credentials") {
        c.JSON(400, gin.H{"error": "unauthorized_client"})
        return
    }

    // 要求されたスコープが許可されてるか
    scopes := filterAllowedScopes(requestedScope, client.AllowedScopes)

    // Access Token 発行
    token, err := h.issuer.SignAccessToken(clientID, scopes, h.issuer.Issuer())
    if err != nil {
        c.JSON(500, gin.H{"error": "server_error"})
        return
    }

    c.JSON(200, gin.H{
        "access_token": token,
        "token_type":   "Bearer",
        "expires_in":   3600,
        "scope":        strings.Join(scopes, " "),
    })
}

クライアント側(トークン取得 + API 呼び出し)

type ServiceClient struct {
    tokenURL     string
    clientID     string
    clientSecret string
    scopes       []string
    cachedToken  string
    tokenExpiry  time.Time
    mu           sync.Mutex
}

func (c *ServiceClient) GetToken(ctx context.Context) (string, error) {
    c.mu.Lock()
    defer c.mu.Unlock()

    // キャッシュが有効ならそれを返す
    if c.cachedToken != "" && time.Now().Before(c.tokenExpiry) {
        return c.cachedToken, nil
    }

    // 新しいトークンを取得
    resp, err := http.PostForm(c.tokenURL, url.Values{
        "grant_type":    {"client_credentials"},
        "client_id":     {c.clientID},
        "client_secret": {c.clientSecret},
        "scope":         {strings.Join(c.scopes, " ")},
    })
    // ...

    c.cachedToken = tokens.AccessToken
    c.tokenExpiry = time.Now().Add(time.Duration(tokens.ExpiresIn-60) * time.Second)
    return c.cachedToken, nil
}

API 側(トークン検証 + スコープチェック)

func RequireScope(required string) gin.HandlerFunc {
    return func(c *gin.Context) {
        claims := getClaims(c)
        scopes := strings.Split(claims["scope"].(string), " ")

        for _, s := range scopes {
            if s == required {
                c.Next()
                return
            }
        }

        c.JSON(403, gin.H{"error": "insufficient_scope", "required": required})
        c.Abort()
    }
}

// ルーティング
api.GET("/orgs/:id/plan", RequireScope("billing:read"), handler.GetPlan)
api.POST("/orgs/:id/usage", RequireScope("usage:write"), handler.RecordUsage)

キャッシュ戦略

Client Credentials トークンは毎リクエスト取得する必要はありません

トークン有効期限: 1 時間
キャッシュ: 有効期限の 1 分前まで使い回す
→ 1 時間に 1 回しかトークンエンドポイントを叩かない

注意点

セキュリティ上の考慮

client_secret の管理

Client Credentials は client_secret が漏れると即座にトークンが取れてしまうため、厳重な管理が必要です。

推奨非推奨
保存場所SSM Parameter Store / Secrets Manager環境変数直書き、コード内
ローテーション定期的に変更固定
ネットワークVPC 内 or HTTPS平文通信

スコープの粒度

スコープが粗すぎると最小権限にならず、細かすぎると管理が破綻します。

粗すぎ:  scope=admin        → 何でもできてしまう
細かすぎ: scope=billing:org:123:plan:read  → 管理不能

ちょうどいい: scope=billing:read usage:write

リソース種別 × 操作の粒度が実用的です。

トークンの有効期限

Client Credentials のトークンは短めにするのが推奨です。

有効期限ユースケース
5 分高セキュリティ(金融系)
1 時間一般的
24 時間内部バッチ処理

短くするほど安全ですが、トークン取得の頻度が上がります。1 時間がバランス良いです。

Authorization Code との共存

実際のシステムでは、1 つの OIDC Provider が両方のフローを提供します。

func (h *TokenHandler) HandleToken(c *gin.Context) {
    grantType := c.PostForm("grant_type")

    switch grantType {
    case "authorization_code":
        h.handleAuthorizationCode(c)
    case "client_credentials":
        h.handleClientCredentials(c)
    case "refresh_token":
        h.handleRefreshToken(c)
    default:
        c.JSON(400, gin.H{"error": "unsupported_grant_type"})
    }
}

/oidc/token エンドポイントは同じで、grant_type パラメータで分岐するだけです。

クライアント登録の違い

Authorization Code 用Client Credentials 用
client_typeconfidential / publicconfidential のみ
redirect_uri必須不要
allowed_grant_typesauthorization_code, refresh_tokenclient_credentials
allowed_scopesopenid, email, profilebilling:read, authz:check 等
ユーザー操作あり(ログイン画面)なし
// Client Credentials 用のクライアント登録例
{
  "client_id": "cases-api",
  "client_secret": "xxx",
  "client_type": "confidential",
  "allowed_grant_types": ["client_credentials"],
  "allowed_scopes": ["billing:read", "usage:write", "authz:check"],
  "description": "Cases プロダクトのバックエンド"
}

まとめ

← Back to all posts