Keycloakを使ってAPI GatewayでAPIをアクセス制限する

本記事は、「[Keycloakを使ってAPI GatewayでAPIをアクセス制限する](https://qiita.com/rawr/items/abcfa753e1fda43d156c)」の記事を元に、Amazon API Gatewayを使ったアクセス制限（認可）として、クライアントアプリ向けのAPIサーバーをAmazon API GatewayとAWS lamdaで構築して、どのようにアクセス制限が行えるのか検証する。

# 本記事でやること
1. Keycloakサーバ設定
1. Lambda関数作成
1. API Gateway作成
1. 動作検証

# Amazon API Gatewayとは
Amazon API Gatewayは、様々な規模の REST、HTTPなどのAPIを作成、公開、維持、モニタリング、セキュア化できる AWS のフルマネージドサービスのことである。
世間一般的なAPI Gatewayとは何か、については[こちらの記事](https://qiita.com/rawr/items/abcfa753e1fda43d156c#api-gateway%E3%81%A8%E3%81%AF%E4%BD%95%E3%81%8B)で説明されているので本記事では割愛する。

# 今回の構築する構成
以下のような環境を構築する。
認証についてはKeycloakを利用して、
APIのアクセス制御に関してはAPI GatewayのLambda オーソライザー機能を利用してトークン(JWT)の検証処理を行う。

![構成図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333320/0e7eeb5d-65fe-1178-fab2-d57725e56922.png)

# Keycloakサーバ設定
## レルムを作成
Realm nameを`api-gateway`としてレルムを作成する。
![1_レルム作成.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333320/74387718-68bc-dedd-0b9a-b27eb0662540.png)

## ユーザー作成
作成したレルムに所属するユーザーとして、今回は検証用に以下の２つのユーザーを作成する。
| ユーザー名 | 用途 |
|:-:|:-:|
|  testuser1 | ロール未所属のユーザー  |
|  testuser2 | `api-gateway-role`のロールに所属するユーザー  |

![2_ユーザー作成.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333320/7d9abd4c-8819-93af-1c29-9148eca8d76a.png)

## ロール作成＆ユーザー追加
ロール名を`api-gateway-role`としてロールを作成し、作成済みの`testuser2`ユーザーを作成したロールに所属させる。
![3_ロール追加.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333320/b3df8a55-75ee-c085-13e6-868e546994f0.png)


## Keycloakの公開鍵情報取得
左のメニューから`レルムの設定`→`鍵`タブを押下して、公開鍵情報を確認する。`RS256`の`公開`ボタンを押下することで、公開鍵情報を表示される。
![4_公開鍵①_編集.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333320/a8b39666-0350-57c7-ffcd-0d973767da52.png)
![4_公開鍵②.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333320/9e72e063-466a-566c-1929-223053d81a38.png)
公開鍵はLambdaオーソライザーのトークン検証処理の実装時に利用するため、後で確認できるようにメモしておく。

# Lambda関数作成
AWSのLambdaサービスの画面を開き、`関数の作成`ボタンから以下の２つのLambda関数を作成する。ランタイムなどは何でもよいが、今回はNode.jsで作成した。

## API業務処理関数作成
業務用の関数はほぼデフォルトのままとし、以下の通り返却するJson文字列だけ変更した。

```javascript:lamda業務関数
export const handler = async(event) => {
    const response = {
        statusCode: 200,
        body: JSON.stringify('Get resource!!!'),
    };
    return response;
};
```

## オーソライザー用関数作成
オーソライザー関数については[こちら](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html#api-gateway-lambda-authorizer-lambda-function-create)をベースに、トークン検証用に[jsonwebtoken](https://www.npmjs.com/package/jsonwebtoken)を利用する形で実装した。


```javascript:Lambdaオーソライザー関数
import * as jsonwebtoken from 'jsonwebtoken';
export const handler =  function(event, context, callback) {
    // トークンを取得
    var token = event.authorizationToken.replace("Bearer ", "");
    
    // Keycloak側の公開鍵
    const publicKey = "-----BEGIN PUBLIC KEY-----\r\n"
                      + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
                      + "\r\n-----END PUBLIC KEY-----";

    // トークンの検証
    jsonwebtoken.verify(token, publicKey, (err, decoded) => {
        if (err) {
            // 検証NG (403 Error)
            console.log(err);
            callback(null, generatePolicy('user', 'Deny', event.methodArn));
        } else {
            // 検証OK
            var roles = decoded.realm_access.roles
            if(roles.includes('api_gateway_test_role')) { 
                callback(null, generatePolicy('user', 'Allow', event.methodArn));
            } else {
                // 検証NG (403 Error)
                console.log('権限なし');
                callback(null, generatePolicy('user', 'Deny', event.methodArn));
            }   
        }
    });
};

// ポリシーを作成
var generatePolicy = (principalId, effect, resource) => {
    var authResponse = {};
    
    authResponse.principalId = principalId;
    if (effect && resource) {
        var policyDocument = {};
        policyDocument.Version = '2012-10-17'; 
        policyDocument.Statement = [];
        var statementOne = {};
        statementOne.Action = 'execute-api:Invoke'; 
        statementOne.Effect = effect;
        statementOne.Resource = resource;
        policyDocument.Statement[0] = statementOne;
        authResponse.policyDocument = policyDocument;
    }

    return authResponse;
}
```
オーソライザー関数では以下のような処理を行っている。
1. リクエストヘッダから`Bearer`を除去したトークンを取得
1. Keycloakの公開鍵を用いてトークンを検証
1. 検証成功時、デコードしたトークンからroleの情報を取り出して`api_gateway_test_role`のロールを持っているか確認
   ※トークン検証NGの時などは、エラーとして403応答が返却
1. ロール保持時、Invokeの権限を付与したポリシーを作成してコールバック
   ※ロール非保持の場合、権限をDenyに設定してコールバック

:::note info
本記事では公開鍵を直接Lambda関数に埋め込んだが、JWKsエンドをKeycloakで設定することで都度参照するようにすることも可能
:::


# API Gateway作成

## API作成
`REST API`を選択してAPIを作成する。API名以外はデフォルトの設定値のままとする。
![6_API作成.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333320/b3ed47e3-f035-02e4-1c14-0a5a7076eb0a.png)

## リソース＆メソッド作成
API一覧から作成したAPIを選択して、`アクション`ボタン → `リソース作成`ボタンを押下してリソースの作成画面を開く。
![7_リソース作成②.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333320/02c27b21-8618-bfd0-6887-7e4377522006.png)


リソース名を入力して、`リソース作成`ボタンを押下すると、以下のように作成したリソースがAPIに追加される。
![7_リソース作成③.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333320/c9f6b7f2-e9d5-d372-010a-0aad12c2bcd9.png)


次にメソッドを作成する。
作成したリソースを選択した状態で、`アクション`ボタン → `メソッドの作成`ボタンを押下すると、リソースの下にメソッドのセレクトボックスが表示される。`GET`を選択してリストの右にある✓ボタンを押下するとメソッドの設定画面が開かれるので作成したLambda関数等を設定していく。
![7_リソース作成⑤.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333320/3272c607-60d8-28d8-a995-1d889981d0d1.png)

Lamda関数の欄に事前に作成済みの関数名を入力して保存ボタンを押下する。
これでAPIのtest_resourceパスのGETメソッドに作成したLambda関数が統合された。

## APIデプロイ
作成したAPIを外部からアクセスできるようにデプロイを行う。
`アクション`ボタン → `メソッドの作成`ボタンを押下してデプロイするステージを選択する。ステージが未作成の場合は、新規作成選択およびステージ名を入力してからデプロイボタンを押下する。
![8_デプロイ①.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333320/94219ac5-a2d7-b537-9018-a2f5f8461cdf.png)
デプロイすると以下のようにデプロイしたリソースがステージに含まれる。
![8_デプロイ②.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333320/aedfd7d5-b94d-c301-c0f9-0a6395a9f4ed.png)


## テスト実行
ここで作成したAPIを実行して動作確認してみる。現時点ではアクセス制限をしていない状態のため、API Gatewayは単純なプロキシーをする動きのみとなっている。
APIを実行するクライアントとして[Postman](https://www.postman.com/)を利用して、以下のようにtest_resourceパスのGETでリクエストを送信する。

![9‗テスト実行.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333320/bc1f20ef-2a2e-0c7b-5972-8fcf943cb3ec.png)
リクエストにはアクセストークンなど一切設定していないがひとまずAPIの正常疎通が確認できた。

## オーソライザー作成
最後にAPI Gatewayにオーソライザーを作成する。
API Gatewayの左側のメニューから`オーソライザー`を選択して、`新しいオーソライザーの作成`を押下してオーソライザーを作成する。
![オーソライザー①.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333320/e98d7fdc-3a4a-eb80-8df7-bfb00e9e1a55.png)

| 設定項目 | 設定内容 |
|:-:|:-:|
|  名前 | 任意でOK  |
|  Lambda関数 | 事前に作成したオーソライザー用Lambda関数  |
|Lambda呼び出しロール|未設定でOK|
|トークンのソース|トークンとして取得するHTTPヘッダの名称|
|トークン検証|トークンのバリデーション|
|認可のキャッシュ|任意で設定|

:::note info
トークンソースのソースは実際のHTTPヘッダの名称を指定するが、Lambda関数内で取得するときはauthorizationTokenとなる。
:::

続けて作成したオーソライザーをAPIに設定する。
リソースからオーソライザーを設定するメソッドを選択して。`メソッドリクエスト`の部分を押下する。
![オーソライザー②.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333320/3b900ccc-863b-e4e7-be41-ef456e7431ca.png)
メソッドリクエストの設定画面が開くので、`認可`の部分で作成したオーソライザーを設定する。
![オーソライザー③.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333320/7a55766b-481b-b38e-a0ae-3c741c236530.png)
ここまで完了したらオーソライザーを適用したAPIを再度デプロイして準備完了。

# 動作検証
今回作成したAPIは`api_gateway_test_role`を持っているユーザーのみアクセスを許可するようにオーソライザーを設定しているので、対象のロールを持っていないユーザー、持っているユーザーのトークンを用いてAPIを実行し、期待通りに制限できているか検証する。

まず、対象のロールを持っていない`testuser1`で取得したトークンでAPIを実行する。
トークン取得についてはKeycloak標準であるaccount_connsoleなどでログインして取得し、Postmanの`Authorization`ヘッダに付与して実行する。
その結果、以下のように必要なロールを保持していないため認可されず、403応答でレスポンスが返却される。
![API_NG.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333320/ae914e8d-1d47-8b55-1b5c-e5302186837d.png)
Lambdaのログを確認するとしっかり検証失敗時に埋め込んだログが出力されているため、オーソライザーとして作成したLambda関数で検証されていることが確認できる。
![API_NG②.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333320/d731d199-6a4b-b31d-104b-659241266907.png)


次に、対象のロールを持っている`testuser2`で取得したトークンでAPIを実行する。
先ほど同様に取得したトークンをPostmanの`Authorization`ヘッダに付与して実行すると、以下のように必要なロールを保持しているため認可され、200応答でレスポンスが返却される。
![API_OK.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333320/d2e1cb8b-539a-6f3a-22ab-8c81f7a2caa7.png)
以上でAmazon API GatewayとKeycloakを使ってAPIのアクセス制限を確認することができた。

# 最後に
今回は簡易的な検証として、トークン(JWT)にある属性値の一致をみるだけのアクセス制限(認可)でした。実際にOIDCとしての認可ではトークン・インストロスペクション・エンドポイントへトークンを送り、現在アクティブかどうかや権限情報を取得するような動きとすべきかと思います。そのあたりについては機会があればまた検証してみたいと思います。


# 参考資料
- https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide
- https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html
- https://www.npmjs.com/package/jsonwebtoken