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

* Flask とPyJWTを使用したGoogleとのOpenID連携方法についてメモする。

## 大まかな処理の流れ

1. Googleへ認可リクエスト(Authorization Request)を行う。

2. ユーザー認証/同意を行い、認可レスポンスを受け取る。

3. 認可レスポンスを使ってトークンリクエストを行う。

   
## 事前準備

* [Googleデベロッパーコンソール](https://console.cloud.google.com/projectselector2/home/dashboard?organizationId=0&supportedpurview=project)からプロジェクト、クライアントを登録する。
  * アプリケーションの種類
        * ウェブアプリケーション
  * クライアントID/シークレット発行
  * スコープ設定
  * リダイレクトURI設定
        * 今回は`http://localhost:5000/callback`を設定
* Pythonライブラリインストール
  * `flask`
        * ウェブアプリ開発用フレームワーク
  * `pyjwt`
        * JSON Web Tokenエンコード/デコード用ライブラリ
        * id_token検証に使用する。

* id_token署名情報確認
  1. [jwks_uri](https://www.googleapis.com/oauth2/v3/certs) からJWKを確認する。
  2. id_tokenのヘッダー記載のkidと同じJWKを確認する(下記`jwk_json`)。
  3. JWKに対してpyjwtを使用して公開鍵を取得する。

     ```python
     from jwt.algorithms import RSAAlgorithm
     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)
     ```

     



## 実装

* 認可リクエスト ~ トークンリクエスト(id_token検証含む)までのコード

```python
import hashlib
from flask import Flask, request, redirect, session
import urllib.request
import urllib.parse
import json
import base64
from pprint import pprint
import os
import jwt
from jwt.algorithms import RSAAlgorithm

# id_token 検証用公開鍵
# 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)
# iss(トークン発行者)
issuer = 'https://accounts.google.com'

# クライアント情報
# 自身の環境に合わせて設定する。
client_id = <YOUR_CLIENT_ID>
client_secret = <YOUR_CLIENT_SECRET>
redirect_uri = 'http://localhost:5000/callback'

# Google エンドポイント
authorization_base_url = 'https://accounts.google.com/o/oauth2/v2/auth'
token_url = 'https://www.googleapis.com/oauth2/v4/token'

app = Flask(__name__)
app.secret_key = 'session_key'


# 認可リクエスト用
@app.route("/login")
def login():
    # nonce、stateの生成と保存
    nonce = hashlib.sha256(os.urandom(32)).hexdigest()
    state = hashlib.sha256(os.urandom(32)).hexdigest()
    session['nonce'] = nonce
    session['state'] = state
    # Googleへの認可リクエスト
    return redirect(authorization_base_url+'?{}'.format(urllib.parse.urlencode({
        'client_id': client_id,
        'scope': 'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile',
        'redirect_uri': redirect_uri,
        'state': state,
        'nonce': nonce,
        'response_type': 'code'
    })))


# 認可レスポンス用リダイレクションエンドポイント + トークンリクエスト用
@app.route("/callback")
def callback():
    # state 検証
    state = request.args.get('state')
    if state != session['state']:
        print("invalid_redirect")
    code = request.args.get('code')
    # トークンリクエスト
    body = urllib.parse.urlencode({
        'code': code,
        'client_id': client_id,
        'client_secret': client_secret,
        'redirect_uri': redirect_uri,
        'grant_type': 'authorization_code'
    }).encode('utf-8')
    req = urllib.request.Request(token_url)
    with urllib.request.urlopen(req, data=body) as f:
        res = f.read()

    # id_token 検証
    content = json.loads(res)
    id_token = content['id_token']
    claims = jwt.decode(id_token,
                        public_key,
                        issuer=issuer,
                        audience=client_id,
                        algorithms=["RS256"])
    # nonce 検証
    if claims['nonce'] != session['nonce']:
        return "invalid id_token"
    return claims

if __name__ == "__main__":
    app.run(debug=True)

```





## 参考情報

* [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/)