PythonとOAuth2でログイン／ログアウトするWebサーバを作る（Auth0, Azure AD対応ソース記載）

## 概要

「ユーザ認証でログイン／ログアウトできるWebサーバを作ってみたい」と思ったが、やったことがないのでどう取っ掛かっていいのかがわからない…。自分で全部実装するのは初心者には結構大変そうなので、ユーザ管理に関して便利な仕組みはないのだろうか。
そう思って調べていくと、ユーザIDの保存や管理などを代わりにやってくれるIdentify Provider（IdP）というサービスがあるようで、これを使えばだいぶ手間が省けそうな気がした。IdPとしては、例えばOkta、Azure AD、Auth0とかが有名みたい。
沢山あるIdPの中から今回はとりあえずAuth0を選んで、ログイン／ログアウト可能なWebサーバを実装してみた。（**21/12/29：Azure AD対応版ソースも追記**）

## やりたいこと

すごく単純。Webブラウザ上で以下の遷移をするサーバを作りたい。

* (1) トップページ（Path : ``/``）
    * 「ログイン」と記載されたリンクだけがある。クリックすると(2)に進む。
* (2) ログイン入力ページ（Auth0の認証サイトに遷移）
    * 「ユーザID」と「パスワード」を入力するページ
    * 実際はわざわざ(1)のトップページと分けなくても、(1)に埋め込んで一つのページで完結できるようだが、今回はそこまでできていない。
* (3) ログイン完了後のページ（Pathは``/welcome``）
    * 「Welcome (ユーザ名）!」と表示
    * 「ログアウト」と記載されたリンクがある。クリックすると(1)のトップページに戻る

## 準備

### (1) Auth0へのユーザ登録

まずは以下のサイト等を参考にして、Auth0のサービスを利用可能な状態にする

* クラウド認証サービス Auth0 の無料トライアルを試してみよう
    * https://qiita.com/furuth/items/8826b66a3ec2b2b14222

### (2) Auth0 Dashboardでの事前設定

* Auth0のトップページ（[https://auth0.com/jp](https://auth0.com/jp)）からログインして、「Auth0 Dashboard」の画面に移動。
* 左側のメニューで「Applications」→「Applications」をクリックすると右側に２つのアプリケーションが出てくるので、その中の「Default App」を選択。
* Default Appの「Settings」で真ん中あたりに出てくる「Allowed Callback URLs」と「Allowed Logout URLs」にそれぞれ以下のようにURLを追加。
    * Allowed Callback URLs
        * http(s)://[サーバのホスト名 or IP]:3000/callback
    * Allowed Logout URLs
        * http(s)://[サーバのホスト名 or IP]:3000
    * ※ホスト名やIPアドレスはプライベートな値でも問題ない
* 設定の一番下までいって、「Save Changes」をクリックして保存。

「Callback URL」「Logout URL」と言われてもピンとこないかもしれないので、本記事の「やりたいこと」で書いた遷移(1)〜(3)で、これらがどこで関わってくるかに主眼を置き説明する。
まずCallback URLは、「遷移(2)でAuth0の認証サイトでID/Passwordを入力後、どこに遷移するか」を表す。今回の例の場合は自分で作ったサイトの``/callback``のパスに戻ってくる。
続いてLogout URLは、遷移(3)でログアウトボタンをクリック後に遷移する先を指定する。後で示すソースを見ていただくとわかるかと思うが、ログアウト時は「Auth0のログアウト用API」も呼ばれ、API実行後にルート``/``のページに戻る。

### (3) Webサーバの構築準備（PythonのFlaskを利用）

PythonのFlaskを使ってログイン／ログアウトするWebサーバを構築する。

#### Pythonの環境情報

* 動作確認したPythonのバージョン
    * 3.9.7
* pipでインストールするパッケージ

    | パッケージ | 用途など |
    | --- | --- |
    | flask | Webアプリケーションフレームワーク |
    | oauthlib | OAuth2に関連するリクエスト情報の作成 |
    | requests | Auth0(IdP)のAPIへのリクエスト用 |

## 実装

準備が整ったらソースを作成して、Webサーバを実装する。
ソース作成のために参考にさせていただいたサイトは下記２つ。

* (1) Auth0のPythonログイン用サンプルソース(GitHub)
    * https://github.com/auth0-samples/auth0-python-web-app/tree/master/01-Login

上記サイトのソースをそのまま実行すれば本記事とほぼ同じログイン／ログアウトの動作を確認できるが、Auth0用のパッケージでかなりラッピングされている。今回はOAuth2の動きも実感しつつソースを作ってみたかったので、Auth0のパッケージはあえて使わず、他のIdPでも共通して使えそうなOAuthのパッケージ(OauthLib)で実装してみることにした。

* (2) PythonでシンプルにOAuth2する
    * https://qiita.com/hoto17296/items/2d2cb76d323099e9f8ab

OauthLibの基本的な使い方が書かれており、大変参考にさせていただいた。

### 24/10/14追記：GitHubでソースを公開

本記事に記載した内容をブラッシュアップして、FlaskでOIDCを行うソースを以下のGitHubに公開しました。

https://github.com/showchan33/flask-oidc/blob/main/README.ja.md

### ソース構成

```
.
├── app.py            <- メイン部分
└── templates
    ├── layout.html   <- 共通に使うテンプレート
    ├── top.html      <- ルート(/)の内容
    └── welcome.html  <- ログイン後のサイト(/welcome)の内容
```

### htmlテンプレートの中身

```html:layout.html
<!doctype html>
<html>
<head>
<title>{{ title }}</title>
</head>
<meta charset="utf-8" />
<body>
{% block content %}
<!-- ここにメインコンテンツを書く -->
{% endblock %}
</body>
</html>
```

トップページ(top.html)はAuth0のログインサイトに遷移するリンクを表示

```html:top.html
{% extends "layout.html" %}
{% block content %}
<a href="{{ authz_url }}">ログイン</a>
{% endblock %}
```

ログイン後のページ(welcome.html)には以下を表示

* Auth0から取得したユーザ名。変数``{{ name }}``が該当。
* ログアウトへのリンク

```html:welcome.html
{% extends "layout.html" %}
{% block content %}
Welcome, {{ name }}!<br>
<a href="/logout">ログアウト</a>
{% endblock %}
```

### メインのソース部分

まずはソース全体を掲載。
★がついているところは後ほど解説。

```python:app.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os, requests, json, base64
from urllib.parse import urlencode, urlparse, parse_qs
from functools import wraps
from flask import Flask, render_template, request, redirect, session
from oauthlib.oauth2 import WebApplicationClient

# httpでtoken取得可能にするために設定
# (httpsサーバの場合は不要)
os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1'

# FlaskのWebサーバ設定
app = Flask(__name__)
app.secret_key = "SecretKey"
app.debug = True

# ログイン判定に使用
PROFILE_KEY = 'profile'

# ★ 解説(1)
# Auth0(OAuth2)関連の情報を定義
AUTH0_DOMAIN = "dev-xxxxxxxx.us.auth0.com"
AUTH0_CLIENT_ID = "yyyyyyyyyyyyyyyyyyyyy"
AUTH0_CLIENT_SECRET = "zzzzzzzzzzzzzzzzzzzzz"
AUTH0_CALLBACK_URL = r"http://[サーバのホスト名 or IP]:3000/callback"

# WebApplicationClientの作成
oauth = WebApplicationClient(AUTH0_CLIENT_ID)

# Auth0での認証が完了していない場合はルート(/)にリダイレクトさせる関数
# 関数welcome(URLで/welcomeを指定した時の処理)にデコレータとして適用
def requires_auth(f):
  @wraps(f)
  def decorated(*args, **kwargs):
    if PROFILE_KEY not in session:
      return redirect('/')
    return f(*args, **kwargs)

  return decorated

# トップページ
@app.route('/')
def index():

  # ★ 解説(2)
  # 認可用のリクエストを作成
  authz_url, authz_headers, authz_body = \
    oauth.prepare_authorization_request(
      'https://{}/authorize'.format(AUTH0_DOMAIN),
      redirect_url=AUTH0_CALLBACK_URL,
      scope='openid profile email',
  )

  # トップページに、Auth0の認可用URLへのリンク(authz_urlの値)を貼る
  return render_template('top.html', authz_url=authz_url)

# ★ 解説(3)
# Auth0での認証後に戻ってくるパス
@app.route('/callback')
def callback():

  # callbackURLのクエリ文字列から、stateの値を取得
  url_parsed = urlparse(request.url)
  qs_d = parse_qs(url_parsed.query)
  state = qs_d['state'][0]

  # ★ 解説(3-1)
  # トークン取得用のリクエスト作成
  token_url, token_headers, token_body = \
    oauth.prepare_token_request(
      'https://{}/oauth/token'.format(AUTH0_DOMAIN),
      authorization_response=request.url,
      client_secret=AUTH0_CLIENT_SECRET,
      state=state,
  )

  # ★ 解説(3-2)
  # アクセストークンを取得
  token_res = requests.post(
    token_url, data=token_body, headers=token_headers
  )

  # ★ 解説(3-3)
  # レスポンスからJWTの各種情報を取得
  token_res_json = json.loads(token_res.content.decode('utf-8'))

  # JWTからペイロード情報を取得
  jwts = token_res_json['id_token'].split(".")
  # 文字数が4の倍数でないとエラーになるので"="でパディング
  jwts[1] = jwts[1] + '='*(-len(jwts[1])%4)

  # ペイロードをsessionの配列に格納
  session[PROFILE_KEY] = json.loads(base64.urlsafe_b64decode(jwts[1]).decode('utf-8'))

  return redirect('/welcome')

# ★ 解説(4)
# ログイン後のWelcomeページ（Auth0で認証完了後でないとアクセスできない）
@app.route('/welcome')
@requires_auth
def welcome():
  return render_template(
    'welcome.html',
    name = session[PROFILE_KEY]['nickname'],
  )

# ★ 解説(5)
# ログアウト時に通るパス。最終的にはトップページ(/)に戻る
@app.route('/logout')
def logout():
    session.clear()
    params = {'returnTo': "http://[サーバのホスト名 or IP]:3000/", 'client_id': AUTH0_CLIENT_ID }
    return redirect('https://' + AUTH0_DOMAIN + '/v2/logout?' + urlencode(params))

if __name__ == "__main__":
  app.run(host='0.0.0.0', port=3000)
```

## ソース解説

### (1) Auth0(OAuth2)にアクセスするための情報定義

```python
AUTH0_DOMAIN = "dev-xxxxxxxx.us.auth0.com"
AUTH0_CLIENT_ID = "yyyyyyyyyyyyyyyyyyyyy"
AUTH0_CLIENT_SECRET = "zzzzzzzzzzzzzzzzzzzzz"
AUTH0_CALLBACK_URL = r"http://[サーバのホスト名 or IP]:3000/callback"
```

* Auth0の場合、``AUTH0_DOMAIN``, ``AUTH0_CLIENT_ID``, ``AUTH0_CLIENT_SECRET``の３つは「Auth0 Dashboard」のApplicationの設定(Settings)で確認可能。おそらくAuth0以外のIdPであってもこれらのパラメータは用意されており共通に使えるはず。
* ``AUTH0_CALLBACK_URL``は、Auth0でのユーザログイン後に戻ってくるURL

### (2) 認可用リクエストの作成

```python
  authz_url, authz_headers, authz_body = \
    oauth.prepare_authorization_request(
      'https://{}/authorize'.format(AUTH0_DOMAIN),
      redirect_url=AUTH0_CALLBACK_URL,
      scope='openid profile email',
  )
```

Auth0で認可してもらうためのリクエスト情報を作成する。``authz_url``には以下のようなURLがクエリ文字列つきで返ってきた。（実際の値は改行されていない。以後のURLも同様）

```
https://dev-xxxxxxxx.us.auth0.com/authorize?
response_type=code&client_id=[AUTH0_CLIENT_ID]&
redirect_uri=[AUTH0_CALLBACK_URL]&scope=openid+profile+email&
state=[ランダムな文字列]
```

### (3) Callback後の処理

#### (3-1) トークン取得用リクエストの作成

CallbackされたURLには、以下のようにcodeとstateのパラメータ値がクエリ文字列でくっついてくる。

```
http://[AUTH0_CALLBACK_URL]?
code=[ランダムな文字列]
state=[authz_urlのstateと同じ値]
```

ここからstateの値を取得して、以下のように「トークン取得用リクエスト情報」を作成する。

```python
  token_url, token_headers, token_body = \
    oauth.prepare_token_request(
      'https://{}/oauth/token'.format(AUTH0_DOMAIN),
      authorization_response=request.url,
      client_secret=AUTH0_CLIENT_SECRET,
      state=state,
  )
```

各々、以下のような結果が返ってくる。

```yaml
token_url : https:/[AUTH0_DOMAIN]/oauth/token
token_headers : {'Content-Type': 'application/x-www-form-urlencoded'}
token_body : grant_type=authorization_code&
             client_id=[AUTH0_CLIENT_ID]&
             client_secret=[AUTH0_CLIENT_SECRET]&
             code=[CallbackURLのcodeと同じ値]&
             redirect_uri=[AUTH0_CALLBACK_URL]
```

#### (3-2) アクセストークンを取得

```python
  token_res = requests.post(
    token_url, data=token_body, headers=token_headers
  )
```

(3-1)で作った「アクセストークン取得用リクエスト」を投げて、アクセストークンを取得する。
アクセストークンの中身は以下のようになっている。

```json
{
  "access_token":"…",
  "id_token":"aaaa.bbbb.cccc",
  "scope":"openid profile email",
  "expires_in":86400,
  "token_type":"Bearer"
}
```

上記の詳しい説明は省略するが、id_tokenの``aaaa.bbbb.cccc``の部分はJSON Web Token（JWT）と呼ばれており、「OpenID Connect（OIDC）の仕様で定めれられた、２者間で情報を安全に伝送するためのトークン」である。
id_tokenのドット(.)で区切られたうちの``bbbb``部分に認証ユーザに関する様々な情報が入っており（「ペイロード」と呼ぶ）次の処理で抽出する。

#### (3-3) Welcomeページに表示するユーザ情報の取得

```python
  # レスポンスからJWTの各種情報を取得
  token_res_json = json.loads(token_res.content.decode('utf-8'))

  # JWTからペイロード情報を取得
  jwts = token_res_json['id_token'].split(".")
  # 文字数が4の倍数でないとエラーになるので"="でパディング
  jwts[1] = jwts[1] + '='*(-len(jwts[1])%4)

  # ペイロードをsessionの配列に格納
  session[PROFILE_KEY] = json.loads(base64.urlsafe_b64decode(jwts[1]).decode('utf-8'))
```

ソース内で``token_res_json['id_token']``がJWT、``jwts[1]``がペイロードに相当する。ペイロードはBase64URL形式でエンコードされているため、デコードして中身を取得する。
今回は``/callback``からリダイレクトする``/welcome``ページに、ペイロードから「ユーザのニックネーム」を抽出して表示させたい。そこで、Flaskのsession部分（Cookieと同等？）にデコードしたペイロード情報を保存することにする。
ペイロードの中身は以下のようなJSON形式になっている。

```JSON
{
  'nickname': 'username',
  'name': 'username@hogehoge.com',
  'picture': 'https://s.gravatar.com/avatar/...',
  'updated_at': '2021-09-18T12:21:57.535Z',
  'email': 'username@hogehoge.com',
  'email_verified': False,
  'iss': 'https://[AUTH0_DOMAIN]/',
  'sub': 'auth0|xxxxxxxxxxxxxxxx',
  'aud': '[AUTH0_CLIENT_ID]',
  'iat': 1631974418,
  'exp': 1632010418,
}
```

#### (4) Welcomeページの表示

```python
# ログイン後のWelcomeページ（Auth0で認証完了後でないとアクセスできない）
@app.route('/welcome')
@requires_auth
def welcome():
  return render_template(
    'welcome.html',
    name = session[PROFILE_KEY]['nickname'],
  )
```

(3-3)で取得したペイロード情報のnicknameをWebページに表示する。

#### (5) ログアウト

```python
@app.route('/logout')
def logout():
    session.clear()
    params = {'returnTo': "http://[サーバのホスト名 or IP]:3000/", 'client_id': AUTH0_CLIENT_ID }
    return redirect('https://' + AUTH0_DOMAIN + '/v2/logout?' + urlencode(params))
```

Auth0のページにリダイレクトしてログアウトする。ログアウト後は自分のWebサーバのルート(/)にリダイレクトされる。
また、``session.clear()``自分のWebサーバのセッションもここで終了する。

## [参考]Auth0のAPIドキュメント

今回、以下３つのAuth0関連のAPIを利用したが、[Auth0の認証APIドキュメント](https://auth0.com/docs/api/authentication)にこれら含めた一連のAPI利用方法が書いてある模様。

| 機能 | URL |
| --- | --- |
| 認可 | https://[AUTH0_DOMAIN]/authorize |
| アクセストークン取得 | https://[AUTH0_DOMAIN]/oauth/token |
| ログアウト | https://[AUTH0_DOMAIN]/v2/logout |

他のIdPでは試せていないが、各IdPのAPI仕様にしたがって今回ご紹介したソースをカスタマイズすれば、たぶん使えるのかなあと思っている次第。

## Azure AD対応版（追記）

Azure ADと連携する場合は``app.py``を以下のように書き換えるとOK。
Auth0版ソースからの主な変更点は下記３点。

* (1) OAuth2のドメイン
   * Azure ADは ``https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/``配下がOAuth2のエンドポイントとなる
* (2) 各種APIのエンドポイントパスの設定

| 機能 | URL | 補足 |
| --- | --- | --- |
| 認可 | https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize | Auth0と形式は同じ
| アクセストークン取得 | https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token |
| ログアウト | https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/logout | Auth0と形式は同じだが設定パラメータが異なる。[MSのドキュメント](https://docs.microsoft.com/ja-jp/azure/active-directory/develop/v2-protocols-oidc) |

* (3) Auth0にあったid_tokenのパラメータ``nickname``は、Azure ADにはデフォルトでは存在しないので、存在する``name``に変更

```python:app.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os, requests, json, base64
from urllib.parse import urlencode, urlparse, parse_qs
from functools import wraps
from flask import Flask, render_template, request, redirect, session
from oauthlib.oauth2 import WebApplicationClient

# httpでtoken取得可能にするために設定
# (httpsサーバの場合は不要)
os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1'

SESSION_COOKIE_NAME = "oauth-test-flask"

# FlaskのWebサーバ設定
app = Flask(__name__)
app.secret_key = "SecretKey"
app.config["SESSION_COOKIE_NAME"] = SESSION_COOKIE_NAME
app.debug = True

# ログイン判定に使用
PROFILE_KEY = 'profile'

PROTOCOL = "https"
SERVER_NAME_PORT = "[サーバのIP]:[ポート]"

# Azure ADテスト用
OAUTH2_DOMAIN = "login.microsoftonline.com/{tenant_id}/oauth2/v2.0"
OAUTH2_CLIENT_ID = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
OAUTH2_CLIENT_SECRET = "yyyyyyyyyyyyyyyyyyyyyyyyyyyy"
OAUTH2_CALLBACK_URL = "{}://{}/callback".format(PROTOCOL, SERVER_NAME_PORT)

# WebApplicationClientの作成
oauth = WebApplicationClient(OAUTH2_CLIENT_ID)

def requires_auth(f):
  @wraps(f)
  def decorated(*args, **kwargs):
    if PROFILE_KEY not in session:
      return redirect("{}://{}/".format(PROTOCOL, SERVER_NAME_PORT))
    return f(*args, **kwargs)

  return decorated

# トップページ
@app.route('/')
def index():

  # 認可用のリクエストを作成
  authz_url, authz_headers, authz_body = \
    oauth.prepare_authorization_request(
      'https://{}/authorize'.format(OAUTH2_DOMAIN),
      redirect_url=OAUTH2_CALLBACK_URL,
      scope='openid profile email',
  )

  # トップページに、Auth0の認可用URLへのリンク(authz_urlの値)を貼る
  return render_template('top.html', authz_url=authz_url)

# Auth0での認証後に戻ってくるパス
@app.route('/callback')
def callback():

  # callbackURLのクエリ文字列から、stateの値を取得
  url_parsed = urlparse(request.url)
  qs_d = parse_qs(url_parsed.query)
  state = qs_d['state'][0]

  # トークン取得用のリクエスト作成
  token_url, token_headers, token_body = \
    oauth.prepare_token_request(
      'https://{}/token'.format(OAUTH2_DOMAIN),
      authorization_response=request.url,
      client_secret=OAUTH2_CLIENT_SECRET,
      state=state,
  )

  # アクセストークンを取得
  token_res = requests.post(
    token_url, data=token_body, headers=token_headers
  )

  # レスポンスからJWTの各種情報を取得
  token_res_json = json.loads(token_res.content.decode('utf-8'))

  # JWTからペイロード情報を取得
  jwts = token_res_json['id_token'].split(".")
  # 文字数が4の倍数でないとエラーになるので"="でパディング
  jwts[1] = jwts[1] + '='*(-len(jwts[1])%4)

  # JWTからペイロード情報を取得
  session[PROFILE_KEY] = json.loads(base64.urlsafe_b64decode(jwts[1]).decode('utf-8'))

  # return redirect('/welcome')
  return redirect("{}://{}/welcome".format(PROTOCOL, SERVER_NAME_PORT))

# ログイン後のパス（Auth0で認証完了後でないとアクセスできない）
@app.route('/welcome')
@requires_auth
def welcome():
  return render_template(
    'welcome.html',
    name = session[PROFILE_KEY]['name'],
  )

# ログアウト時の通るパス。最終的にはトップページ(/)に戻る
@app.route('/logout')
def logout():
    session.clear()
    params = {'post_logout_redirect_uri': "{}://{}/".format(PROTOCOL, SERVER_NAME_PORT), 'client_id': OAUTH2_CLIENT_ID }
    return redirect('https://' + OAUTH2_DOMAIN + '/logout?' + urlencode(params))

if __name__ == "__main__":
  app.run(host='0.0.0.0', port=3000)
```