IstioとAuth0でJWT認証付きAPIを5分でデプロイする

SREのたっち(@TatchNicolas)です。

JX通信社では、月に一度「WinSession」というリリースした機能や検証したリリースについて開発チーム全体へ発表する機会を設けています。今回は自分が前回社内に紹介した「パパッと便利APIを作って5分でお手軽&セキュアにデプロイする」方法について書きます。

TL; DR;

  • Istio/cert-manager/Auth0を使って、任意のコンテナを認証つきで5分でデプロイできる仕組みを作った
  • 設定はアプリケーションごとに独立し、中央集権的なリポジトリに依存しない*1

きっかけ

プロダクト間で共通のAPIを認証付きでパパッと作りたいこと、よくありますよね?

でも、アプリケーションに毎回認証のための仕組みを組み込むのは骨が折れます。アプリケーションはあくまで、アプリケーションの関心ごとに集中させたい。すると、サイドカーコンテナを使って責務を分離するのが良さそうです。

そこで、少しでもその手間を小さくするための仕組みを作ってみよう、となりました。ちょうどPyCon JPの配信システムでGKEに慣れた人も増えてきたタイミングだったので、またGKE上に構築しようと考えました。

tech.jxpress.net

しかし前回と違って幾つか課題がありました。

課題と対策

  1. あるアプリケーションの関心ごとがそのリポジトリの中で完結できない
    • JX通信社ではモノレポではなくマイクロサービス単位でレポジトリを切っている
    • 「気軽さ」を売りにしたいので中央集権的なリポジトリを作りたくない
    • Ingressだと多数のサービスへのトラフィックルールが1つのリソースのmanifestに列挙される
    • するとこの仕組み自体がよく使われるようになる程、Ingressリソースの記述が長くなってしまう
  2. 新しいアプリケーションをデプロイするたびに証明書を作る 必要がある
    • <アプリケーション名のサブドメイン>.<会社のドメイン> みたいな形で割り振りたい
    • しかしGCPのマネージド証明書はワイルドカード非対応
    • 増えた証明書をIngressのAnnotationに列挙する必要があり、記述がまた大きくなる
  3. きちんと認証をかけたいけど、アプリケーション本体と認証は分離したい
    • 基本的にはエンドユーザー向けというよりも内部で使うものたち
    • アプリケーションは自身の機能だけに集中したい

上記の3つの課題に対して、Istio/cert-manager/Auth0 の組み合わせた 「新しくリポジトリを作ったら、その中だけで必要なものが揃う」 ような仕組みで解決を試みました。

  1. 個々のアプリケーションへのトラフィック制御はIstioのVirtualServiceに任せることで、アプリケーションごとのリポジトリでトラフィックの管理ができる
  2. cert-managerを使ってワイルドカード証明書を利用する
    • アプリケーションが増えるたびに証明書を増やしたり、その設定を更新したりしなくて良い
    • IstioのGatewayリソースに三行書き足すだけで簡単
  3. Auth0を使って手軽に認証をつける
    • こちらもIstioのRequestAuthentication/AuthorizationPolicyを使うことで簡単に組み込むことができます。
    • アプリケーション側の実装は何も変えなくてOK

今回の環境/前提

  • クラスタ: v1.15.12-gke.2
  • Istioおよびcert-managerはインストールされている前提とします
    • どちらもOperatorでインストールしました
    • GKEのアドオンのIstioは使っていません
  • クラスタ外からのアクセスはistio-ingressgatewayのLoadBalancerで受けてます
  • アプリケーションのデプロイ先のNamespaceでistio-autoinjectは有効化しておきます
  • Auth0のsign upも完了していることとします

実際に作ってみる

(以下、本記事において「Application」「API」はAuth0上の概念、「Service」「Secrets」などはKubernetesリソース、「アプリケーション」は今回デプロイしたい対象としての一般名詞を指しています)

1. 全体で使うリソースの準備

各アプリケーションではなく、GKEクラスタ全体で使うリソースのリポジトリに置く想定のものから解説します。

cert-manager

apiVersion: cert-manager.io/v1alpha2
kind: ClusterIssuer
metadata:
  name: clusterissuer
  labels:
    repo: platform
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: 'yourname@yourdomain'
    privateKeySecretRef:
      name: cm-secret
    solvers:
    - dns01:
        clouddns:
          # GCPプロジェクトを指定
          project: your-project

CloudDNSを使ったchallengeを行うには、cert-managerのPodがCloudDNSを操作する権限を持つ必要があります。今回は、Workload Identityを使って、cert-managerのServiceAccountに権限を付与する方法を取りました*2

apiVersion: cert-manager.io/v1alpha2
kind: Certificate
metadata:
  name: wildcard
  namespace: istio-system
  labels:
    repo: platform
spec:
  # certificateリソースの名前じゃなくて、
  # このsecretNameをistioのGatewayで使う
  # https://istio.io/latest/docs/ops/integrations/certmanager/
  secretName: cm-secret
  issuerRef:
    name: clusterissuer
    kind: ClusterIssuer
  commonName: <your domain>
  dnsNames:
  - <your domain>
  - '*.<your domain>'

CertificateリソースのsecretNameで指定した名前で、cert-managerがKubernetesのSecretリソースを作成してくれます。このSecretリソースはあとでIstioのGatewayリソースから参照されます。

Istio

apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: gateway-with-tls
  namespace: istio-system
spec:
  selector:
    istio: ingressgateway
  servers:
  - port:
      number: 443
      name: https
      protocol: HTTPS
    tls:
      mode: SIMPLE
      credentialName: cm-secret
    hosts:
    - "*.<your domain>"

Gatewayリソースに、前述のCertificateリソースによって作られるSecret(cm-secret)を紐付けます。

apiVersion: security.istio.io/v1beta1
kind: RequestAuthentication
metadata:
  name: require-auth0-jwt
spec:
  selector:
    matchLabels:
      require-auth0: enabled
  jwtRules:
  - issuer: https://<yourtenant>.auth0.com/
    jwksUri: https://<yourtenant>.auth0.com/.well-known/jwks.json

require-auth0: enabled なラベルを持ったPodへの通信には認証を必要とするような設定をします。但しこれだけではまだ設定不足で、 不正なjwtを弾くことはできても、そもそもAuthorizationヘッダが無いリクエストは素通りしてしまいます。また、そのJWTがどのアプリケーションのために発行されたのかのチェックもできません。その対策は、アプリケーション単位に行います。

2. アプリケーション固有のリソースを作る

続いて、個々のアプリケーションごとに作成するリソースをみてみましょう。新しいアプリケーションを生やしたくなったら、ここ以降の手順を繰り返せばOKです。お好きなコンテナを動かしてください。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: sample
  labels:
    app: sample
spec:
  replicas: 1
  selector:
    matchLabels:
      app: sample
      require-auth0: enabled
  template:
    metadata:
      labels:
        app: sample
        require-auth0: enabled
    spec:
      containers:
      - name: app
        image: tatchnicolas/envecho:v0.3.2
        env:
        - name: ENVECHO_PORT
          value: "8765"
        - name: ENVECHO_MESSAGE
          value: "Hi! I am running as a `sample` service on GKE!"
        ports:
        - containerPort: 8765
---
apiVersion: v1
kind: Service
metadata:
  name: sample
  labels:
    app: sample
spec:
  type: ClusterIP
  selector:
    app: sample
    stage: current
  ports:
    - name: http
      protocol: TCP
      port: 8888
      targetPort: 8765

DeploymentとServiceは、require-auth0: enabled というラベルを指定している以外は特に変わったことはありません。そしてこのラベルが前述のRequestAuthenticationリソースのselectorと対応します。

Serviceが公開するポートは、一つしか無い場合はIstioがよしなにしてくれるので、何でもOKです。

続いて、トラフィック制御と認証を追加しましょう。

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: sample
  labels:
    app: sample
spec:
  gateways:
  - istio-system/gateway-with-tls
  hosts:
  - sample.<your domain>
  http:
  - route:
    - destination:
        host: sample.default.svc.cluster.local
---
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: sample-auth-policy
  labels:
    app: sample
spec:
  selector:
    matchLabels:
      app: sample
  action: ALLOW
  rules:
  - when:
    - key: request.auth.audiences
      values:
      # auth0のidentifierと一致させる
      - sample.<your domain>
  - from:
    - source:
        namespaces: ["prometheus"]
    to:
    - operation:
        methods: ["GET"]

sample.<your domain> というホスト名で来たリクエストを、前の手順で作ったServiceに紐づいたPodたちに回すようにVirtualService設定をします。

先ほど、RequestAuthenticationはまだ設定が不足していると書きましたが、足りない部分をここで補います。AuthorizationPolicyの spec.rules[0].when 以下の部分で、jwtのaudienceが正しいことを求めるようになります。また、AuthorizationPolicyを設定するとAuthorizationヘッダが無いリクエストもちゃんと弾いてステータスコード403を返すようになります。(あとで管理しやすいようにドメインと一致させましたが、特にその必要はありません。)

今回のテーマからは少し逸れますが、アプリケーションがPrometheusでinstrumentされていてる場合スクレイピングも弾かれてしまいますので、Prometheusサーバが prometheus というネームスペースにデプロイされていることを想定して、スクレイピングのリクエストは許可するような設定を参考として追加しました。

Kubernetes側の設定は以上です。

3. Auth0側にAPIを登録する

Auth0のコンソールにログインして、アプリケーションをAPIとして登録します。2. の手順の中でjwtのaudienceとして文字列が必要になります。

細かな手順は 公式の Register APIs を参照してください。注意すべき点はたった一つ、identifierの値を spec.rules[0].when 以下で指定した値(上記の例ではsample.<your domain>) とそろえるだけです。

APIを登録すると、対応するTest Applicationが作成されると同時に、API詳細画面のTestというタブからリクエストの方法が各種言語で見られるようになります。

4. 動作確認

f:id:TatchNicolas:20200827151837p:plain
Client、Auth0、istio-proxy付きPodの関係図

今回はcurlを使ってトークンを取得し、実際に立てたサービスへリクエストを試みます。 Applicationsの一覧に<API作成時の名前> (Test Application) というApplicationが作成されていますので、Client IDとClient Secretを使って次のように実際のアプリケーションへアクセスします。

# 上記の図の(1)
$ TOKEN=$(curl --request POST \
  --url https://jxpress.auth0.com/oauth/token \
  --header 'content-type: application/json' \
  --data '{"client_id":"xxxxxxxx","client_secret":"abcdefghijklmnopqrstuvwxyz","audience":"sample.<your domain>","grant_type":"client_credentials"}' | jq -rc '.access_token')
# 上記の図の(3)
$ curl -i -H "Authorization: Bearer $TOKEN"  https://sample.<your domain>
HTTP/2 200
date: Thu, 27 Aug 2020 03:18:21 GMT
content-length: 46
content-type: text/plain; charset=utf-8
x-envoy-upstream-service-time: 3
server: istio-envoy

Hi! I am running as a `sample` service on GKE!

TOKENが不正だったり、AuthorizationPolicyで指定したaudienceがJWTのaudience(=Auth0 APIのidentifier)が一致しない場合には403 Forbiddenが返ってきます。

Auth0の使い方について

今回は自動で作成されるTest Applicationを利用したので分かりにくいのですが、実際の運用では

  • デプロイするAPIのConsumerごとにApplicationを作成
  • そのApplicationに対してAPIの利用を許可する

という手順を踏むことになると思います。つまり、あるApplicationは複数のAPIに対する認可を得ることができ、またAPIも複数のアプリケーションに認可を与えることができます。

前述の例の audience の部分が使いたいAPIで設定したidentifierになり、ApplicationがAPIに対する権限を持っていなければAuth0からJWTが入手できないので、「どのApplicationがどのAPIを利用できるか」をAuth0で一元管理できるというわけですね。

たとえば、画像のアップロードを受け付ける foo というApplicationが、いい感じにリサイズや圧縮をしてくれる便利API hoge や画像に何が写っているかを判定する fuga というAPIを利用する場合、Application(foo) がAPI (hoge fuga) を叩けるようにAuth0のコンソール上でAuthorizeしてやる 必要があります。

詰まったところ/改善点

Istioがプロトコルを認識してくれない

今回の仕組みを作る途中、RequestAuthorization/AuthenticationPolicyを有効化すると upstream connect error or disconnect/reset before headers なエラーが出る現象にハマってしまいました。

本来IstioはデフォルトでHTTPおよびHTTP/2のプロトコルは自動で検出可能で、VirtualServiceは、spec.http.route[].destination の指しているServiceが公開しているポートが1つだけの場合、自動でそのポートへリクエストを回してくれます。*3 実際、認証をかけない状態ではきちんとリクエストは通っていました。

なので特に明示的に書かなくても大丈夫と思っていたのですが、Service側で spec.ports[].namehttp を指定してやる必要がありました。(そういう仕様なのか意図せぬ挙動なのかまでは調べきれてません...)

VirtualServiceのトラフィック制御条件が散らばってしまう

これは当初の目標だった「アプリケーションごとのリポジトリでトラフィックの管理ができる」ことの裏返しなのですが、トラフィック制御のルールが複数のリポジトリに散らばってしまいます。

公式Docにもある通りVirtualServiceは一つのKubernetesリソースとして登録することも、複数のリソース分割して登録することもできます。今回はその機能を前向きに利用したのですが、そのデメリットとして、例えばホスト名をベースにトラフィック制御していると、たまたま条件がかぶっていたり矛盾していると期待した振る舞いをしてくれない可能性があります。

it will only have predictable behavior if there are no conflicting rules or order dependency between rules across fragments

以前書いた記事のように、Admission Webhookなどを使って、自前で条件被りのチェックを実装しても良いかもしれませんが、条件のパターンは多岐に渡りますし、「何を意図せぬ衝突とし、何を意図した衝突とするか」の判断も難しいので一筋縄では実装できなさそうです。

tech.jxpress.net

さいごに

本記事の元になった社内発表でも、ライブコーディング的にデモを行って5分でサンプルアプリをデプロイすることができました。少しでも皆さんの参考になれば幸いです。

*1:もちろん、Istioやcert-manager自体の管理をするリポジトリは用意しますが、「アプリケーションを追加するときに、基盤の事情は知らなくていい」がポイントです

*2:https://github.com/jetstack/cert-manager/issues/3009

*3:https://istio.io/latest/docs/ops/configuration/traffic-management/protocol-selection/#automatic-protocol-selection