Docker + Keycloak + FastAPIを用いたローカル OAuth2.0 Authorization Code Grant 検証環境構築 メモ


* 学習目的でKeycloakを使ったAuthorization Code Grantを検証するための環境を構築したので、メモとして残しておく。
* アクセストークンを取得するところまで。

## 構成

* Dockerを用いた以下の構成で検証環境を構築する。

![fastapi_keycloak_mysql.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586535/b888bccd-0dd3-0d9d-bb32-c7b0db556080.png)


```
keycloak 	___ docker-compose.yml
			|__ app.env
			|__ app 	___ Dockerfile
						|__ requirements.txt
						|__ api	_ main.py
```



## docker-compose.yml

* FastAPI - Keycloak - MySQLコンテナ定義 

```yml
version: "3"

volumes:
  mysql_data:
    driver: local

services:
  mysql:
    image: mysql:5.7
    volumes:
      - mysql_data:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: keycloak
      MYSQL_USER: keycloak
      MYSQL_PASSWORD: password
  keycloak:
    image: quay.io/keycloak/keycloak:latest
    container_name: "keycloak"
    environment:
      DB_VENDOR: MYSQL
      DB_ADDR: mysql
      DB_DATABASE: keycloak
      DB_USER: keycloak
      DB_PASSWORD: password
      KEYCLOAK_USER: admin
      KEYCLOAK_PASSWORD: P@ssw0rd
    ports:
      - 8080:8080
    depends_on:
      - mysql
  app:
    container_name: "app"
    env_file: app.env
    build: ./app
    volumes:
      - ./app/api:/usr/src/server
    ports:
      - "8000:8000"
```



### app.env

* 環境変数設定
  * 一度Keycloakコンテナを起動して、登録したクライアント情報を設定する。

```
APP_BASE_URL=http://localhost:8000/
KEYCLOAK_BASE_URL_LOCALHOST=http://localhost:8080/
KEYCLOAK_BASE_URL_CONTAINER_NAME=http://keycloak:8080/
CLIENT_ID=YOUR_CLIENT_ID
CLIENT_SECRET=YOUR_CLIENT_SECRET
REDIRECT_URI=http://localhost:8000/auth/callback
```



### requirements.txt

* Python依存ライブラリ

```
fastapi
requests
uvicorn
```



### Dockerfile

* 依存ライブラリインストールとFastAPI起動オプションを指定。

```dockerfile
FROM python:3.7

WORKDIR /usr/src/server
ADD requirements.txt .
RUN pip install -r requirements.txt

# uvicornのオプションに--reloadを付与し、
# main.pyの編集と同時に変更内容を反映させる。
CMD ["uvicorn", "main:app", "--reload", "--host", "0.0.0.0", "--port", "8000"]
```



### main.py

* 以下を行う。
  * Keycloak Authorization Endpointへのリダイレクト(`login`)
  * Keycloak Authorization Responseの受信+トークンリクエスト(`auth`)

```python
import ast
import urllib.parse as parse
import os
import requests
import uvicorn
from fastapi import FastAPI
from starlette.requests import Request
from starlette.responses import RedirectResponse
import hashlib

# 環境変数取得
# FastAPI アプリ用
APP_BASE_URL = os.getenv("APP_BASE_URL")
APP_CLIENT_ID = os.getenv("CLIENT_ID")
APP_CLIENT_SECRET = os.getenv('CLIENT_SECRET')
APP_REDIRECT_URI = os.getenv('REDIRECT_URI')

# Keycloak用
# Authorization Endpoint
KEYCLOAK_BASE_URL_LOCALHOST = os.getenv("KEYCLOAK_BASE_URL_LOCALHOST")
# "Master"は自身の環境のレルム名を指定する。
AUTH_BASE_URL = (
    f"{KEYCLOAK_BASE_URL_LOCALHOST}auth/realms/Master"
    "/protocol/openid-connect/auth"
)
# Token Endpoint
# コンテナ間通信するため、localhostではなくコンテナ名を指定
# "Master"は自身の環境のレルム名を指定する。
KEYCLOAK_BASE_URL_CONTAINER_NAME = os.getenv(
    "KEYCLOAK_BASE_URL_CONTAINER_NAME")
TOKEN_URL = (
    f"{KEYCLOAK_BASE_URL_CONTAINER_NAME}auth/realms/master"
    "/protocol/openid-connect/token"
)

app = FastAPI()

# Keycloak Authorization Endpointへのリダイレクト
@app.get("/auth/login")
async def login() -> RedirectResponse:
    # ステート生成
    state = hashlib.sha256(os.urandom(32)).hexdigest()
    # Authorization Endpointへリダイレクト
    AUTH_URL = AUTH_BASE_URL + '?{}'.format(parse.urlencode({
        'client_id': APP_CLIENT_ID,
        'redirect_uri': APP_REDIRECT_URI,
        'state': state,
        'response_type': 'code'
    }))
    response = RedirectResponse(AUTH_URL)
    # ステート保存
    response.set_cookie(key="AUTH_STATE", value=state)
    return response


# Token Request
def get_token(code):

    params = {
        'client_id': APP_CLIENT_ID,
        'client_secret': APP_CLIENT_SECRET,
        'grant_type': 'authorization_code',
        'redirect_uri': APP_REDIRECT_URI,
        'code': code
    }
    x = requests.post(TOKEN_URL, params, verify=False).content.decode('utf-8')
    return ast.literal_eval(x)


# Redirection Endpoint
# ステートと認可コードを受け取る。
# ステート検証後、トークンリクエストを実行する。
@app.get("/auth/callback")
async def auth(request: Request, code: str, state: str) -> RedirectResponse:
    # State検証
    if state != request.cookies.get("AUTH_STATE"):
        return {"error": "state_verification_failed"}
    return get_token(code)

if __name__ == "__main__":
    uvicorn.run(app, port=8000, loop="asyncio")

```



## 動作確認

* コンテナを起動する

  ```shell
  docker-compose up
  ```

* Keycloak Admin コンソールにアクセスする。

  ```
  http://localhost:8080
  ```
  ※ログイン情報は`docker-compose`に記載

* クライアント(`Clients`)を登録する。
  ※各種登録情報は、[Keycloak: Authorization Code Grant Example](https://www.appsdeveloperblog.com/keycloak-authorization-code-grant-example/)を参考にする。

* テストユーザー(`Users`)を登録する。

* コンテナを再起動する。

  ```shell
  docker-compose down
  docker-compose build
  docker-compose up
  ```

* Keycloak Authorization Endpointリダイレクト用エンドポイントにアクセスする。

  ```
  http://localhost:8000/auth/login
  ```

* ユーザー認証を行う。

* FastAPI側のリダイレクトURIにリダイレクトされ、ブラウザに以下のようなトークンレスポンスJSONが表示される。

  ```json
  {
      "access_token": "...",
      "expires_in": 60,
      "refresh_expires_in": 1800,
      "refresh_token": "...",
      "token_type": "Bearer",
      "not-before-policy": 0,
      "session_state": "...",
      "scope": "profile email"
  }
  ```

  

## 参考情報

* [Keycloak: Authorization Code Grant Example](https://www.appsdeveloperblog.com/keycloak-authorization-code-grant-example/)
* [fastapi-keycloak-oidc-auth](https://github.com/hkiang01/fastapi-keycloak-oidc-auth)