ユースケース — サービス間(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)を使うと、トークンを鍵に結び付けられます。
アーキテクチャ
この流れは /auth を通りません。
コード
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 を集合に加えてください。
トークンエンドポイントを呼び出す
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 のコメントにもあります。
トークンの中身と失敗時の応答
| 項目 | 挙動 |
|---|---|
sub | client_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)を使います。
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 分以内です。
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/tokenFAPI 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 つあります。
- JWT の自己検証(RFC 9068)— JWT アクセストークンを使っている場合。サービス B は
/jwksを取得してキャッシュし、鍵ローテーションで未知のkidが現れたらキャッシュを更新してから、ローカルで署名を検証します。 - Introspection(RFC 7662)— アクセストークンが opaque な場合。サービス B が
/introspectにトークンを POST し、JSON レスポンスからactive、scope、client_id等を読みます。Go のリソースサーバなら、rs.Config.Introspectionを設定すればop/rsが代行します(API を守るを参照)。
curl -s -u service-b:<secret> \
-d "token=$ACCESS_TOKEN" \
https://op.example.com/oidc/introspectIntrospect の呼び出し元には 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 を参照してください。