nginxコミュニティ版をリバプロとして使うときに、バックエンドにトラフィックを流す前にOAuth2のアクセストークンの中身を参照して認可処理を挟みたい。 Luaのスクリプトを書けば細かいカスタマイズはできそうですが、もう少し慣れたJavaScriptで実現する方法を備忘録として残します。
やりたいこと
実現方式(この記事のモチベーション)
JavaScriptだけでなく他の選択肢も並べてみます。
- JWT認証認可モジュール "ngx_http_auth_jwt_module" を入れる ... 有償版のNginx Plusの機能 ref: Setting up JWT Authentication | NGINX Documentation
- Luaスクリプトを自分で書く
- JavaScritpモジュール "ngx_http_js_module" を入れてJavaScriptを自分で書く
- JWT認証認可機能が入った別のリバプロに切り替える
nginxはコミュニティ版を使用しているので1と4をまず除外。 そうなると自分で認可処理を書かないといけないので、慣れている3.JavaScriptで書くことにしました。
やり方
njs概要
nginx でJavaScriptを扱うモジュールは "ngx_http_js_module" (以下、njs) です。 nginxの多種多様な処理をJavaScriptで実現するためのモジュールです。 njs というモジュール略称と同名の組み込みJavaScriptエンジンでJavaScriptを処理します。 njsはECMAScript 5.1と6の一部と互換性があります。 JavaScriptエンジンは他にもモジュール組み込みのQuickJSにも差し替え可能で、ES2023まで互換性範囲を広げて扱えます。この記事ではJavaScriptエンジンにnjsを使用します。
前提
nginxをリバースプロキシとしてローカルのコンテナをたて、バックエンドに httpbin.org を使用しました。 また、JWTアクセストークンを発行するIDプロバイダーはローカルのKeycloakコンテナを使用します。 使用したコンテナのバージョンは次のとおりです。
| ソフトウェア | バージョン (コンテナイメージ) | 起動ポート |
|---|---|---|
| nginx | docker.io/library/nginx:1.28-alpine |
8081 |
| Keycloak (IdP) | quay.io/keycloak/keycloak:26.3.2 |
8080 |
モジュールのインストール
nginxのモジュールのインストール作業を簡略化するためコンテナイメージを使います。 njsはnginxの公式のコンテナイメージの1.28系には最初から入っています。
RUN set -x \
&& apkArch="$(cat /etc/apk/arch)" \
&& nginxPackages=" \
nginx=${NGINX_VERSION}-r${PKG_RELEASE} \
nginx-module-xslt=${NGINX_VERSION}-r${DYNPKG_RELEASE} \
nginx-module-geoip=${NGINX_VERSION}-r${DYNPKG_RELEASE} \
nginx-module-image-filter=${NGINX_VERSION}-r${DYNPKG_RELEASE} \
nginx-module-njs=${NGINX_VERSION}.${NJS_VERSION}-r${NJS_RELEASE} \
認可処理のJavaScript部分はサンプルがGitHubリポジトリにあったので参考にして書いてみます。
認可処理の実装
プレーンなJavaScriptで記述します。
サンプルは var を使っていたのですが、njsはES5,6と互換性があるので今どきの書き方に合わせて const にしました。
// nginx/njs/authz.js function jwt(data) { const parts = data.split('.').slice(0,2) .map(v=>Buffer.from(v, 'base64url').toString()) .map(JSON.parse); return { headers:parts[0], payload: parts[1] }; } function authorize(r) { // Authorizationヘッダーの "Bearer " 部分を除外してアクセストークンのみ取り出す const payload_scopes = jwt(r.headersIn.Authorization.slice(7)).payload.scope; // 読み取り権限 demo/read がなければ権限エラーとする if (!payload_scopes.split(' ').includes('demo/read')) { r.error('Forbidden: insufficient scope'); r.return(403); return; } r.return(200); } export default {authorize}
nginx設定
認証認可用のリダイレクト処理 auth_request と、内部リダイレクト用のパス定義 internal を駆使しながら設定を書きます。
proxy_pass と js_content は同一ブロックには書けなかったので、/secured-api/* にマッチしたリクエストを /auth に内部リダイレクトして認可処理を走らせて 2xx が返ってきたらバックエンドを通す方式とした。
# njs モジュールを読み込み load_module modules/ngx_http_js_module.so; events { } http { # 認可処理用のJavaScriptを読み込み js_path "/etc/nginx/njs/"; js_import authz.js; server { listen 80; location / { root /usr/share/nginx/html; index index.html index.htm; } location /healthz { return 200; } # 内部リダイレクト先の認可処理エンドポイント location /auth { internal; js_content authz.authorize; } # 保護されたエンドポイント location /secured-api { auth_request /auth; proxy_pass https://httpbin.org/anything; proxy_redirect off; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Authorization ""; } } }
コンテナ実行
Docker/Podman Composeで必要なコンテナを実行します。
compose.yml
services: idp: container_name: idp image: quay.io/keycloak/keycloak:26.3.2 ports: - "8080:8080" command: - start-dev environment: - KC_BOOTSTRAP_ADMIN_USERNAME=admin - KC_BOOTSTRAP_ADMIN_PASSWORD=admin proxy: container_name: proxy image: public.ecr.aws/docker/library/nginx:1.28-alpine ports: - "8081:80" environment: - NGINX_ENTRYPOINT_QUIET_LOGS=1 volumes: - ./nginx/config/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/njs:/etc/nginx/njs
compose.yml を入力としてコンテナを起動します。
$ podman compose up -d ✔ Container idp Started ✔ Container proxy Started
nginxのコンテナのログにnjsが起動していることがわかります。
2025/08/03 03:28:02 [notice] 1#1: js vm init njs: ...
リクエスト実行
Keycloakのアカウントコンソール http://localhost:8080/admin/master/console を開いて事前にレルム、クライアントID、シークレット、スコープを作成しておく。方法はこの記事の範囲を超えるので割愛。
| 項目名 | 設定値 |
|---|---|
| レルム(realms) | demo |
| クライアントID | demo-client |
| クライアントシークレット | Keycloakで自動生成 |
| クライアントスコープ | demo/read (読み取り)、demo/write (書き込み) |
Keycloakのトークンエンドポイントからアクセストークンを取得する。 リクエストパラメータのscopeに読み取り権限 (demo/read) を指定することでアクセストークンにもscopeを含めるようにする。
ACCESS_TOKEN=$(curl -X POST -H 'Content-Type: application/x-www-form-urlencoded' \
-d grant_type=client_credentials \
-d client_id=demo-client \
-d client_secret=***** \
-d 'scope=demo/read' \
http://localhost:8080/realms/demo/protocol/openid-connect/token \
| jq -r '.access_token')
アクセストークンのペイロードをデコードすると scope に demo/read が含まれている。
$ echo $ACCESS_TOKEN | awk -F'.' '{print $2}' | base64 --decode | jq -r '.scope'
profile demo/read email
アクセストークンを含めてnginx経由でバックエンドにAPIを投げてみる。
認可処理で許可したスコープ demo/read が含まれているのでこれは問題なく200 OKでレスポンスが返ってくる。
$ curl -v --oauth2-bearer ${ACCESS_TOKEN} http://localhost:8081/secured-api/hogehoge
...
< HTTP/1.1 200 OK
< Server: nginx
...
{
"args": {},
"data": "",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Host": "localhost",
"User-Agent": "curl/8.15.0",
"X-Amzn-Trace-Id": "Root=...
},
"json": null,
"method": "GET",
"origin": "10.20.33.45",
"url": "https://localhost/anything/hogehoge"
}
scopeに demo/read を含まない別のアクセストークンを指定してみると、設定どおり 403 Forbidden で権限エラーが返されました。
$ curl -v --oauth2-bearer ${ACCESS_TOKEN} http://localhost:8081/secured-api/piyofuga
...
< HTTP/1.1 403 Forbidden
< Server: nginx
...
<html>
<head><title>403 Forbidden</title></head>
<body>
<center><h1>403 Forbidden</h1></center>
<hr><center>nginx</center>
</body>
</html>
このときnginxコンテナのエラーログに error レベルでJavaScriptで定義した権限エラーのメッセージが出力されました。
2025/08/03 08:48:19 [error] 17#17: *1 js: Forbidden: insufficient scope
最終的なディレクトリ構造
$ tree .
.
├── compose.yml
└── nginx
├── config
│ └── nginx.conf
└── njs
└── authz.js
以上。