nginx を JavaScript でカスタマイズ : OpenID Connect リバースプロキシ

nginx を用いたリバースプロキシ型の OpenID Connect（OIDC）ユーザ認証を、ローカルPCの開発環境向けに構築したので紹介します。

nginx の [njs（NGINX JavaScript）](https://nginx.org/en/docs/njs/) を用いて OIDC 認可コードフローを実装しました。njs は nginx のモジュールです。JavaScript で nginx の機能を拡張できます。公式の nginx docker イメージでも利用できます。

nginx + JavaScript の組み合わせは、本番間環境により近い構成にできる自由度の高さがメリットです。


# リバースプロキシ型とは
ユーザー認証をリバースプロキシと認可サーバに任せます。アプリケーションはビジネスロジックの開発に集中できます。


```mermaid
flowchart LR
  Browser -->|セッションCookie| RP
  Browser -->|ログイン| IDP
  IDP -->|トークン, ユーザ情報| RP
  RP -->|ユーザ情報| WEB
  Browser[ブラウザ]
  subgraph Docker
    RP[リバースプロキシ]
    IDP[認可サーバ]
    WEB[WEBサーバ]
  end
```

リバースプロキシは、ブラウザからの HTTP リクエストをバックエンドの WEBサーバへ転送しますがその際、HTTPリクエストのセッション Cookie を検証します。

セッション Cookie が存在しない場合、リバースプロキシは、認可サーバへリダイレクトしユーザにログインを促します。ユーザがログインするとリバースプロキシへリダイレクトバックされるので、認可サーバからトークンを取得しそのトークンを用いてユーザ情報を取得するとともにセッション Cookie を作成します。

セッション Cookie が存在する場合、リバースプロキシは、HTTPリクエストヘッダにユーザ情報を付加し WEBサーバへ転送します。

リバースプロキシ型について詳しくは次の記事などを参照ください。

https://atmarkit.itmedia.co.jp/ait/articles/1810/22/news011.html


# njs で実装した機能
njs を用いると、WEBサーバが HTTP リクエストハンドラを呼び出すのと似た感じで nginx から JavaScript 関数を呼び出すことができます。

ただし njs は、nginx をアプリケーション サーバーにすることが目的ではないとのこと ↓
- [NGINX JavaScript Is Not Node.js](
https://www.nginx.com/blog/harnessing-power-convenience-of-javascript-for-each-request-with-nginx-javascript-module/#NGINX-JavaScript-Is-Not-Node.js)


今回は、njs で次の機能を実装しました。

- OIDC 認可コードフローでトークン取得
- 認可サーバからユーザ情報を取得し JWT に変換後、セッション Cookie に格納
- セッション Cookie の JWT を検証

:::note warn
注意
OIDC の state や nonce といった脆弱性対策は実装していません
:::


# 動作環境
Dockerコンテナで環境構築します。

- Docker version 23.0
- 認可サーバ： Keycloak 21.0
- リバースプロキシ： nginx 1.23
- WEBサーバ： nginx 1.23



# 実行方法
次のフォルダ構成で各種の設定ファイルと njs の JavaScript コードを配置し docker compose を実行します。設定ファイルおよびコードの全文は以降に掲載します。


フォルダ構成
```
.
├── backend/                      # WEBサーバ
│   └── nginx.conf
├── docker-compose.yml
├── keycloak/                     # 認可サーバ
│   └── import/
│       └── realm-develop.json    # Keycloak の Realm エクスポートファイル
└── proxy/                        # リバースプロキシ
    ├── conf.d/
    │   └── app.conf
    ├── nginx.conf
    └── njs/                      # njs の JavaScript コード
        ├── jwt.js
        └── oidc.js
```



```yaml:docker-compose.yml
version: "3.9"
services:
  keycloak:
    image: quay.io/keycloak/keycloak:21.0
    container_name: idp
    restart: always
    command: start-dev --import-realm
    environment:
      KEYCLOAK_ADMIN: "admin"
      KEYCLOAK_ADMIN_PASSWORD: "admin"
      KC_HOSTNAME_URL: "http://localhost:8080"
      KC_HOSTNAME_STRICT_BACKCHANNEL: false
    ports:
      - 0.0.0.0:8080:8080
    volumes:
      - ./keycloak/import:/opt/keycloak/data/import/:ro
  proxy:
    container_name: proxy
    image: nginx:1.23
    restart: always
    environment:
      OIDC_ISSUER: "http://localhost:8080/realms/develop"
      OIDC_AUTH_ENDPOINT: "http://localhost:8080/realms/develop/protocol/openid-connect/auth"
      OIDC_LOGOUT_ENDPOINT: "http://localhost:8080/realms/develop/protocol/openid-connect/logout"
      OIDC_TOKEN_ENDPOINT: "http://idp:8080/realms/develop/protocol/openid-connect/token"
      OIDC_USER_INFO_ENDPOINT: "http://idp:8080/realms/develop/protocol/openid-connect/userinfo"
      OIDC_CLIENT_ID: "reverse-proxy"
      OIDC_CLIENT_SECRET: "hogehogehoge"
      OIDC_SCOPE: "openid" # 空白文字区切り
      JWT_GEN_KEY: "mySecretKey"
    ports:
      - 0.0.0.0:80:80
    volumes:
      - ./proxy/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./proxy/conf.d:/etc/nginx/conf.d/:ro
      - ./proxy/njs:/etc/nginx/njs/:ro
  backend:
    container_name: backend
    image: nginx:1.23
    volumes:
      - ./backend/nginx.conf:/etc/nginx/nginx.conf:ro
```
コンテナを開始します。
```
$ docker compose up -d
```

```
$ docker compose ps
NAME      IMAGE                           ...  SERVICE   ...  PORTS
backend   nginx:1.23                      ...  backend   ...  80/tcp
idp       quay.io/keycloak/keycloak:21.0  ...  keycloak  ...  0.0.0.0:8080->8080/tcp, 8443/tcp
proxy     nginx:1.23                      ...  proxy     ...  0.0.0.0:80->80/tcp
```

コンテナを開始すると Keycloak の初期化処理が走るのでそれが終わるまで１分ほど待ちます。

ブラウザで http://localhost/ を開くと Keycloak のログイン画面が表示されます。初回はユーザ登録（セルフサインアップ）します。ログインするとバックエンドのWEBサーバ nginx のウェルカムページが表示されます。

![qt_1_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2629684/9dba434b-de2d-c86c-10a3-049fc62ec40d.png)

ブラウザの開発ツールで、セッション Cookie `MY_SESSION` が作成されていることを確認。

![qt_1_9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2629684/f9b30787-ff4a-643f-5955-38f775f777cd.png)


WEBサーバ nginx（backendコンテナ）のログで、HTTPリクエストヘッダにユーザ情報（JWT）が付加されていることを確認。ログの `eyJ0eX ... CnARo` の部分がユーザ情報の JWT です。

```shell_session
$ docker compose logs backend
backend  | 192.168.208.2 - - [05/Apr/2023:09:42:58 +0000] "GET / HTTP/1.0" 200 615 "-" "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI1YjhiNWZjZS1jN2JjLTQ2NmMtOTJlMy0wOTQxYTc0NGJmNzkiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiJhIGIiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJkZXZAZXhhbXBsZS5jb20iLCJnaXZlbl9uYW1lIjoiYSIsImZhbWlseV9uYW1lIjoiYiIsImVtYWlsIjoiZGV2QGV4YW1wbGUuY29tIn0.Hhl9aBQznkfQTjjShyR1-5kkxYFvdXtKVAQgXjCnARo"
```


:::note info
サイト [JSON Web Tokens - jwt.io](https://jwt.io/) で JWT の中身を確認できます。漏洩しても問題が無いデータでお試しください。

![qt_1_10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2629684/e5502dd0-fd92-80f2-2990-7370d2eec1ed.png)
:::


ブラウザで http://localhost/logout を叩くとログアウトできます。

![qt_1_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2629684/087e2df4-fe5a-20ed-e8c5-ce4508bc0904.png)


# リバースプロキシ nginx の設定

`proxy/nginx.conf` に次の設定を追加します。
- njs モジュールをロード
- `conf.d/app.conf` をインクルード

```nginx:proxy/nginx.conf
user  nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log notice;
pid        /var/run/nginx.pid;

load_module modules/ngx_http_js_module.so;      # njs モジュールをロード

events {
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;

    include /etc/nginx/conf.d/app.conf;     # conf.d/app.conf をインクルード
}
```

`proxy/conf.d/app.conf` に次を設定します。
- Docker resolver
- インポートするJSモジュールのパス追加
- インポートするJSモジュール
- nginx 変数に JS関数の戻り値を設定
- `server` セクションで URLパスと JSモジュール関数を紐づけ

```nginx:proxy/conf.d/app.conf
resolver 127.0.0.11;                # Docker resolver

js_path "/etc/nginx/njs/";          # インポートするJSモジュールのパス追加
js_import oidc from oidc.js;        # インポートするJSモジュール

js_set $user_info oidc.user_info;   # nginx 変数に JS関数の戻り値を設定

server {
    listen 80;

    location / {
        auth_request /auth/validate;                   # /auth/validate でログイン有無を確認
        error_page 401 = @login;                       # 未ログインなら @login へ
        expires -1;
        proxy_set_header X-Amzn-Oidc-Data $user_info;  # ヘッダーにユーザ情報を付加
        proxy_pass http://backend;                     # WEBサーバへ転送
    }

    location /auth/validate {
        internal;
        js_content oidc.validate;           # JSモジュール oidc の関数 validate を呼び出し
    }

    location @login {
        js_content oidc.login;              # JSモジュール oidc の関数 login を呼び出し
    }

    location /auth/postlogin {
        js_content oidc.postlogin;          # JSモジュール oidc の関数 postlogin を呼び出し
    }

    location /logout {
        js_content oidc.logout;             # JSモジュール oidc の関数 logout を呼び出し
    }

    location /auth/postlogout {
        js_content oidc.postlogout;         # JSモジュール oidc の関数 postlogout を呼び出し
    }

    location @bye {
        add_header 'Content-Type' 'text/html';
        return 200 "See you again.\n";
    }
}
```

ロケーション `/` にマッチすると `/auth/validate` にログイン有無を確認しにいきます。`/auth/validate` は、JS関数 `oidc.validate()` を呼び出します。`oidc.validate()` は、セッション Cookie を調べログイン済みなら HTTPステータス 200 を返し、未ログインなら 401 を返します。ステータス 200 ならヘッダーにユーザ情報を付加しリクエストをバックエンドへ転送します。401 なら `@login` へ内部リダイレクトし JS関数 `oidc.login()` を呼び出しログインシーケンスを開始します。ログインシーケンスは **OIDC 認可コードフロー**に基づきます。

![qt_1_11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2629684/e561b4e3-4c18-af25-dbfb-d21db82f08b9.png)

:::note info
`@` で始まるロケーションは、nginx の名前付きロケーションです。URIパスの書き換えを行わない内部リダイレクトに利用します。
:::


# リバースプロキシ nginx の JS関数
ロケーション `/auth/validate` に紐づく JS関数 `oidc.validate()` は、セッション Cookie に格納された JWT の署名を検証します。検証に成功すれば HTTPステータス 200 を、失敗すれば 401 を返します。

```mermaid
flowchart TB
  root["/"] -->|ログイン確認| validate
  subgraph validate["/auth/validate"]
    A["oidc.validate()"]
  end
  subgraph login["@login"]
    B["oidc.login()"]
  end
  A -->|200| back["バックエンドへ"]
  A -->|401| login
```

- JS関数 `oidc.validate()`
```javascript:proxy/njs/oidc.js（抜粋）
// ログイン済みか否かをチェック
async function validate(r) {
    let secret_key = process.env.JWT_GEN_KEY;
    let session_data = r.variables.cookie_MY_SESSION;
    let valid = await jwt.verify(session_data, secret_key);
    if( valid ) {
        r.return(200);
    } else {
        r.return(401);
    }
}
```
ここで関数 `validate(r)` の引数 `r` は、HTTPリクエストを表すオブジェクトです。`process` は、プロセスに関する情報を提供するグローバル オブジェクトです。

リファレンスはこちら↓

https://nginx.org/en/docs/njs/reference.html#http

`process.env.JWT_GEN_KEY` は、環境変数 `JWT_GEN_KEY` の値を取得します。

:::note info
リファレンスの注釈に次のようにありますが、動作確認した環境では env ディレクティブを使用せずとも環境変数を参照できました。理由は不明。
> デフォルトでは、nginx は親プロセスから継承されたすべての環境変数を削除しますが、TZ 変数は除きます。継承された変数の一部を保持するには、 env ディレクティブを使用します。

:::


`r.variables.cookie_MY_SESSION` は [nginx の組み込み変数](https://nginx.org/en/docs/http/ngx_http_core_module.html#var_cookie_)です。Cookie `MY_SESSION` の値を参照します。このように JSコード内で nginx の組み込み変数を参照することもできます。

ロケーション `@login` に紐づくJS関数 `oidc.login()` は、認可サーバ（Keycloak）の認可エンドポイントへリダイレクトします。認可サーバは、ログイン画面を表示します。

- JS関数 `oidc.login()`
```javascript:proxy/njs/oidc.js（抜粋）
const postlogin_uri = "http://localhost:80/auth/postlogin";


// OIDC認可エンドポイントへリダイレクト
function login(r) {
    let referer = r.variables.uri;
    let params = qs.stringify({
        response_type : "code",
        scope         : process.env.OIDC_SCOPE,
        client_id     : process.env.OIDC_CLIENT_ID,
        redirect_uri  : postlogin_uri + "?" + qs.stringify({p: referer}),
    });
    let url = process.env.OIDC_AUTH_ENDPOINT + "?" + params;
    r.return(302, url);
}
```

ユーザがログインすると、認可エンドポイントへのリクエストパラメータに指定した `redirect_uri`（= /auth/postlogin）へリダイレクトバックされ、JS関数 `oidc.postlogin()` が呼び出されます。

```mermaid
flowchart TB
  root["/"] -->|ログイン確認| validate
  subgraph validate["/auth/validate"]
    A["oidc.validate()"]
  end
  subgraph login["@login"]
    B["oidc.login()"]
  end
  A -->|200| back["バックエンドへ"]
  A -->|401| login
  B --> G
  subgraph Keycloak
    G["認可エンドポイント"]
  end
  subgraph postlogin["/auth/postlogin"]
    C["oidc.postlogin()"]
  end
  G --> postlogin
```

JS関数 `oidc.postlogin()` は、認可サーバのトークンエンドポイントから IDトークンを取得し検証します。そのトークンを用いてユーザ情報を取得し JWT へ変換します。JWT をセッション Cookie に設定し、呼び出し元ページへリダイレクトします。

- JS関数 `oidc.postlogin()`
```javascript:proxy/njs/oidc.js（抜粋）
// ログイン後の処理
async function postlogin(r) {
    try {
        let referer = r.args.p;

        // OIDCトークン取得
        let redirect_uri = postlogin_uri + "?" + qs.stringify({p: referer});
        let tokens = await get_token(r.args.code, redirect_uri);

        // IDトークン検証
        validate_id_token(tokens.id_token, process.env.OIDC_ISSUER, process.env.OIDC_CLIENT_ID);

        // OIDCユーザ情報取得
        let claims = await get_userinfo(tokens.access_token);

        // ユーザ情報を JWT に変換
        let secret_key = process.env.JWT_GEN_KEY;
        let session_data = await jwt.encode(claims, secret_key);

        r.headersOut["Set-Cookie"] = [
            "MY_SESSION=" + session_data + "; Path=/",
        ];
        r.return(302, referer);
    }  catch (e) {
        r.error(e.message);
        r.return(403);  // Forbidden
    }
}
```

:::note warn
注意
IDトークンの検証は、一部のクレームのみを対象とし、署名は検証していません。

```javascript:proxy/njs/oidc.js（抜粋）
// OIDC IDトークン検証
function validate_id_token(token, issuer, audience) {
    let payload = jwt.decode(token).payload;
    if( payload.iss != issuer ) throw new Error("invalid token");
    if( payload.aud != audience ) throw new Error("invalid token");
    if( payload.exp < Math.floor(Date.now()/1000) ) throw new Error("invalid token");
}
```
:::

:::note warn
セッション Cookie に格納する JWT は暗号化していません。
暗号化したい場合は njs のサンプルコードに暗号化の例があります。
:::

- <b>njs サンプルコード</b>

https://github.com/nginx/njs-examples#webcrypto-aes-gsm-misc-aes-gsm


ログアウトの際は、認可サーバ Keycloak のセッションも併せて終了する必要があります。

ロケーション `/logout` に紐づく JS関数 `oidc.logout()` は、Keyclok のログアウトエンドポイントへリダイレクトします。Keycloak がログアウトされるとロケーション`/postlogout` へリダイレクトバックされるので JS関数 `oidc.postlogout()` でセッション Cookie を削除します。

```mermaid
flowchart LR
  subgraph logout["/logout"]
    A["oidc.logout()"]
  end
  A --> O
  subgraph Keycloak
    O["ログアウトエンドポイント"]
  end
  subgraph postlogout["/auth/postlogout"]
    B["oidc.postlogout()"]
  end
  O --> postlogout
  B -->|Cookie削除| X["@bye"]
```

- JS関数 `oidc.logout()`
```javascript:proxy/njs/oidc.js（抜粋）
// ログアウトエンドポイントへリダイレクト
function logout(r) {
    let params = qs.stringify({
        client_id : process.env.OIDC_CLIENT_ID,
        post_logout_redirect_uri : postlogout_uri,
    });
    let url = process.env.OIDC_LOGOUT_ENDPOINT + "?" + params;
    r.return(302, url);
}
```
- JS関数 `oidc.postlogout()`
```javascript:proxy/njs/oidc.js（抜粋）
// ログアウト後の処理. セッション Cookie 削除
function postlogout(r) {
    r.headersOut['Set-Cookie'] = ["MY_SESSION=; Path=/; Max-Age=-1; Expires=Wed, 21 Oct 2015 07:28:00 GMT"];
    r.internalRedirect("@bye");
}
```


# バックエンド nginx の設定
バックエンドのWEBサーバやアプリケーションサーバは、リバースプロキシが HTTPリクエストヘッダに付加したユーザ情報（JWT）を見て、それが誰からのリクエストなのかを識別し、リソースのアクセス制御を行うことができます。

ここではそこまで実装しません。HTTPリクエストヘッダに付加されたユーザ情報（JWT）をログ出力するまでにとどめます。

`backend/nginx.conf` に次を設定します。

- `log_format` にHTTPリクエストヘッダの出力を追加

```nginx;backend/nginx.conf
user  nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log notice;
pid        /var/run/nginx.pid;

events {
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    # ユーザ情報（ヘッダ X-Amzn-Oidc-Data）をログへ出力
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_x_amzn_oidc_data"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;

    include /etc/nginx/conf.d/*.conf;
}
```


ここでは、ユーザ情報（JWT）をヘッダ X-Amzn-Oidc-Data に格納しているので `log_format` に `'"$http_x_amzn_oidc_data"'` を追加します。

ログ出力例
```shell_session
$ docker compose logs backend
backend  | 192.168.208.2 - - [05/Apr/2023:09:42:58 +0000] "GET / HTTP/1.0" 200 615 "-" "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI1YjhiNWZjZS1jN2JjLTQ2NmMtOTJlMy0wOTQxYTc0NGJmNzkiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiJhIGIiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJkZXZAZXhhbXBsZS5jb20iLCJnaXZlbl9uYW1lIjoiYSIsImZhbWlseV9uYW1lIjoiYiIsImVtYWlsIjoiZGV2QGV4YW1wbGUuY29tIn0.Hhl9aBQznkfQTjjShyR1-5kkxYFvdXtKVAQgXjCnARo"
```

:::note info
このヘッダ名にした理由が気になる方は、巻末の参考記事「Application Load Balancer を使用してユーザーを認証する」をご覧ください。
:::


# Keycloak の Realm エクスポートファイルの準備
リバースプロキシが認可サーバからトークンを取得する際にクライアント ID とクライアントシークレットが必要ですが、そのためには Keycloak の Realm にアプリケーションクライアントを登録する必要があります。

コンテナを開始／終了する度にアプリケーションクライアントを登録しなおすのは面倒なので、事前定義した Realm をインポートするようにします。

:::note warn
注意
クライアントシークレットの安全な取り扱いについては考慮しません。
:::


手順
1. Realm 作成
1. アプリケーションクライアント登録
1. セルフサインアップ有効化
1. Realm エクスポート
1. クライアントシークレット設定
1. Realm エクスポートファイル配置

Realm エクスポートファイル `keycloak/import/realm-develop.json` が存在しない状態で Docker Compose を実行しコンテナを開始します。

```shell_session
$ docker compose up -d
```

Keycloak 初期化処理が終わるまで１分ほど待ちます。ブラウザから http://localhost:8080/ を開くと Keycloak のウェルカムページが表示されるので [Administration Console] を選択し admin/admin でログインします。

![qt_1_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2629684/d0121fd7-095f-9ead-63c1-689e32996b6a.png)


### Realm 作成
Realm "develop" を作成します。

画面左上の master と表示されているプルダウンから Create Realm を選択。
Realm name に "develop" と入力し Create する。

![qt_1_4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2629684/921fce5a-94d0-d45b-d031-ba1b5b37ebb6.png)


### アプリケーションクライアント登録
作成したRealm "develop" にアプリケーションクライアント "reverse-proxy" を登録します。

サイドメニューから Clients を選択し Creat Client をクリック。

![qt_1_5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2629684/d514b881-df92-9deb-24a7-1a10051906ed.png)


必須入力項目
| Field | Value |
| --- | --- |
| Client ID | reverse-proxy |
| Client authentication | ON |
| Valid redirect URIs | * |
| Valid post logout redirect URIs | * |

:::note warn
注意
上記の redirect URIs の設定値は全てを許容する脆弱な設定です。
:::

### セルフサインアップ有効化
ユーザーのセルフサインアップを許可します。

サイドメニューから Realm settings を選択。
タブ Login を開き User registration を ON。

![qt_1_6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2629684/be44af50-2fec-8e8f-36ca-ea437e3a637b.png)

### Realm エクスポート
作成した Realm "develop" をファイルにエクスポートします。

画面右上の Action プルダウンから Partial Export を選択。

![qt_1_6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2629684/be44af50-2fec-8e8f-36ca-ea437e3a637b.png)


Include clients を ON にし Export を実行。

![qt_1_7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2629684/e51be4d0-8c24-a97d-6b69-7e8c3da1a1ca.png)


### クライアントシークレット設定
エクスポートされたファイルにクライアントシークレットは出力されないので、手動でクライアントシークレットを書き込みます。

保存した Realm エクスポートファイルをエディタで開く。
clientId "reverse-proxy" を検索し secret に "hogehogehoge" と入力し保存。

![qt_1_8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2629684/fb5c7107-45f6-7f2e-585c-7627ed3773ad.png)


### Realm エクスポートファイル配置
Realm エクスポートファイルを `keycloak/import/realm-develop.json` に配置します。

Docker Compose を再起動すると Realm エクスポートファイルがインポートされます。

```shell_session
$ docker compose down -v

$ docker compose up -d
```

コンテナ keycloan のログにインポートした旨のメッセージが表示されます。
```
$ docker compose logs keycloak

... Full importing from file /opt/keycloak/bin/../data/import/realm-develop.json
... Realm 'develop' imported
```

Realm のインポート／エクスポートについては、Keycloak のドキュメントを参照ください ↓

https://www.keycloak.org/server/importExport


# njs コード全文

## proxy/njs/jwt.js
```javascript:proxy/njs/jwt.js
// JWTデコード
function decode(jot) {
    var parts = jot.split('.').slice(0,2)
        .map(v=>Buffer.from(v, 'base64url').toString())
        .map(JSON.parse);
    return { headers:parts[0], payload: parts[1] };
}

// JWTエンコード
async function encode(claims, key) {
    let header = { typ: "JWT",  alg: "HS256" };

    let s = [header, claims]
        .map(JSON.stringify)
        .map(v=>Buffer.from(v).toString('base64url'))
        .join('.');

    let wc_key = await crypto.subtle.importKey(
        'raw', key, {name: 'HMAC', hash: 'SHA-256'}, false, ['sign']);

    let sign = await crypto.subtle.sign({name: 'HMAC'}, wc_key, s);

    return s + '.' + Buffer.from(sign).toString('base64url');
}

// JWT署名検証
async function verify(jot, key) {
    if( !jot ) return false;

    let parts = jot.split('.');
    let data = parts.slice(0,2).join('.');
    let sign = Buffer.from(parts[2], 'base64url');
    let wc_key = await crypto.subtle.importKey(
        'raw', key, {name: 'HMAC', hash: 'SHA-256'}, false, ['verify']);

    return crypto.subtle.verify({name: 'HMAC'}, wc_key, sign, data);
}

export default {decode, encode, verify}
```


## proxy/njs/oidc.js
```javascript:proxy/njs/oidc.js
import qs from "querystring";
import jwt from "jwt.js";

const postlogin_uri = "http://localhost:80/auth/postlogin";
const postlogout_uri = "http://localhost:80/auth/postlogout";

// OIDC IDトークン検証
function validate_id_token(token, issuer, audience) {
    let payload = jwt.decode(token).payload;
    if( payload.iss != issuer ) throw new Error("invalid token");
    if( payload.aud != audience ) throw new Error("invalid token");
    if( payload.exp < Math.floor(Date.now()/1000) ) throw new Error("invalid token");
}

// OIDCトークンエンドポイントからトークン取得
async function get_token(code, redirect_uri) {
    let reply = await ngx.fetch(process.env.OIDC_TOKEN_ENDPOINT, {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: qs.stringify({
            grant_type    : "authorization_code",
            client_id     : process.env.OIDC_CLIENT_ID,
            client_secret : process.env.OIDC_CLIENT_SECRET,
            redirect_uri  : redirect_uri,
            code          : code,
        }),
    });
    return await reply.json();
}

// OIDCユーザ情報取得
async function get_userinfo(access_token) {
    let reply = await ngx.fetch(process.env.OIDC_USER_INFO_ENDPOINT, {
        method: "GET",
        headers: { "Authorization": "Bearer " + access_token },
    });
    return await reply.json();
}

// ログイン済みか否かをチェック
async function validate(r) {
    let secret_key = process.env.JWT_GEN_KEY;
    let session_data = r.variables.cookie_MY_SESSION;
    let valid = await jwt.verify(session_data, secret_key);
    if( valid ) {
        r.return(200);
    } else {
        r.return(401);
    }
}

// OIDC認可エンドポイントへリダイレクト
function login(r) {
    let referer = r.variables.uri;
    let params = qs.stringify({
        response_type : "code",
        scope         : process.env.OIDC_SCOPE,
        client_id     : process.env.OIDC_CLIENT_ID,
        redirect_uri  : postlogin_uri + "?" + qs.stringify({p: referer}),
    });
    let url = process.env.OIDC_AUTH_ENDPOINT + "?" + params;
    r.return(302, url);
}

// ログイン後の処理
async function postlogin(r) {
    try {
        let referer = r.args.p;

        // OIDCトークン取得
        let redirect_uri = postlogin_uri + "?" + qs.stringify({p: referer});
        let tokens = await get_token(r.args.code, redirect_uri);

        // IDトークン検証
        validate_id_token(tokens.id_token, process.env.OIDC_ISSUER, process.env.OIDC_CLIENT_ID);

        // OIDCユーザ情報取得
        let claims = await get_userinfo(tokens.access_token);

        // ユーザ情報を JWT に変換
        let secret_key = process.env.JWT_GEN_KEY;
        let session_data = await jwt.encode(claims, secret_key);

        r.headersOut["Set-Cookie"] = [
            "MY_SESSION=" + session_data + "; Path=/",
        ];
        r.return(302, referer);
    }  catch (e) {
        r.error(e.message);
        r.return(403);  // Forbidden
    }
}

// ログアウトエンドポイントへリダイレクト
function logout(r) {
    let params = qs.stringify({
        client_id : process.env.OIDC_CLIENT_ID,
        post_logout_redirect_uri : postlogout_uri,
    });
    let url = process.env.OIDC_LOGOUT_ENDPOINT + "?" + params;
    r.return(302, url);
}

// ログアウト後の処理. セッション Cookie 削除
function postlogout(r) {
    r.headersOut['Set-Cookie'] = ["MY_SESSION=; Path=/; Max-Age=-1; Expires=Wed, 21 Oct 2015 07:28:00 GMT"];
    r.internalRedirect("@bye");
}

// バックエンドへ送るユーザ情報
function user_info(r) {
    return r.variables.cookie_MY_SESSION;
}

export default {validate, login, logout, postlogin, postlogout, user_info}
```



# 参考にした記事

https://nginx.org/en/docs/njs/index.html

https://github.com/nginx/njs-examples

https://openid.net/specs/openid-connect-core-1_0.html

https://thinkit.co.jp/article/17559

https://tech.jxpress.net/entry/2018/08/23/104123

https://atmarkit.itmedia.co.jp/ait/articles/1810/22/news011.html

https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/application/listener-authenticate-users.html