ConnexionのJWT Auth Exampleを読み解く

# はじめに

Connexion[^1] の JWT Auth Example が、個人的に面白いと思ったのでメモします。

JWT Auth Example
https://github.com/zalando/connexion/tree/master/examples/openapi3/jwt

[^1]: https://github.com/zalando/connexion

Connexionとは:

- Pythonのバックエンドのフレームワークです。
- OpenAPI Specification[^2] (OAS)の形式で記述されたAPI仕様のYAMLを読み込み、HTTPリクエストをハンドリングします。
- 各APIをPythonの各関数に紐づける役割を果たします。

[^2]: https://swagger.io/specification/

JWT Auth Exampleは、ConnexionによるJWT認証のexampleです。
シンプルな中にも色々な要素が詰まっているところが面白いと思ったので、解説のメモを残しておきます。

# JWT Auth Example

## ダウンロード

JWT Auth Exampleを含む [zalando/connexion](https://github.com/zalando/connexion) をクローン(もしくはZIPでダウンロード)します。

```
$ git clone https://github.com/zalando/connexion.git
```

## 実行手順

基本的に [README](https://github.com/zalando/connexion/blob/master/examples/openapi3/jwt/README.rst) の通りにやっていきます。

```
$ cd connexion/examples/openapi3/jwt/
```

- ここで `requirements.txt` 内に `connexion>=2.0.0rc3` という記載がある場合は `connexion>=2.0.0` に修正しておいてください。
  修正しないと、想定外のバージョンの `connexion` がインストールされてハマることがあります。

```
$ sudo pip3 install -r requirements.txt
$ sudo pip3 install connexion[swagger-ui]
$ ./app.py
```

- READMEには記載されていませんが、Swagger UI を使用するため `connexion[swagger-ui]` をインストールしました。

ターミナルの最後に以下のように表示されれば起動成功です。

```
 * Running on http://0.0.0.0:8080/ (Press CTRL+C to quit)
```

READMEに従い、Swagger UI (http://localhost:8080/ui/) からAPIを叩きます。

1. `GET /auth/{user_id}` を実行し、取得したトークンをクリップボードにコピーします。
1. `Authorize` ボタンを押し、コピーしたトークンを登録します。
1. `GET /secret` を実行します。

以下は、Swagger UIで生成したcurlコマンドと、その実行結果です。
(これらを直接実行しても構いませんが、一度はSwagger UIを使ってみることをおすすめします)

```
$ curl -X GET "http://localhost:8080/auth/12" -H "accept: text/plain"
eyJ0e...(中略)...MpgLI
```

```
$ curl -X GET "http://localhost:8080/secret" -H "accept: text/plain" -H "Authorization: Bearer eyJ0e...(中略)...MpgLI"

    You are user_id 12 and the secret is 'wbevuec'.
    Decoded token claims: {'iss': 'com.zalando.connexion', 'iat': 1570964455, 'exp': 1570965055, 'sub': '12'}.
```

## 解説

JWT Auth Exampleを、ファイルごとに解説します。

### openapi.yaml

https://github.com/zalando/connexion/blob/master/examples/openapi3/jwt/openapi.yaml

`openapi.yaml` には、2つのAPIが定義されています。

#### GET /auth/{user_id}

1つ目のAPIは `GET /auth/{user_id}` です。
このAPIは `user_id` を指定すると、対応するトークンを発行します。
認証をイメージしたAPIのようですが、`user_id` は `integer` であれば何でもよく、パスワードも必要ありません。

```yaml:openapi.yaml
  /auth/{user_id}:
    get:
      summary: Return JWT token
      operationId: app.generate_token
      ...
```

#### GET /secret

2つ目のAPIは `GET /secret` です。
このAPIは秘密の情報を返します(という設定です)。
トークンを指定する必要があります。

```yaml:openapi.yaml
  /secret:
    get:
      summary: Return secret string
      operationId: app.get_secret
      ...
      security:
      - jwt: ['secret']
```

最後で `security` に `jwt` を指定しているところに注目してください。
これにより、APIの実行にトークンが必要となります。

- 余談ですが、Bearer Authenticationの説明[^3] によると、`jwt: ['secret']` は `jwt: []` としても同じです。  
  > `[]` contain a list of security scopes required for API calls. The list is empty because scopes are only used with OAuth 2 and OpenID Connect.

`jwt` というsecurity schemeは以下のように定義されています。

```yaml:openapi.yaml
components:
  securitySchemes:
    jwt:
      type: http
      scheme: bearer
      bearerFormat: JWT
      x-bearerInfoFunc: app.decode_token
```

ざっくりいうと、`jwt` とはJWTを使うBearer認証(トークン認証)[^3]であり、トークンのデコード(つまり認可)は `app.decode_token` に実装する、と書いてあります。
なお、`x-bearerInfoFunc` はOASではなくConnexionで規定されているプロパティです。[^4]

[^3]: https://swagger.io/docs/specification/authentication/bearer-authentication/
[^4]: https://connexion.readthedocs.io/en/latest/security.html#bearer-authentication-jwt

### app.py

https://github.com/zalando/connexion/blob/master/examples/openapi3/jwt/app.py

`app.py` には、3つのpublicな関数が定義されています。

#### generate_token()

1つ目の関数は `GET /auth/{user_id}` に対応する `generate_token()` です。
`user_id` に対応するJWTを生成します。

```py:app,py
def generate_token(user_id):
    timestamp = _current_timestamp()
    payload = {
        "iss": JWT_ISSUER,
        "iat": int(timestamp),
        "exp": int(timestamp + JWT_LIFETIME_SECONDS),
        "sub": str(user_id),
    }

    return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
```

JWTの生成には、python-jose[^5] の `jose.jwt` モジュールを使用しています。

実際の用途では、ここで認証(ユーザIDとパスワードを照合する等)が必要になるでしょう。

[^5]: https://github.com/mpdavis/python-jose

#### decode_token()

2つ目の関数はトークンのデコード(つまり認可)を行う `decode_token()` です。
`openapi.yaml` 内の `x-bearerInfoFunc` に指定されていましたね。

```py:app.py
def decode_token(token):
    try:
        return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
    except JWTError as e:
        six.raise_from(Unauthorized, e)
```

この関数は、`jwt.decode()` を呼び出しているだけです。
ここでも python-jose[^5] の `jose.jwt` モジュールを使用していますね。
トークンが正しい場合はトークンの情報(ペイロード)を返し、
トークンが正しくない場合は `werkzeug.exceptions.Unauthorized` [^6] をraiseします。

[^6]: https://werkzeug.palletsprojects.com/en/0.16.x/exceptions/#werkzeug.exceptions.Unauthorized

`algorithms=[JWT_ALGORITHM]` は、JWTの `alg` クレームに関する脆弱性[^7]への対処と思われます。
デコードに使用するアルゴリズムを制限していますね。

[^7]: https://www.chosenplaintext.ca/2015/03/31/jwt-algorithm-confusion.html

#### get_secret()

3つ目の関数は `GET /secret` に対応する `get_secret()` です。
秘密の情報を返します(という設定です)。

```py:app.py
def get_secret(user, token_info) -> str:
    return '''
    You are user_id {user} and the secret is 'wbevuec'.
    Decoded token claims: {token_info}.
    '''.format(user=user, token_info=token_info)
```

`openapi.yaml` で、`GET /secret` の `security` に `jwt` が指定されていましたね。
これにより、`get_secret()` が呼び出される前に `decode_token()` が呼び出されます。
トークンが正しい場合のみ `get_secret()` が実行されます。

Connexionは、`get_secret()` の引数に `user` (`sub` クレーム、つまり `user_id`) と `token_info` (`decode_token()` の戻り値、つまりJWTのペイロード) を渡します。
APIに対応する関数内でトークンが持つ情報を参照し、ユーザを判別したりできるということですね。

改めて、`GET /secret` の実行結果を見てみましょう。
`user` と `token_info` が埋め込まれていることがわかります。

```
    You are user_id 12 and the secret is 'wbevuec'.
    Decoded token claims: {'iss': 'com.zalando.connexion', 'iat': 1570964455, 'exp': 1570965055, 'sub': '12'}.
```

`the secret is 'wbevuec'` が何かは、よくわかりません。
秘密の情報ということでしょう。

# まとめ

この記事では、ConnexionのJWT Auth Exampleを見てみました。
トークン認証の基本が、OAS、Connexion、そしてpython-joseのおかげで楽に実装できることがわかりました。
シンプルな中に色々な要素が詰まっている、読み応えのあるexampleだったと思います。