サービス間の認証・認可基盤の構築:OAuth 2.0 Client Credentials Flow
こんにちは。Belong Inc. で SWE をしている nakasei です。
最近プラネタリウムを見に行ったのですが、上映中に流れたアロマに影響を受けアロマディフューザーを衝動買いしてしまいました。部屋が少しだけオシャレになるのでおすすめです。
はじめに
現在では、Auth0 や Firebase Authentication、Amazon Cognito など、アプリケーションに認証・認可機能を簡単に組み込めるサービスが数多く提供されています。いわゆる IDaaS(Identity as a Service) と呼ばれているものですね。
しかし、サービスが複数に分かれる構成では、「サービス間」の認証・認可が課題となる場面も少なくありません。 本記事では、そうしたケースに対応するための認証・認可基盤の構成について一例を紹介します。
用語説明
本題に入る前に、今回登場する用語について説明します。既にご存知の方は読み飛ばして頂く方が良いかもしれません。
認証と認可
項目 | 認証(Authentication) | 認可(Authorization) |
---|---|---|
目的 | 「誰か」を確認する | 「何ができるか」を確認する |
質問 | 「あなたは誰ですか?」 | 「あなたはこれをしていい人ですか?」 |
検証対象 | ユーザーの本人性(ID・パスワード、トークンなど) | ユーザーの権限やアクセス範囲 |
技術的手段 | パスワード、OpenID Connect、証明書など | ロールベース制御(RBAC)、OAuth 2.0、スコープ、ポリシーなど |
タイミング | アクセスの最初に実施する | アクセス許可を判断するたびに実施する |
例 | ログイン画面での ID/PW 入力、JWT の署名検証など | 「管理者だけがこの画面を見られる」といった制御 |
一言でまとめると、認証とはユーザーを確認し不正なアクセスを防止する仕組みで、認可とは権限等を確認し不正な操作を防止する仕組みです。
OAuth(Open Authorization)
ユーザー同意のもと、あるサービスから別サービスへのアクセスを可能にする仕組みです。
あるサービスから別サービスが管理するリソース(例:Google フォト)を取得したい場合など、リソースを持つ側はオーナーであるユーザーに同意を確認しアクセストークンを発行します。
アクセストークンとは認可情報が含まれるトークンで、これを利用することでユーザー本人でなくともリソースへのアクセスが可能となります。
サービスにサインインする際など、Google アカウント等を選択して「アクセスを許可しますか?」といった確認画面が表示されることがありますが、まさにこの仕組みですね。
OAuth 2.0 は OAuth 1.0 の後継であり、アクセストークンでのやり取りによって毎回の署名が不要になったり、リフレッシュトークンや認可フローが追加されるなどいくつか改善が行われています。
Client Credentials Flow
OAuth 2.0 では認可フローが追加されたと記述しましたが、その中のひとつに Client Credentials Flow が定義されています。
Ref: https://datatracker.ietf.org/doc/html/rfc6749#section-4.4
OAuth の基本形はユーザー同意のもとリソースにアクセスする形ですが、このフローではそのステップが存在しません。というのも、これは主にクライアントアプリケーション自体がオーナーとしてリソースへアクセスする際に利用されるフローだからです。
具体的には、サーバー間の通信やバッチ処理、バックエンドサービスなど、マシン間の連携(M2M: Machine-to-Machine)が挙げられます。
フローの概要としては、以下です。
- クライアントアプリケーションが ID・Secret 等を認可サーバーへ送り検証。
- 成功すればアクセストークンを発行
- アクセストークンを使用して保護されたリソースへアクセス
JWT(Json Web Token)
情報を JSON オブジェクトとして表現し、署名や暗号化によって安全にやり取りするためのオープンスタンダードです。
Ref: https://datatracker.ietf.org/doc/html/rfc7519
ちなみに公式的な呼び方は「ジョット」となっています。
The suggested pronunciation of JWT is the same as the English word "jot".
Ref: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-json-web-token-13
では早速 JWT の構造について見ていきましょう。
構造:
ヘッダー.ペイロード.署名
例:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
どこに JSON オブジェクトがあるのかと思われる方もいるかと思いますが、これは BASE64 エンコーディング(64 種類の文字といくつかの記号で変換する方式)した値であり、エンコード前は以下のようになっています。
ヘッダー
トークンの署名に使用されるアルゴリズム(例:RS256)やトークンのタイプ(例:JWT)などを指定します。
{
"alg": "RS256",
"typ": "JWT"
}
クレーム
トークンの作成者や有効期限など、トークンに関する追加のメタデータです。
{
"sub": "1234567890",
"name": "nakasei",
"iat": 1516239022
}
署名
エンコードされたヘッダー、ペイロードを利用して、指定されたアルゴリズムで署名を行います。
RS256 のような公開鍵/秘密鍵ペアを使用するアルゴリズムの場合、署名は秘密鍵で作成され、公開鍵で検証されます。
JWT は JSON を URL セーフにすることで、HTTP ヘッダーやクエリパラメータに JSON データをうまく載せられるように設計されていることが分かりますね。
認証・認可基盤の構築
それでは、本題の認証・認可基盤の構築について説明をします。
以下が大まかな処理全体のシーケンス図です。
次に各ステップの実装について説明します。
1. 認証
先で説明した Client Credentials Flow
のフローに従い、アクセストークン発行の前にまずは認証を実施します。
リクエストの形式は主に以下の二つが標準的です。
HTTP Basic 認証
POST /v1/token
Authorization: Basic xxxxxx
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
Ref: https://datatracker.ietf.org/doc/html/rfc6749#section-4.4.2
リクエストボディに含める
POST /token HTTP/1.1
Host: server.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA
&client_id=s6BhdRkqt3&client_secret=7Fjfp0ZBr1KtDRbnfVdmIw
Ref: https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
上記のリクエストからは Google Cloud サービスアカウント(以下 SA)のメールアドレスと key を受け取ることを想定します。
SA とは個人ではなくアプリケーションや VM に紐づくアカウントです。固有のメールアドレスを持ち、Google Cloud リソースにアクセスするためのスコープや権限などを割り当てることが可能となります。
今回は事前にこの SA と SA key を作成し、クライアントに渡しておくことが必要です。
その後、受け取ったメールアドレスと key を使ってアカウント情報を取得し、それが不正でないか確認を行います。
https://oauth2.googleapis.com/token
へリクエストし Google API でリソースにアクセスするためのアクセストークンを取得します。https://www.googleapis.com/oauth2/v1/userinfo
へリクエストしアカウント情報を取得します。この際、取得したアクセストークンを token_type で指定されている type に従い付与します。今回は Bearer トークンが指定されているため、その付与が必要です。- 正常に情報が取得できれば認証は成功です。
以下、Go での実装例です。client でのリクエスト関連の処理は省略しています。
また、外部 API 呼び出しや検証コストを削減するためにキャッシュを利用しています。
import (
"context"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v4"
"github.com/patrickmn/go-cache"
)
var ErrTokenUnauthorized = errors.New("token: unauthorized")
const (
accessTokenAPIURL = "https://oauth2.googleapis.com/token"
userInfoAPIURL = "https://www.googleapis.com/oauth2/v1/userinfo"
)
type AccessTokenResponse struct {
AccessToken string `json:"access_token"`
Scope string `json:"scope"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
type AccessTokenErrorResponse struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
type UserInfoResponse struct {
ID string `json:"id"`
Email string `json:"email"`
VerifiedEmail bool `json:"verified_email"`
}
type UserInfoErrorDetails struct {
Code int `json:"code"`
Message string `json:"message"`
Status string `json:"status"`
}
type UserInfoErrorResponse struct {
Error UserInfoErrorDetails `json:"error"`
}
type client struct {
HTTPClient *http.Client
cache *cache.Cache
accessTokenConfig *AccessTokenConfig
userInfoConfig *UserInfoConfig
}
// VerifyEmailCredential verify SA email and SA private key.
func (c *client) VerifyEmailCredential(ctx context.Context, email string, privateKey string) (bool, error) {
hash := getHashString(email + privateKey)
if _, ok := c.cache.Get(hash); ok {
return true, nil
}
accessToken, err := c.getAccessToken(ctx, email, privateKey)
if err != nil {
return false, err
}
userEmail, err := c.getUserEmail(ctx, *accessToken)
if err != nil {
return false, err
}
if email != *userEmail {
return false, fmt.Errorf("invalid email. expected: %s, got: %s", email, *userEmail)
}
c.cache.SetDefault(hash, true)
return true, nil
}
// getAccessToken get access token from Google oauth2 api.
// ref: https://developers.google.com/identity/protocols/oauth2/service-account?hl=ja#authorizingrequests
func (c *client) getAccessToken(ctx context.Context, email string, privateKey string) (*string, error) {
if err := c.accessTokenConfig.valid(); err != nil {
return nil, err
}
signedToken, err := generateGoogleJWT(email, privateKey, accessTokenAPIURL, c.accessTokenConfig.scope)
if err != nil {
return nil, ErrTokenUnauthorized
}
payload := c.accessTokenConfig.convertToPayload(*signedToken)
headers := c.accessTokenConfig.headers()
res, err := c.exec(ctx, http.MethodPost, accessTokenAPIURL, strings.NewReader(payload.Encode()), WithHeaders(headers))
if err != nil {
return nil, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
if isErrorStatusCode(res.StatusCode) {
var errRes AccessTokenErrorResponse
if err = json.Unmarshal(body, &errRes); err != nil {
return nil, err
}
return nil, ErrTokenUnauthorized
}
var atRes AccessTokenResponse
if err = json.Unmarshal(body, &atRes); err != nil {
return nil, err
}
return &atRes.AccessToken, nil
}
// getUserEmail get user email from Google oauth2 api.
// ref: https://developers.google.com/identity/protocols/oauth2/openid-connect#obtainuserinfo
func (c *client) getUserEmail(ctx context.Context, accessToken string) (*string, error) {
headers := c.userInfoConfig.headers(accessToken)
res, err := c.exec(ctx, http.MethodGet, userInfoAPIURL, nil, WithHeaders(headers))
if err != nil {
return nil, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
if isErrorStatusCode(res.StatusCode) {
var errRes UserInfoErrorResponse
if err = json.Unmarshal(body, &errRes); err != nil {
return nil, err
}
return nil, ErrTokenUnauthorized
}
var uiRes UserInfoResponse
if err = json.Unmarshal(body, &uiRes); err != nil {
return nil, err
}
return &uiRes.Email, nil
}
func getHash(s string) []byte {
sha := sha256.Sum256([]byte(s))
return sha[:]
}
func getHashString(s string) string {
return hex.EncodeToString(getHash(s))
}
func generateGoogleJWT(email string, privateKey string, aud string, scope ...string) (*string, error) {
block, _ := pem.Decode([]byte(privateKey))
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
parsedPrivateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key")
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"iss": email,
"sub": email,
"aud": aud,
"iat": time.Now().Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
"scope": strings.Join(scope, " "),
})
signedToken, err := token.SignedString(parsedPrivateKey)
if err != nil {
return nil, fmt.Errorf("failed to parse token")
}
return &signedToken, nil
}
2. アクセストークンの発行
認証が問題なければ、アクセストークンを発行します。今回は RS256 アルゴリズムを利用し、公開鍵/秘密鍵ペアで署名する形です。
RS256 とは RSA(公開鍵暗号)と SHA-256(ハッシュ関数)を組み合わせた署名方式のことになります。
💡補足
以下では省いていますが、トークン発行前に firebase.google.com/go/auth
package の TenantClient
を利用してユーザー情報を取得する想定です。Firebase package ですが Google Cloud の Identity Platform でも利用可能で、Identity Platform ではユーザー管理が行えるため手軽に利用者を制御できます。この制御が必要無ければ実装は不要です。
import (
"context"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v4"
)
func createJWT(ctx context.Context, secretKey string, authorizations []string, tokenDuration time.Duration) (string, error) {
claims := &CustomClaims{
Authorizations: authorizations,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(ctime.Now(ctx).Add(tokenDuration)),
},
}
block, _ := pem.Decode([]byte(secretKey))
if block == nil {
return "", errors.New("failed to decode secret key block")
}
parsedPrivateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("failed to parse RSA private key: %v", err)
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
accessToken, err := token.SignedString(parsedPrivateKey)
if err != nil {
return "", fmt.Errorf("failed to sign token: %v", err)
}
return accessToken, nil
}
公開鍵/秘密鍵は以下などのコマンドで生成することが可能です。
ssh-keygen -t rsa -b 4096 -m PKCS8 -f rsa.pem
3. 認可
最後に認可処理についてです。認可では、アクセストークンの検証を行い、その中に含まれる JWT クレームの内容をもとに、呼び出し元の正当性や操作の可否を判断します。
このクレーム情報には、トークンの発行者・対象・発行先・有効期限など、重要な属性が含まれており、サービス間通信におけるアクセス制御の根拠になります。
以下、代表的なクレームとそのユースケース例の紹介です。
よく使われる JWT クレームとそのユースケース
クレーム | 説明 | ユースケース例 |
---|---|---|
sub (Subject) | トークンの主体(誰のトークンか) | 呼び出し元 SA やユーザー ID を一意に識別する。監査ログやアクセス制御に活用。 |
aud (Audience) | トークンの宛先 | 自サービス宛てのトークンかどうかを確認することで、なりすまし防止に使う。 |
iss (Issuer) | トークンの発行者 | Google の信頼できる発行元(例: https://accounts.google.com )かを確認。 |
email | トークン主体のメールアドレス(SA の ID) | 登録済みのロール・パーミッションと照合するためのキー情報。 |
exp / iat | 有効期限 / 発行時刻 | トークンが期限切れ・不正発行でないかの検証に使用。 |
本記事では実装を記載していませんが、例えば以下のようなケースが考えられます。
- aud によるリクエストの正当性確認
自サービスを対象に発行されたトークン (aud = "my-service") であることを確認し、他サービス向けのトークンを拒否する。 - email によるロール判定
SA のメールアドレスを元に、事前に登録されたロール(例:read, write, admin)と照合して、操作可否を判定する。
以下は、Go による JWT 検証とクレーム利用の一例です。
func VerifyJWT(ctx context.Context, signedString string) error {
rsaPublicKey, err := parsePublicKey(config.Get().AccessTokenVerificationKey)
if err != nil {
return err
}
claims := &CustomClaims{}
token, err := jwt.ParseWithClaims(signedString, claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, ErrClientPermissionDenied
}
return rsaPublicKey, nil
})
if err != nil {
var ve *jwt.ValidationError
if errors.As(err, &ve) {
if ve.Errors&jwt.ValidationErrorExpired != 0 {
return ErrTokenExpired
}
}
return ErrClientPermissionDenied
}
if token == nil || !token.Valid {
return ErrClientPermissionDenied
}
// ここで JWT クレームを活用しアクセス権限等を判定
return nil
}
func parsePublicKey(publicKey string) (*rsa.PublicKey, error) {
block, _ := pem.Decode([]byte(publicKey))
if block == nil {
return nil, fmt.Errorf("failed to decode public key block: %s", publicKey)
}
parsedPublicKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse public key: %v", err)
}
rsaPublicKey, ok := parsedPublicKey.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("not RSA public key")
}
return rsaPublicKey, nil
}
おわりに
本記事では Google Cloud と Google API を活用したサーバー間の認証・認可基盤の構築について紹介しました。
この基盤を構築するメリットは、大きく二つあるかなと思っています。
一つは、既存の Google Cloud のエコシステムを最大限に活用できるため、インフラの追加投資や複雑な設定が不要である点。
もう一つは、オープンスタンダードである OAuth 2.0 と JWT を利用することで、ベンダーロックインを回避し、将来にわたって拡張性の高い認証・認可基盤を実現できる点です。
本記事で紹介した内容が、皆さんのシステムにおける認証・認可の設計や実装の一助となれば幸いです。
🚀 Belong で一緒に働きませんか?
Belong では積極的にエンジニアの採用をしています。
ご興味のある方はぜひ エンジニアリングチーム紹介ページ をご覧ください。