用JavaScript自定义nginx:OpenID Connect反向代理
我为本地PC的开发环境搭建了基于nginx的反向代理型OpenID Connect(OIDC)用户认证,以下是我介绍。
我們使用nginx的njs(NGINX JavaScript)來實現OIDC授權碼流程。 njs是nginx的一個模塊,可以通過JavaScript擴展nginx的功能。它也可以在官方的nginx docker映像中使用。
在使用nginx和JavaScript的组合时,其优势在于可以创建接近生产环境配置的自由度较高。
反向代理类型是什么意思?
用户认证由反向代理和授权服务器处理。应用程序可以专注于业务逻辑的开发。
反向代理将浏览器的HTTP请求转发到后端的WEB服务器,同时验证HTTP请求中的会话Cookie。
如果不存在会话Cookie,反向代理服务器将重定向到授权服务器并提示用户登录。用户登录后再重定向回反向代理服务器,然后从授权服务器获取令牌并使用该令牌获取用户信息,同时创建会话Cookie。
如果存在会话Cookie,反向代理会将用户信息添加到HTTP请求头中并转发给Web服务器。
请参阅下文以获得关于反向代理类型的详细信息。
用njs实现的功能
使用njs时,可以从nginx调用JavaScript函数,就像Web服务器调用HTTP请求处理程序一样。
然而,njs的目的并不是将nginx变为一个应用程序服务器。
- NGINX JavaScript Is Not Node.js
这次,我们在 njs 中实现了以下功能。
-
- OIDC 認可コードフローでトークン取得
-
- 認可サーバからユーザ情報を取得し JWT に変換後、セッション Cookie に格納
- セッション Cookie の JWT を検証
运动环境
使用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
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 的欢迎页面。

请在浏览器开发工具中确认是否已创建名为MY_SESSION的会话Cookie。

确认在WEB服务器nginx(后端容器)的日志中,HTTP请求头中附加了用户信息(JWT)。其中,日志的 “eyJ0eX … CnARo” 部分即为用户信息的JWT。
$ 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"

当您在浏览器中访问 http://localhost/logout,便可实现登出操作。

反向代理服务器nginx的设置
请在proxy/nginx.conf文件中添加以下配置。
-
- njs モジュールをロード
conf.d/app.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モジュール関数を紐づけ
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授权码流程。

逆向代理服务器 nginx 的 JS 函数
与/auth/validate绑定的JS函数oidc.validate()将验证存储在会话Cookie中的JWT的签名。如果验证成功,将返回HTTP状态码200;如果失败,将返回401。
- JS関数 oidc.validate()
// ログイン済みか否かをチェック
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是提供有关进程信息的全局对象。
参考来源在这里↓
process.env.JWT_GEN_KEY 获取环境变量 JWT_GEN_KEY 的值。
在参考文献的注释中如下所示,虽然在运行环境测试中不使用env指令也可以引用环境变量,但原因不明。
默认情况下,nginx会删除从父进程继承来的所有环境变量,但会保留TZ变量。如果需要保留继承变量的一部分,可以使用env指令。
r.variables.cookie_MY_SESSION 是 nginx 内置变量。它用于引用 Cookie MY_SESSION 的值。这样,我们也可以在 JS 代码中引用 nginx 的内置变量。
与 @login 相关的 JS 函数 oidc.login() 将重定向到授权服务器(Keycloak)的授权端点。授权服务器将显示登录页面。
- JS関数 oidc.login()
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()。
– I’m sorry, I don’t understand what you mean.
对不起,我不明白您的意思。
– Could you provide more details?
能否提供更多细节?
– I need further explanation.
我需要进一步的解释。
JS函数oidc.postlogin()用于从授权服务器的令牌端点获取并验证ID令牌。使用该令牌获取用户信息并转换为JWT。将JWT设置为会话Cookie,并将页面重定向到调用者页面。
- JS関数 oidc.postlogin()
// ログイン後の処理
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
}
}
注意:ID令牌验证只针对部分声明,并未验证签名。
代理/ njs / oidc.js(摘录)
// OIDC ID令牌验证
function validate_id_token(token, issuer, audience) {
let payload = jwt.decode(token).payload;
if(payload.iss!=发行方)抛出新错误(“无效令牌”);
if(payload.aud!=受众)抛出新错误(“无效令牌”);
if(payload.exp <Math.floor(Date.now()/ 1000))抛出新错误(“无效令牌”);
}
- njs サンプルコード
登出时,还需要同时结束认证服务器Keycloak的会话。
与“/logout”相关的JS函数oidc.logout()将重定向到Keycloak的注销端点。当Keycloak注销后,它将重定向回“/postlogout”,然后通过JS函数oidc.postlogout()删除会话Cookie。
- JS関数 oidc.logout()
// ログアウトエンドポイントへリダイレクト
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()
// ログアウト後の処理. セッション 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)记录到日志中。
将以下设置添加到后端/nginx.conf。
log_format にHTTPリクエストヘッダの出力を追加
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”‘。
输出日志示例
$ 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"
准备Keycloak Realm导出文件
在从授权服务器获取令牌时,反向代理需要客户端ID和客户端密码。为此,需要在Keycloak的领域中注册应用程序客户端。
由于每次启动/停止容器时重新注册应用程序客户端很麻烦,因此我们将预先定义的 Realm 导入。
执行步骤
-
- 创建领域
-
- 注册应用程序客户端
-
- 激活自助注册
-
- 导出领域
-
- 设置客户端密钥
- 配置领域导出文件
在没有 Realm エクスポートファイル keycloak/import/realm-develop.json 的情况下, 执行 Docker Compose 并启动容器。
$ docker compose up -d
等待大约一分钟,直到Keycloak初始化完成。在浏览器中打开http://localhost:8080/,将显示Keycloak的欢迎页面,然后选择[Administration Console],使用admin/admin进行登录。

创造领域
构建实现 “develop” 的领域。
从显示为“master”的下拉菜单中选择“Create Realm”。
在Realm名称中输入“develop”,然后点击“Create”。

应用程序客户端注册
将应用程序客户端 “reverse-proxy” 注册到创建的Realm “develop” 中。
在侧边栏中选择“客户”然后点击“创建客户”。

需要填写的项目
自助注册激活
允许用户自行注册。
在侧边菜单中选择 “Realm 设置”。
打开 “登录” 选项卡并启用 “用户注册”。

领域出口
将创建的 Realm “develop” 导出到文件中。
从画面右上方的动作下拉菜单中选择“部分导出”。

将“Include clients”设为ON,执行“Export”。

客户端秘钥设置
由于在导出的文件中没有包含客户端密钥,所以需要手动填写客户端密钥。
在编辑器中打开保存的Realm导出文件。
搜索clientId为”reverse-proxy”并在secret中输入”hogehogehoge”,然后保存。

领域的导出文件位置安排
将Realm导出文件放置在keycloak/import/realm-develop.json文件中。
重新启动 Docker Compose 后,将导入 Realm 导出文件。
$ 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
请参考 Keycloak 文档了解 Realm 的导入/导出功能。
njs 代码的完整内容
代理/ 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}
代理/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}
请提供一篇参考文章。