はじめに

Cloud Endpoints for gRPCの認証まわりでちょっと引っかかったので整理しました。
GCPのドキュメントは大元の英語版であっても間違ってる箇所が多いので注意が必要です。

言語はGolangです。

Cloud Endpoints

Cloud EndpointsはAPIを管理するためのサービスです。いまいちわかりにくいですが、こんなことをやってくれます。

  • gRPC APIをREST APIに変換して提供
  • APIのモニタリング
  • Auth0やFirebaseでの認証

Cloud Endpointsは大きく3つの形式で利用できます。

Extensible Service Proxy

for gRPCではリクエストはExtensible Service Proxy(以下ESP)を経由することになります。ESPはNginxベースのリバースプロキシでCloud Endpointsの機能を提供します。

このESPをサイドカーコンテナとしてアプリケーションと一緒にデプロイすることでCloud Endpointsの機能を利用できます。

認証

Cloud Endpointsの認証には大きくわけて2種類あります。

  • APIキー
    • 呼び出し元を識別する
  • 認証スキーム (Firebase、Auth0など)
    • エンドユーザを識別する

APIキー

API キーで API アクセスを制限する(gRPC)  |  Cloud Endpoints  |  Google Cloud Platform

APIキーの認証を有効化するにはサービス設定usage.rulesallow_unregistered_callsfalseにします。

usage:
  rules:
    - selector: "*"
      allow_unregistered_calls: false

クライアント側はmetadataのx-api-keyにAPIキーをセットしてリクエストします。また、例えばAPIキーの制限としてリファラを設定している場合は、metadataのrefererにリファラをセットする必要があります。

認証スキーム

ユーザーの認証  |  gRPC を使用する Cloud Endpoints  |  Google Cloud Platform

認証スキームではより細い認証を行うことができます。Firebaseやサービスアカウント、Auth0などで認証することができます。有効化するにはサービス設定のauthenticationを設定します。

クライアント側ではmetadataのauthorizationBaarer + tokenをセットします。

やってみる

実際に実装して認証結果をみていきます。細かい部分はかなり適当なのでそのままプロダクションでは使わないでください。

gRPCのメソッドはEcho1〜4までの4つを用意して、次のように認証を設定します。

メソッド APIキー認証 認証スキーム認証
Echo1 なし なし
Echo2 なし あり
Echo3 あり なし
Echo4 あり あり

認証スキームにはサービスアカウントを利用します。

ここではGCPのプロジェクトとしてcloud-endpoints-grpc-authを使っています。適宜書き換えてください。

完成コードはこちらにあります。

サービスの定義・実装

まず、gRPCのサービスを定義してGoのコードとデスクリプタファイルを生成します。

echo.proto
syntax = "proto3";

package echo;
option go_package = "main";

service EchoService {
  rpc Echo1 (Request) returns (Response) {}
  rpc Echo2 (Request) returns (Response) {}
  rpc Echo3 (Request) returns (Response) {}
  rpc Echo4 (Request) returns (Response) {}
}

message Request {
  string message = 1;
}

message Response {
  string message = 1;
}
protoc \
  -I=. \
  --include_source_info \
  --descriptor_set_out=echo.pb \
  --go_out=plugins=grpc:. \
  echo.proto

次に、サーバとクライアントを実装します。まだ認証部分は実装しません。それぞれのディレクトリを作成します。ここではめんどくさいのでpbファイルはコピーします。

mkdir server client
cp echo.pb.go server
cp echo.pb.go client

サーバの実装です。

server/main.go
package main

import (
    "fmt"
    "net"

    "golang.org/x/net/context"
    "google.golang.org/grpc"
    "google.golang.org/grpc/metadata"
    "google.golang.org/grpc/reflection"
)

const (
    port = ":50051"
)

func echo(ctx context.Context, in *Request) (*Response, error) {
    md, _ := metadata.FromIncomingContext(ctx)
    fmt.Println(", Metadata:", md)
    return &Response{Message: in.Message}, nil
}

type server struct{}

func (server) Echo1(ctx context.Context, in *Request) (*Response, error) {
    fmt.Print("Echo1 Received: ", in.Message)
    return echo(ctx, in)
}

func (server) Echo2(ctx context.Context, in *Request) (*Response, error) {
    fmt.Print("Echo2 Received: ", in.Message)
    return echo(ctx, in)
}

func (server) Echo3(ctx context.Context, in *Request) (*Response, error) {
    fmt.Print("Echo3 Received: ", in.Message)
    return echo(ctx, in)
}

func (server) Echo4(ctx context.Context, in *Request) (*Response, error) {
    fmt.Print("Echo4 Received: ", in.Message)
    return echo(ctx, in)
}

func main() {
    s := grpc.NewServer()
    RegisterEchoServiceServer(s, server{})
    reflection.Register(s)

    lis, err := net.Listen("tcp", port)
    if err != nil {
        panic(err)
    }

    if err := s.Serve(lis); err != nil {
        panic(err)
    }
}

metadataのx-endpoint-api-userinfoには認証したユーザの情報が入ります。

起動してみましょう。

cd server
go get .
go run *.go

クライアントの実装です。

client/main.go
package main

import (
    "flag"
    "fmt"

    "golang.org/x/net/context"
    "google.golang.org/grpc"
)

func main() {
    var addr, msg string
    flag.StringVar(&addr, "addr", "127.0.0.1:50051", "server address")
    flag.StringVar(&msg, "msg", "Hello", "message")
    flag.Parse()

    conn, err := grpc.Dial(addr, grpc.WithInsecure())
    if err != nil {
        panic(err)
    }
    defer conn.Close()

    c := NewEchoServiceClient(conn)

    ctx := context.Background()
    req := &Request{Message: msg}

    res, err := c.Echo1(ctx, req)
    if err == nil {
        fmt.Println("Echo1: succeeded:", res)
    } else {
        fmt.Println("Echo1: failed:", err)
    }

    res, err = c.Echo2(ctx, req)
    if err == nil {
        fmt.Println("Echo2: succeeded:", res)
    } else {
        fmt.Println("Echo2: failed:", err)
    }

    res, err = c.Echo3(ctx, req)
    if err == nil {
        fmt.Println("Echo3: succeeded:", res)
    } else {
        fmt.Println("Echo3: failed:", err)
    }

    res, err = c.Echo4(ctx, req)
    if err == nil {
        fmt.Println("Echo4: succeeded:", res)
    } else {
        fmt.Println("Echo4: failed:", err)
    }
}

サーバが起動していることを確認して、クライアントを実行してみます。

cd client
go run *.go

次のような出力が得られます。

Echo1: succeeded: message:"Hello"
Echo2: succeeded: message:"Hello"
Echo3: succeeded: message:"Hello"
Echo4: succeeded: message:"Hello"

これでgRPCサービスの定義と実装がおわりました。

Cloud Endpointsのデプロイ

Cloud Endpointsをデプロイする前に、認証に必要なサービスアカウントを作成しておきます。

gcloud iam service-accounts create client1 --display-name=client1
gcloud iam service-accounts add-iam-policy-binding \
  [email protected] \
  --member serviceAccount:[email protected] \
  --role roles/iam.serviceAccountUser

前述の表を満たすようにサービス設定をYAMLで書きます。

api_config.yaml
type: google.api.Service
config_version: 3

name: echo.endpoints.cloud-endpoints-grpc-auth.cloud.goog

title: Echo API
apis:
  - name: echo.EchoService

usage:
  rules:
    - selector: "echo.EchoService.Echo1"
      allow_unregistered_calls: true
    - selector: "echo.EchoService.Echo2"
      allow_unregistered_calls: true
    - selector: "echo.EchoService.Echo3"
      allow_unregistered_calls: false
    - selector: "echo.EchoService.Echo4"
      allow_unregistered_calls: false

authentication:
  providers:
    - id: client1
      issuer: [email protected]
      jwks_uri: https://www.googleapis.com/robot/v1/metadata/x509/[email protected]
  rules:
    - selector: "echo.EchoService.Echo2"
      requirements:
        - provider_id: client1
    - selector: "echo.EchoService.Echo4"
      requirements:
        - provider_id: client1

Cloud Endpointsをデプロイします。

gcloud endpoints services deploy api_config.yaml echo.pb

成功すると次のようなメッセージが表示されます。

Service Configuration [2018-02-05r0] uploaded for service [echo.endpoints.cloud-endpoints-grpc-auth.cloud.goog]

To manage your API, go to: https://console.cloud.google.com/endpoints/api/echo.endpoints.cloud-endpoints-grpc-auth.cloud.goog/overview?project=cloud-endpoints-grpc-auth

この2018-02-05r0echo.endpoints.cloud-endpoints-grpc-auth.cloud.googは後で必要になります。また、最後のURLにアクセスするとAPIのダッシュボードが表示され、モニタリング結果を確認できます。

APIのデプロイ

実装したAPI(サービス)をデプロイします。今回はGKEにデプロイします。また、Cloud Endpointsを有効にするため、サイドカーコンテナとしてESPを使います。

まずはサーバのDockerfileを定義します。

server/Dockerfile
FROM golang:1.9

WORKDIR /go/src/app
ADD . .
RUN go get .

EXPOSE 50051

CMD ["go", "run", "main.go", "echo.pb.go"]

Dockerイメージをレジストリにプッシュします。コンソールでContainer Registry APIを有効にしておく必要があります。https://console.cloud.google.com/apis/api/containerregistry.googleapis.com/overview?project=cloud-endpoints-grpc-auth

cd server
docker build -t gcr.io/cloud-endpoints-grpc-auth/echo .
gcloud docker -- push gcr.io/cloud-endpoints-grpc-auth/echo

KubernetesのDeploymentとServiceの設定ファイルを作ります。

server/k8s.yaml
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: echo
spec:
  replicas: 1
  template:
    metadata:
      labels:
        service: echo
    spec:
      containers:
        - name: esp
          image: gcr.io/endpoints-release/endpoints-runtime:1
          imagePullPolicy: Always
          args: [
            "-P", "9000",
            "-a", "grpc://127.0.0.1:50051",
            "-s", "echo.endpoints.cloud-endpoints-grpc-auth.cloud.goog",
            "-v", "2018-02-05r0",
          ]
          ports:
            - containerPort: 9000
        - name: echo
          image: gcr.io/cloud-endpoints-grpc-auth/echo
          ports:
            - containerPort: 50051
---
apiVersion: v1
kind: Service
metadata:
  name: echo
spec:
  type: LoadBalancer
  ports:
    - port: 80
      targetPort: 9000
      protocol: TCP
  selector:
    service: echo

ESPのargs-sはサービス名、-vは設定IDです。Endpointをデプロイしたときに表示されたものを使います。

GKEクラスタを作成してAPIをデプロイします。Kubernetes Engine APIを有効にしておく必要があります。
https://console.cloud.google.com/apis/api/container.googleapis.com/overview?project=cloud-endpoints-grpc-auth

# GKEクラスタ作成
gcloud container clusters create cluster-1 \
  --cluster-version 1.8.7-gke.0 \
  --num-nodes 1 \
  --zone asia-northeast1-a

# APIデプロイ
gcloud container clusters get-credentials cluster-1 --zone asia-northeast1-a
kubectl create -f k8s.yaml

しばらくするとGCEのロードバランサが作成されてIPが取得できるので、クライアントでリクエストしてみます。

ADDRESS=$(kubectl get service echo --output jsonpath="{.status.loadBalancer.ingress[0].ip}")
cd client
go run *.go -addr=${ADDRESS}:80

次のように出力されます。

Echo1: succeeded: message:"Hello"
Echo2: failed: rpc error: code = Unauthenticated desc = JWT validation failed: Missing or invalid credentials
Echo3: failed: rpc error: code = Unauthenticated desc = Method doesn't allow unregistered callers (callers without established identity). Please use API Key or other form of API consumer identity to call this API.
Echo4: failed: rpc error: code = Unauthenticated desc = JWT validation failed: Missing or invalid credentials

なんの認証もかかっていないEcho1だけレスポンスを返します。他はESPで拒否されています。

ログを見るとESPで401を返していることがわかります。

$ kubectl logs -lservice=echo -c esp
10.52.0.1 - - [05/Feb/2018:05:50:59 +0000] "POST /echo.EchoService/Echo1 HTTP/2.0" 200 12 "-" "grpc-go/1.10.0-dev"
2018/02/05 05:50:59 [warn] 9#9: *1 a client request body is buffered to a temporary file /var/cache/nginx/client_temp/0000000001, client: 10.52.0.1, server: , request: "POST /echo.EchoService/Echo1 HTTP/2.0", host: "35.189.159.64:80"
2018/02/05 05:50:59[warn]9#9: Received non-matching report response service config ID: '', requested: '2018-02-05r0'
10.52.0.1 - - [05/Feb/2018:05:50:59 +0000] "POST /echo.EchoService/Echo2 HTTP/2.0" 401 0 "-" "grpc-go/1.10.0-dev"
10.52.0.1 - - [05/Feb/2018:05:50:59 +0000] "POST /echo.EchoService/Echo3 HTTP/2.0" 401 0 "-" "grpc-go/1.10.0-dev"
10.52.0.1 - - [05/Feb/2018:05:50:59 +0000] "POST /echo.EchoService/Echo4 HTTP/2.0" 401 0 "-" "grpc-go/1.10.0-dev"

APIキーで認証

クライアントが認証できるようにしていきます。

まず、APIキーを取得します。コンソールのAPIとサービスの認証情報から、APIキーを作成します。
https://console.cloud.google.com/apis/credentials?project=cloud-endpoints-grpc-auth

なんらかの制限をかけるように警告されるので、ここではHTTP リファラーを設定してみます。とりあえずdummy.example.comとしておきます。

クライアントのコードを次のように修正します。

client/main.go
// 省略

type credential struct {
    key     string
    referer string
}

func (c credential) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
    return map[string]string{
        "x-api-key": c.key,
        "referer":   c.referer,
    }, nil
}

func (credential) RequireTransportSecurity() bool {
    return false
}

func main() {
    var addr, msg, key, referer string
    flag.StringVar(&addr, "addr", "127.0.0.1:50051", "server address")
    flag.StringVar(&msg, "msg", "Hello", "message")
    flag.StringVar(&key, "key", "invalid", "API Key")
    flag.StringVar(&referer, "referer", "invalid", "referer")
    flag.Parse()

    cred := credential{
        key:     key,
        referer: referer,
    }

    conn, err := grpc.Dial(addr, grpc.WithInsecure(), grpc.WithPerRPCCredentials(cred))
    if err != nil {
        panic(err)
    }
    defer conn.Close()

        // 省略
}

認証情報をリクエストに付加する方法は何通りかありますが、grpcパッケージのAPIを見る限りこれがよさそうな気がします。

これを実行すると、次のようにAPIキーでの認証が成功していることがわかります。

$ go run *.go -addr=${ADDRESS}:80 -key=${API_KEY} -referer=dummy.example.com
Echo1: succeeded: message:"Hello"
Echo2: failed: rpc error: code = Unauthenticated desc = JWT validation failed: Missing or invalid credentials
Echo3: succeeded: message:"Hello"
Echo4: failed: rpc error: code = Unauthenticated desc = JWT validation failed: Missing or invalid credentials

認証はESPが行うのでサーバのコードは変更不要です。

サービスアカウントで認証

次にサービスアカウントで認証します。認証にはトークンが必要になるので、まずサービスアカウントの秘密鍵でJSON Web Tokenを生成します。

サービスアカウントの秘密鍵はJSONで生成してclient/service_account.jsonに保存しておいてください。

JWT生成にはPythonスクリプトを使います。このスクリプトはGoogle公式のものを少し修正しています。公式のものだとIssuerとSubjectが異なるため認証できません。1

client/gen_jwt.py
import argparse
import json
import time

import google.auth.crypt
import google.auth.jwt

MAX_TOKEN_LIFETIME_SECS = 3600

def generate_jwt(service_account_file, issuer, audiences):
    with open(service_account_file, 'r') as fh:
        service_account_info = json.load(fh)

    signer = google.auth.crypt.RSASigner.from_string(
        service_account_info['private_key'],
        service_account_info['private_key_id'])

    now = int(time.time())

    payload = {
        'iat': now,
        'exp': now + MAX_TOKEN_LIFETIME_SECS,
        'aud': audiences,
        'iss': issuer,
        'sub': issuer,
        'email': '[email protected]'
    }

    signed_jwt = google.auth.jwt.encode(signer, payload)
    return signed_jwt


if __name__ == '__main__':
    parser = argparse.ArgumentParser(
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument('--file',
                        help='The path to your service account json file.')
    parser.add_argument('--issuer', default='', help='issuer')
    parser.add_argument('--audiences', default='', help='audiences')

    args = parser.parse_args()

    signed_jwt = generate_jwt(args.file, args.issuer, args.audiences)
    print(signed_jwt.decode())

実行します。

JWT=$(python gen_jwt.py --file service_account.json --audience echo.endpoints.cloud-endpoints-grpc-auth.cloud.goog --issuer [email protected])

audienceはCloud Endpointsのサービス名 (サービス設定のname)、issuerはサービス設定で設定したissuerと一致する必要があります。

クライアントを次のように修正します。

client/main.go
// 適当に省略

type credential struct {
    key     string
    referer string
    jwt     string
}

func (c credential) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
    return map[string]string{
        "x-api-key":     c.key,
        "referer":       c.referer,
        "authorization": "Bearer " + c.jwt,
    }, nil
}

func main() {
    var addr, msg, key, referer, jwt string
    flag.StringVar(&jwt, "jwt", "invalid", "JSON Web Token")

    cred := credential{
        key:     key,
        referer: referer,
        jwt:     jwt,
    }
}

これを実行するとすべての認証が成功します。

go run *.go -addr=${ADDRESS}:80 -key=${API_KEY} -referer=dummy.example.com -jwt=${JWT}
Echo1: succeeded: message:"Hello"
Echo2: succeeded: message:"Hello"
Echo3: succeeded: message:"Hello"
Echo4: succeeded: message:"Hello"

サーバのログを見ると、それぞれの認証でMetadataがどうなっているか確認できます。JWTやAPIキーがそのまま格納されるようです。

kubectl logs -lservice=echo -c echo

認証の組合せ

確認するため雑な表にしてみました。

スクリーンショット 2018-02-05 16.15.16.png (270.9 kB)

どちらも認証がかかっている場合は、どちらかが不正だとエラーになりました。また、両方不正な場合はサービスアカウント認証のエラーが返ってきました。

APIキー、JWTともにフォーマットが不正だとその旨のエラーが返ってきます。

APIキーの認証エラーは次のような感じで結構細かくメッセージが返ってきました。

# フォーマット不正
code = InvalidArgument desc = API key not valid. Please pass a valid API key.

# 違うプロジェクトのAPIキー
code = PermissionDenied desc = API echo.endpoints.cloud-endpoints-grpc-auth.cloud.goog is not enabled for the project.

# 削除した
code = InvalidArgument desc = API key expired. Please renew the API key.

おわりに

サンプルがなかったりドキュメントが間違ってたりしますが、認証を自分で持たなくていいのはとても楽ですね。