Flask、PyJWTを使用したGoogle OpenID 連携用API作成 メモ

* Flask とPyJWTを使用してGoogleとのOpenID連携するためのAPI作成方法についてメモする。
  * 連携処理をバックエンド側に持たせたかったため、Docker起動できる形で[過去に作成した検証コード](https://qiita.com/KWS_0901/items/28066892d0811e571796)をAPI化した。

## 作成するAPI

### 認可リクエスト作成API

* Googleへの認可エンドポイントへアクセスするためのURLをパラメータをつけて生成・返却する。

  * `state`,`nonce`はAPI呼び出し時に生成し、レスポンスとして返却する。

  * `client_id`など固定の属性値は環境変数から取得する。

  * リクエスト例

    ```http
    POST /api/google/auth_request/create HTTP/1.1
    Host: localhost:5000
    ```

  * レスポンス例

    ```json
    {
        "authorization_request_url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=ABC12345&scope=hoge&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2Fcallback&state=ABCDE12345&nonce=FGHIJ67890&response_type=code",
        "nonce": "FGHIJ67890",
        "state": "ABCDE12345"
    }
    ```

    

### トークンリクエスト+ユーザー情報取得API

* 認可レスポンスとして受け取った認可コードを指定して、トークンリクエスト＋id_token検証(ユーザー情報取得)を行う。

  * 今回は簡略化のため認可リクエストパラメータのDB保存までは実装しておらず、検証処理は行っていないが、**`state`パラメータの検証は必須。**

  * リクエスト例

    ```http
    POST /api/google/auth_request/complete HTTP/1.1
    Host: localhost:5000
    Content-Type: application/json
    Content-Length: 173
    
    {
        "code":"XXXXX",
        "nonce":"FGHIJ67890"
    }
    ```

  * レスポンス例

    ```json
    {
        "email": "test@example.com",
        "email_verified": true,
        "sub": "123456789123456789"
    }
    ```

    

## プロジェクト構成

```
google_oidc
└─ docker-compose.yml
└─ google.env
│
└─ be
   └─ Dockerfile
   └─ requirements.txt
   │
   └─app
      └─ app.py
      │
      └─ api
          └─ __init__.py
          │
          └─views
             └─  google.py
```

## 実装

* `docker-compose.yml`

  ※起動時に環境変数ファイル`google.env`を読み込む。

  ```yaml
  version: "3"
  services:
    be:
      container_name: be
      build: ./be
      env_file: google.env
      volumes:
        - ./be/app:/app
      ports:
        - "5000:5000"
      command: flask run --host 0.0.0.0 --port 5000
      tty: true
  ```

* `google.env`

  * 環境変数ファイル。アプリケーション登録時に発行・設定した値を指定する。

  ```env
  CLIENT_ID=YOUR_CLIENT_ID
  CLIENT_SECRET=YOUR_CLIENT_SECRET
  REDIRECT_URI=YOUR_REDIRECT_URI
  AUTHORIZATION_ENDPOINT=https://accounts.google.com/o/oauth2/v2/auth
  TOKEN_ENDPOINT=https://www.googleapis.com/oauth2/v4/token
  SCOPE_LIST=YOUR_SCOPE_LIST
  ISSUER=https://accounts.google.com
  ```

* `be/requirements.txt`

  * Pythonライブラリ一式

  ```
  Flask
  Flask-Cors
  PyJWT
  cryptography
  ```

* `be/Dockerfile`

  ```dockerfile
  FROM python:3.8
  
  RUN mkdir /app
  ADD requirements.txt /app
  
  ENV PYTHONUNBUFFERED 1
  EXPOSE 5000
  
  WORKDIR /app
  RUN pip3 install -r requirements.txt
  ```

* `be/app/app.py`

  ```python
  from api import app
  
  if __name__ == '__main__':
      app.run()
  ```

* `be/api/__init__.py`

  ```
  from flask import Flask
  from .views.google import google_router
  from flask_cors import CORS
  
  
  def create_app():
  
      app = Flask(__name__)
  
      CORS(app, supports_credentials=True)
  
      app.register_blueprint(google_router, url_prefix='/api')
  
      return app
  
  
  app = create_app()
  
  ```

* `be/api/views/google.py`

  * コントローラー

  * id_token検証用の署名情報は、事前にid_tokeの形式を確認し、コード中にべた書きしている。

    ```python
    import hashlib
    from flask import Flask, Blueprint, request
    import urllib.parse as parse
    import urllib.request as req
    import urllib.error as error
    import json
    import os
    import jwt
    from jwt.algorithms import RSAAlgorithm
    from pprint import pprint
    
    # Routing Settings
    google_router = Blueprint('google_router', __name__)
    
    
    # Client Param
    client_id = os.getenv('CLIENT_ID')
    client_secret = os.getenv('CLIENT_SECRET')
    redirect_uri = os.getenv('REDIRECT_URI')
    scope_list = os.getenv('SCOPE_LIST')
    
    # Google Endpoint
    authorization_endpoint = os.getenv('AUTHORIZATION_ENDPOINT')
    token_endpoint = os.getenv('TOKEN_ENDPOINT')
    
    # id_token Validation Param
    issuer = os.getenv('ISSUER')
    # https://www.googleapis.com/oauth2/v3/certs
    jwk_json = {
        "e": "AQAB",
        "use": "sig",
        "n": "q_GoX7XASWstA7CZs3acUgCVB2QhwhupF1WZsIr6FoI-DpLaiTlGLzEJlkLKW2nthUP35lqhXilaInOAN86sOEssz4h_uEycVpM_xLBRR-7Rqs5iXype340JV4pNzruXX5Z_Q4D7YLvm2E1QWivvTK4FiSCeBbo78Lpkr5atiHmWEcLENoquhEHdpij3wppdDlL5eUAy4xH6Ait5IDe66RehBEGfs3MLnCKyGAPIammSUruV0BEmUPfecLoXNhpuAfoGs3TO-5CIt1jmaRL2B-A2UxhPQkpE4Q-U6OJ81i4nzs34dtaQhFfT9pZqkgOwIJ4Djj7HI1xKOmoExMCDLw",
        "kid": "774573218c6f6a2fe50e29acbc686432863fc9c3",
        "kty": "RSA",
        "alg": "RS256"
    }
    public_key = RSAAlgorithm.from_jwk(jwk_json)
    
    
    app = Flask(__name__)
    
    
    # Create Authorization Request Endpoint
    @google_router.route("/google/auth_request/create",  methods=['POST'])
    def create():
        # Generate nonce and state
        nonce = hashlib.sha256(os.urandom(32)).hexdigest()
        state = hashlib.sha256(os.urandom(32)).hexdigest()
        # Create Authz Request URL
        auth_request_url = authorization_endpoint+'?{}'.format(parse.urlencode({
            'client_id': client_id,
            'scope': scope_list,
            'redirect_uri': redirect_uri,
            'state': state,
            'nonce': nonce,
            'response_type': 'code'
        }))
        res_body = {
            "authorization_request_url": auth_request_url,
            "state": state,
            "nonce": nonce
        }
        return json.loads(json.dumps(res_body))
    
    
    # Token Request And Get User Info Endpoint
    @google_router.route("/google/auth_request/complete", methods=['POST'])
    def complete():
        # Parse Req Body
        jsonData = json.dumps(request.json)
        req_body = json.loads(jsonData)
        # Token Request
        token_req_body = parse.urlencode({
            'code': req_body["code"],
            'client_id': client_id,
            'client_secret': client_secret,
            'redirect_uri': redirect_uri,
            'grant_type': 'authorization_code'
        }).encode('utf-8')
        token_req = req.Request(token_endpoint)
        err_str = ''
        try:
            with req.urlopen(token_req, data=token_req_body) as f:
                token_res = f.read()
        except error.HTTPError as err:
            err_str = str(err.code) + ':' + err.reason + ':' + str(err.read())
            pprint(err_str)
        except error.URLError as err:
            err_str = err.reason
            pprint(err_str)
        # id_token Validation by PyJWT
        id_token = json.loads(token_res)['id_token']
        claims = jwt.decode(id_token,
                            public_key,
                            nonce=req_body['nonce'],
                            issuer=issuer,
                            audience=client_id,
                            algorithms=["RS256"])
        # nonce Validation
        if claims['nonce'] != req_body['nonce']:
            return "invalid id_token"
        res_body = {
            "sub": claims["sub"],
            "email": claims["email"],
            "email_verified": claims["email_verified"]
        }
        return json.loads(json.dumps(res_body))
    
    ```

    

## 起動

```shell
docker-compose up -d
```



## 参考情報

* [OpenID Connect](https://developers.google.com/identity/protocols/oauth2/openid-connect)
* [python/flaskでgoogleにOpenIDでログインしてみた。ライブラリ無しで。](https://blanktar.jp/blog/2015/08/python-flask-google-openid)
* [[Python] PyJWT で Google OAuth 2.0 API の ID Token を検証](https://www.ykrods.net/posts/2019/04/30/pyjwt-id_token-validation/)