Skip to content

ユースケース — サービス間(client_credentials) ​

client_credentials グラントとは ​

多くの OAuth の grant には、アクセスを承認する人(リソースオーナー)が登場します。client_credentials(RFC 6749 §4.4)は、その人がいない場合のための grant です。サービス A が登録済みの client_id と認証情報を持ち、/token でそれを直接アクセストークンに交換します。トークンが表すのは サービス自身 で、エンドユーザではありません。そのため id_token も refresh_token も同意画面もありません(取り直しは安価なので、リフレッシュトークンは不要です)。

cron ジョブ、webhook、マイクロサービス間呼び出しなど、ブラウザもエンドユーザもいない場面で使います。

このページで触れる仕様
  • RFC 6749 — OAuth 2.0 Authorization Framework, §4.4(client_credentials)
  • RFC 7523 — JWT Profile for OAuth 2.0 Client Authentication(private_key_jwt)
  • RFC 8705 — OAuth 2.0 Mutual-TLS Client Authentication
  • RFC 8707 — Resource Indicators for OAuth 2.0(トークンを特定の RS にピン)
  • RFC 9068 — JWT Profile for OAuth 2.0 Access Tokens
  • RFC 7662 — OAuth 2.0 Token Introspection
用語の補足
  • Confidential クライアントと public クライアント — confidential クライアント(バックエンドサービス)は実際の認証情報(secret か秘密鍵)を保持できます。public クライアント(ブラウザ SPA、モバイルアプリ)は秘密を保持できず、client_id だけで自分を名乗ります。client_credentials は confidential クライアント専用です。認証情報がなければ、「クライアント自身」を認証済みの主体として扱えないからです。
  • private_key_jwt — 共有秘密を送る代わりに、クライアントが秘密鍵で短命な JWT に署名し、client_assertion として送ります。OP は事前登録された公開 JWKS で検証するので、秘密が通信路に乗ることはありません。
  • Bearer トークン — 提示するだけで認可が成立するアクセストークン(RFC 6750)です。持っている人は誰でも使えます。送信者制約(DPoP / mTLS)を使うと、トークンを鍵に結び付けられます。

ソース: examples/05-client-credentials

アーキテクチャ ​

サービス間通信、ユーザは登場しない
サービス A が自分自身として OP に認証してアクセストークンを受け取り、それを付けてサービス B を呼び出します。サービス B は introspection か公開鍵集合のいずれかでそのトークンを検証します。サービス Aconfidential クライアントOPgo-oidc-providerサービス BリソースサーバPOST /tokenクライアント認証access_tokenAuthorization: Bearer <token>検証: /introspect | /jwks
A から B への呼び出しに OP は関与せず、B が A の資格情報を見ることもありません。トークンについて OP に問い合わせるか自分で署名を検証するかはサービス B が決めます。その選択がアクセストークン形式のページの主題です。

この流れは /auth を通りません。

コード ​

go
import (
  "github.com/libraz/go-oidc-provider/op"
  "github.com/libraz/go-oidc-provider/op/feature"
  "github.com/libraz/go-oidc-provider/op/grant"
  "github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)

provider, err := op.New(
  op.WithIssuer("https://op.example.com"),
  op.WithStore(inmem.New()),
  op.WithKeyset(myKeyset),
  // ブラウザを使う grant を有効にしないため cookie key は不要で、
  // エンドユーザも解決しないため user store も不要です。
  op.WithGrants(grant.ClientCredentials),
  op.WithFeature(feature.Introspect),

  op.WithStaticClients(
    op.ConfidentialClient{
      ID:         "service-a",
      Secret:     serviceASecret, // seed が保存前に hash
      AuthMethod: op.AuthClientSecretBasic,
      GrantTypes: []string{"client_credentials"},
      Scopes:     []string{"read:things", "write:things"},
      Resources:  []string{"https://api.b.example.com"}, // RFC 8707 で audience を固定
    },
    op.ConfidentialClient{
      ID:         "service-b",
      Secret:     serviceBSecret,
      AuthMethod: op.AuthClientSecretBasic,
      GrantTypes: []string{"client_credentials"},
    },
  ),
  op.WithProtectedResources(op.ProtectedResource{
    Resource:             "https://api.b.example.com",
    IntrospectionClients: []string{"service-b"},
  }),
)

WithGrants は既定の grant 集合を置き換えるので、grant.ClientCredentials だけを渡すと authorization_code は無効になります。ブラウザのサインイン用オプション(WithLoginFlow、WithUI、WithInteractions、WithFirstPartyClients)を明示的に設定したまま grant.AuthorizationCode を含めないと、op.New は op.ErrAuthorizationCodeGrantRequired で失敗し、該当したオプション名を示します。そのオプションを外すか、grant.AuthorizationCode を集合に加えてください。

トークンエンドポイントを呼び出す ​

sh
curl -s -u service-a:<secret> \
  -d 'grant_type=client_credentials&scope=read:things' \
  https://op.example.com/oidc/token
# {
#   "access_token": "...",
#   "token_type": "Bearer",
#   "expires_in": 300,
#   "scope": "read:things"
# }

Confidential クライアントのみ

client_credentials を使えるのは、実際の認証情報を持つクライアント(client_secret_basic、client_secret_post、private_key_jwt、feature.MTLS があれば tls_client_auth と self_signed_tls_client_auth)だけです。public クライアント(token_endpoint_auth_method=none)は使えません。静的に seed する ConfidentialClient が client_secret_basic か client_secret_post を使う場合は、32 byte 以上の Secret か SecretHash が必須で、空または短いと op.New が拒否します。対になる secret と hash は op.NewClientSecret で生成できます。トークンをクライアント証明書に結び付ける(cnf)ことと、証明書で認証することは別の仕組みです。

シークレット検証のコスト

client_secret の既定の検証は Argon2id で、1 回あたり約 90 ms、64 MiB を使います。人が入力するシークレット向けの設計です。機械生成のシークレットを使うマシン間通信の高頻度パスでは、トークンリクエストのたびにこのコストを払うことになります。op.WithHighEntropyClientSecrets は、すべてのシークレットが機械生成であると宣言し、検証を数マイクロ秒の keyed hash に切り替えます。その代わり、Argon2id のハッシュを持つ静的クライアント(ConfidentialClient.Secret で seed したものなど)が残っていると op.New が拒否します。シークレットは op.NewClientSecret で発行してください(すでに発行済みで 32 文字以上のものは op.HashHighEntropyClientSecret)。保存済みのレコードは変換できないため、有効化する前に既存のクライアントを再発行してください。トレードオフは 05-client-credentials のコメントにもあります。

トークンの中身と失敗時の応答 ​

項目挙動
subclient_id。エンドユーザがいないため、pairwise subject への変換は行いません。
scope要求したスコープ。すべてクライアントの登録済み Scopes に含まれている必要があります。scope を省略すると、登録済みの全スコープから openid を除いたものになります。
aud要求に resource があればその値(クライアントの Resources に含まれている必要があり、指定できるのは 1 つだけ)。なければ issuer の URL。
応答access_token、token_type、expires_in、scope。id_token と refresh_token は含まれません。
cnf要求に検証済みの DPoP proof か mTLS 証明書があれば付きます。
失敗の内容応答
クライアントが client_credentials 未登録、または public クライアント400 unauthorized_client
openid を要求した、または登録済みスコープの範囲外400 invalid_scope
resource の形式が不正、クライアントに未登録、または異なる値を複数指定400 invalid_target
認証情報がない、誤っている、または再利用された(private_key_jwt の assertion で jti が空・長すぎる場合を含む)401 invalid_client
クライアントの JWKS を一時的に取得できない、または負荷で拒否された503 server_error
クライアントストアが利用できない500 server_error

本番向け: basic の代わりに private_key_jwt ​

より高い保証が必要な環境では、private_key_jwt(RFC 7523)を使います。

go
op.WithStaticClients(op.PrivateKeyJWTClient{
  ID:         "service-a",
  JWKS:       serviceAPublicJWKs, // 公開 JWK Set を JSON バイト列で
  GrantTypes: []string{"client_credentials"},
})

PrivateKeyJWTClient seed は token_endpoint_auth_method=private_key_jwt を自動で設定するので、この型には AuthMethod フィールドがありません。クライアントのレコードには、インラインの jwks か jwks_uri のどちらか一方だけを持たせます。両方は持てません。

サービス A は、トークンを要求するたびに、自分の秘密鍵で新しい JWT assertion に署名します。assertion には一意の jti(OP は再利用を拒否します)が必要で、有効期間は 5 分以内です。

sh
curl -s -d 'grant_type=client_credentials' \
  -d 'client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer' \
  -d "client_assertion=$JWT_ASSERTION" \
  -d 'scope=read:things' \
  https://op.example.com/oidc/token
FAPI 2.0 の client_credentials

op.WithProfile(profile.FAPI2Baseline) 配下では client_secret_basic が除外されます。プロファイルが token endpoint のクライアント認証で受理するのは private_key_jwt と、feature.MTLS を有効にした場合の tls_client_auth、self_signed_tls_client_auth です(クライアント認証の選択肢)。feature.DPoP を上乗せすれば発行アクセストークンをクライアント保有鍵に追加バインドできます。

リソースサーバ側の検証 ​

検証の方法は 2 つあります。

  1. JWT の自己検証(RFC 9068)— JWT アクセストークンを使っている場合。サービス B は /jwks を取得してキャッシュし、鍵ローテーションで未知の kid が現れたらキャッシュを更新してから、ローカルで署名を検証します。
  2. Introspection(RFC 7662)— アクセストークンが opaque な場合。サービス B が /introspect にトークンを POST し、JSON レスポンスから active、scope、client_id 等を読みます。Go のリソースサーバなら、rs.Config.Introspection を設定すれば op/rs が代行します(API を守るを参照)。
sh
curl -s -u service-b:<secret> \
  -d "token=$ACCESS_TOKEN" \
  https://op.example.com/oidc/introspect

Introspect の呼び出し元には confidential クライアントが必要

introspection エンドポイントは 呼び出し元(サービス B、リソースサーバ)を認証します。public クライアントと token_endpoint_auth_method=none のクライアントは 401 invalid_client になります。サービス B を confidential クライアントとして登録してください。既定では、クライアントは自分が発行を受けたトークンだけを検査できます。https://api.b.example.com audience のアクセストークンをサービス B に委譲するには、その resource の ProtectedResource.IntrospectionClients にサービス B を列挙します。この委譲はリフレッシュトークンには適用されません。フル実装は examples/05-client-credentials を参照してください。