スタンドアロンApplication RouterでRedis on SAP BTP, Hyperscaler Optionを使う

## Redis on SAP BTP, Hyperscaler Optionとは
Redis on SAP BTP, Hyperscaler Optionは、アプリケーションのキャッシュやセッション情報を格納するために使用するサービスです。AWS, Azure, GCPといったハイパースケーラーが提供するRedisキャッシュサービスを、BTPのサービスとして利用できるようになっています。開発者はハイパースケーラーの違いを意識する必要はありません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/2dc2a2c6-a44f-8015-577b-aec001e8d3a9.png)
出典：[What Is Redis, Hyperscaler Option?](https://help.sap.com/docs/redis-hyperscaler-option/redis-on-sap-btp-hyperscaler-option/what-is-redis-hyperscaler-option?locale=en-US)

※以下、この記事ではRedis on SAP BTP, Hyperscaler Optionを"Redis"と記載します。

## スタンドアロンApplication RouterでのRedisの役割
### Application Routerについて
Application RouterはBTPのアプリケーションへユーザがアクセスする際の入り口となるサービスで、ユーザの認証やバックエンドサービスなどへのルーティングを行います。
Application Routerには二種類あります。一つはスタンドアロンのApplication Router（自分で作成する）で、もう一つはSAP Build Work Zoneなどに組み込まれたManaged Approuterです。

スタンドアロンApplication Routerを使用するケースとしては、SAP Build Work Zoneを使用しないケース、およびマルチテナントなアプリケーションを開発するケースなどがあります。

Application Routerについて詳しく知りたい場合は、以下のブログが参考になります。

https://community.sap.com/t5/technology-blogs-by-sap/sap-application-router/ba-p/13393550

### スタンドアロンApplication RouterでのRedisの必要性
私がRedisについて初めて知ったのは、以下のブログがきっかけでした。

https://community.sap.com/t5/technology-blogs-by-sap/401-errors-and-approuter/ba-p/13579267

それによると、
- BTPのアプリケーションでランダムに401（認証）エラーが発生する事象が発生していた
- 調査の結果、Cloud Foundryの[Evacuation](https://docs.cloudfoundry.org/devguide/deploy-apps/app-lifecycle.html#evacuation)（Diago Cellのメンテナンスなどのために、アプリケーションインスタンスを別インスタンスに移行させるプロセス）によってApplication Routerのインスタンスが再作成されたときに、保持していたセッション情報などが失われたと考えられた

ブログでは、Application Routerを使用する場合には以下が必要としています。
- Redisのような状態保持メカニズムを使用することが本番環境では必須。そうしないと、セルがEvacuateされたときにセッションが失われてしまう
- 本番環境ではApplication Routerインスタンスを少なくとも2つ、理想的には3つにスケールする。これにより、セルが更新のためEvacuateされるときに、他のApplication Router（セル）がその情報を引き継ぎ、状態を失うことがないようにすることができる

**本番環境でスタンドアロンApplication Routerを使用するときは、Redisを使うことが必須**ということです。

### Service to Application RouterシナリオでのCSRFトークンを使用したいとき
私がRedisの検証をしてみようと思った直接のきっかけは、CAPで作ったODataサービスにCSRFトークンチェックを追加したいと思ったことでした。CAP自体にはCSRFトークンを扱う仕組みはなく、自前の実装かApplication Routerを使用した実装が紹介されています。

https://cap.cloud.sap/docs/node.js/best-practices#cross-site-request-forgery-csrf-token

Application RouterでCSRFトークンチェックを有効化するには、xs-app.jsonに`"csrfProtection": true`の設定を追加します。

```json
{
  "routes": [
    {
      "source": "^/odata/(.*)$",
      "target": "/odata/$1",
      "destination": "srv-api",
      "csrfProtection": true
    }
  ]  
}
```
しかし、単純にこの設定をしただけだと、PostmanからCSRFトークンを設定しなくてもPOSTリクエストが実行できました。

::: note info
Application Routerに対してリクエストする場合、認証用のトークンは`Authorization`ではなく、`x-approuter-authorization`に設定する必要があります

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/18007025-bfac-5e45-d4cc-9d014042edef.png)
:::

[ドキュメント](https://www.npmjs.com/package/@sap/approuter#csrf-protection)を読んだところ、Service to Application Routerシナリオのシナリオでは、外部（Redis）でセッション管理をしないとCSRFトークンは有効にならないことがわかりました。そこで、Application RouterでRedisを使用してCSRFトークンハンドリングが有効化になることを検証してみようと思いました。

## 検証内容
1. Redis使用前の状態を確認
1. Application RouterでRedisを有効化する
1. Redisに格納されたセッション情報を確認

### 検証環境
BTPのトライアル環境を使用しています。
※トライアル環境ではRedisのサービスインスタンスが1つしか作れませんでした
### コンポーネントの構成

![CAP-Redis.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/f0c6960b-d29e-e02d-c3e8-4ac5428f95e9.png)

### ソースコード
https://github.com/miyasuta/cap-redis

### プロジェクトの構成
```
.
├── app
│   ├── booksui2 // Fiori elements
│   ├── router   // Application Router
│   └── services.cds
├── db
│   ├── data
│   └──data-model.cds
├── srv
│   └── cat-service.cds
└──  package.json
```

/app/router/xs-app.jsonの設定は以下のようになっています。
```json
{
  "welcomeFile": "nsbooksui2/index.html",
  "routes": [
    ...,
    {
      "source": "^/odata/(.*)$",
      "target": "/odata/$1",
      "destination": "srv-api",
      "csrfProtection": true
    },
    {
      "source": "^(.*)$",
      "target": "$1",
      "service": "html5-apps-repo-rt",
      "authenticationType": "xsuaa"
    }
  ]  
}
```

## 検証手順
### 1. Redis使用前の状態を確認
アプリケーションをデプロイし、PostmanからApplication Routerにリクエストを送ってみます。

- OAuth 2.0のトークンを取得します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/c7274c1e-d6b9-4bb1-21df-578de471ad0c.png)

- 取得したトークンを`x-approuter-authorization`ヘッダに"Bearer <トークン>"の形で設定します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/2f35da4b-7951-84fb-02cd-238352fedcdb.png)

- そのままPostリクエストを送信すると、リクエストは成功します。CSRFトークンチェックがされていないということです
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/bb4eabdc-1faa-f374-cd0c-59b570f5e9b5.png)

### 2. Application RouterでRedisを有効化する
以下のステップでRedisを有効化します。

#### 2.1. mta.yamlにRedisのリソースを追加
resourcesセクションに以下の設定を追加します。

```yaml
resources:
- name: cap-redis-redis
  type: org.cloudfoundry.managed-service
  parameters:
    config:
      engine_version: "6.0"
      eviction_policy: noeviction
    service: redis-cache
    service-plan: trial
```

:::note warn
Redisのサービスインスタンスを作成するには数十分かかりました。
:::

#### 2.2. Application RouterでRedisを使用する設定
mta.yamlでApplication Routerのモジュールに以下の設定を追加します。

```yaml
- name: cap-redis
  type: approuter.nodejs
  path: app/router
  properties:
    EXT_SESSION_MGT: |
      {
        "instanceName": "cap-redis-redis", //redisのサービスインスタンス名
        "storageType": "redis",
        "sessionSecret": "G7h2xP8RjWmQ9uV3bLfZ1cDnJ4oB5sY6kMtH0aE2wX9vFi8zK7yCq3R",
        "defaultRetryTimeout": 10000,
        "backOffMultiplier": 10
      }
    SVC2AR_STORE_CSRF_IN_EXTERNAL_SESSION: true
  requires:
  ...
  - name: cap-redis-redis
```

**EXT_SESSION_MGT**
ユーザセッションをRedisで保管するための設定です。設定内容は[ドキュメント](https://www.npmjs.com/package/@sap/approuter#external-session-management)に記載されています。`sessionSecret`はセッションクッキーを生成するためのシークレットで、自分で64文字以上の文字列を作成して設定します。

**SVC2AR_STORE_CSRF_IN_EXTERNAL_SESSION**
Service to Application RouterシナリオでCSRFトークンを使用したい場合、trueを設定します。ブラウザからログインするシナリオの場合、この設定は不要です。

#### 2.3. CSRFトークンチェックが有効化されたことを確認
Postmanからステップ1と同じリクエストを送信すると、403 (Forbidden) エラーになります。CSRFトークンチェックが有効化されたためです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/61e36ae6-eb1b-30b7-5027-4612a44730b1.png)

POSTリクエストを送るには、まずGet（またはHead）リクエストによりCSRFトークンを取得します。`x-csrf-token`ヘッダに`fetch`を指定することで、レスポンスヘッダにトークンが返ります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/9c081c42-d000-87ec-e136-66170662eca2.png)

取得したトークンをPOSTリクエストの`x-csrf-token`ヘッダに設定して実行すると、リクエストは成功します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/35bd253b-0a2d-3eee-8a13-93825c1a18f6.png)

### 3. Redisに格納されたセッション情報を確認
Redisのキャッシュは以下のステップで確認できます。

1. redis-cliをインストール
1. Application RouterにSSHで接続
1. redis-cliでセッション情報を確認
1. セッション情報の中身を確認

#### 3.1. redis-cliをインストール
linux (Ubuntu) の場合、以下のコマンドでインストールできます。

```
sudo apt-get install redis-tools
redis-cli --version //インストールされたことを確認
```

#### 3.2. Application RouterにSSHで接続
[ドキュメント](https://help.sap.com/docs/redis-hyperscaler-option/redis-on-sap-btp-hyperscaler-option/access-redis-cache-instance-from-redis-cli)を参考に、Application RouterにSSHで接続します。

- SSHを有効化
```
cf enable-ssh <app name> //SSHを有効化
cf restart <app name>    //アプリケーションを再起動
```
- Reidsインスタンスのサービスキーを作成して表示
```
cf service-key <instance name> <key name>
```
- SSHトンネルを設定（以下ではローカルポート6666を使用）
```
cf ssh -L 6666:<instance hostname>:<instance port> <host app name>

例）
cf ssh -L 6666:master.rg-00b26854-749a-47eb-a6d1-41f6ecb16c9d.9u04ff.use1.cache.amazonaws.com:1460 cap-redis
vcap@740f7311-ba59-4946-66d6-52cc:~$
```

- 新規のターミナルを立ち上げ、Redisのインスタンスに接続
```
redis-cli --tls -c -p 6666 -a <instance password>
127.0.0.1:6666> 
```

#### 3.3. redis-cliでセッション情報を確認
Redisに格納されたキーの一覧を確認します。Postmanから接続したセッションが`{external-session}`として登録されています。
```
KEYS *
1) "{external-sessions}:91006ab733d64a139d8ecf637c258320"
```
※UIから接続した場合は、`{approuter-sessions}`で始まるキーが登録されます。
```
1) "{approuter-sessions}:ktS8iAaHA3NNjRojQET6gDODc_KEA9nV"
```

キーに対応する値を取得します。
```
GET <キー値>

例）
GET {external-sessions}:91006ab733d64a139d8ecf637c258320 
"H4sIAAAAAAAEA61WWZOqSBr9Kz283rKKfamIeUBFTYRUUGSZmriRQCprgiwqdNz/3oFVHTHd/XK7Y3ghkvzW8y2HXyn86HBDUHHAbZtWBMTUO6UwNC2iUOK4WOQRwymxjKOzyEkRK8gcS1Mvf1bTHnXaoC6tCPXOSIzICzwtKS9Udu9sfG5wm・・・
```
このままでば解読できないので、ChatGPTにこのデータ形式は何か聞いたところ、以下の回答でした。
>提供されたデータはGzip圧縮されたバイナリデータのようです。これを読める形式にするためには、Gzip圧縮を解凍する必要があります

#### 4.4. セッション情報の中身を確認
ChatGPTの助けを借りて、セッション情報を解凍するスクリプトを作成しました。

```shell:decode_redis_data.sh
#!/bin/bash

# Redisからデータを取得してBase64デコード
compressed_data='3.3で取得した値'
echo $compressed_data | base64 --decode > compressed_data.gz

# Gzip解凍
gzip -d -c compressed_data.gz > decompressed_data.txt

# エスケープされたJSON文字列を読み込み、正しいJSON形式に変換して保存
escaped_json=$(cat decompressed_data.txt)
echo $escaped_json | jq -r . > decompressed_data.json

# 正しいJSON形式のデータを表示
cat decompressed_data.json

# 一時ファイルを削除
rm compressed_data.gz decompressed_data.txt
```

新規ターミナルを立ち上げ、スクリプトを実行します。
```
chmod +x decode_redis_data.sh
./decode_redis_data.sh
```

解凍結果は以下のようになりました。セッション情報としてaccessTokenやxsrf-tokenが保存されていることがわかります。
```json
{
  "externalSessionId": "91006ab733d64a139d8ecf637c258320",
  "externalSessionExpiration": 1716454079,
  "jwtRefreshStarted": true,
  "user": {
    "userId": "n/a",
    "name": "n/a",
    "token": {
      "accessToken": "XXXX",
      "expiryDate": 1718129515764382,
      "oauthOptions": {
        "tenantmode": "dedicated",
        "sburl": "https://internal-xsuaa.authentication.us10.hana.ondemand.com",
        "subaccountid": "75ba9a19-b434-4743-89e6-4a9d9ab3e5e8",
        "credential-type": "instance-secret",
        "clientid": "XXXX",
        "xsappname": "cap-redis-a2c7c84etrial-dev!t266030",
        "clientsecret": "XXXX",
        "serviceInstanceId": "29fb9fcd-376f-4631-ad8f-f250913c757a",
        "url": "https://a2c7c84etrial.authentication.us10.hana.ondemand.com",
        "uaadomain": "authentication.us10.hana.ondemand.com",
        "verificationkey": "-----BEGIN PUBLIC KEY-----\nXXXX\n-----END PUBLIC KEY-----",
        "apiurl": "https://api.authentication.us10.hana.ondemand.com",
        "identityzone": "a2c7c84etrial",
        "identityzoneid": "75ba9a19-b434-4743-89e6-4a9d9ab3e5e8",
        "tenantid": "75ba9a19-b434-4743-89e6-4a9d9ab3e5e8",
        "zoneid": "75ba9a19-b434-4743-89e6-4a9d9ab3e5e8"
      }
    },
    "tenantid": "75ba9a19-b434-4743-89e6-4a9d9ab3e5e8",
    "scopes": [
      "uaa.resource"
    ],
    "tenant": "a2c7c84etrial"
  },
  "xsrf": {
    "token": "05a99e23a2e36bf7-dSPc2omar80L9EWLRQJ4rZQ5eAI",
    "secret": "gUasKun6U8eMapRThKpyIbNWX5HF3h1juEIH8P7ynos"
  }
}
```

## まとめ
- スタンドアロンApplication Routerを本番環境で使用する場合、Redisを使用する
- Application RouterでRedisを使用するには、環境変数`EXT_SESSION_MGT`を設定する
- Service to Application RouterシナリオでCSRFトークンを使用したい場合は、上記に加えて`SVC2AR_STORE_CSRF_IN_EXTERNAL_SESSION`も設定する