- 04-proxy-loadbalancing: 新增第18节主动健康检查详解 - 被动检查vs主动检查对比、NGINX Plus health_check/match指令 - Stream健康检查、gRPC健康检查、开源替代方案 - 新增 MQTT 模块文档 (33): broker负载均衡、Client ID路由 - 新增 OIDC 模块文档 (34): OpenID Connect认证、JWT验证 - 新增 Keyval 模块文档 (35): 动态键值存储、API管理接口 - 新增 流媒体模块文档 (36): HLS/FLV/MP4伪流媒体配置 - 新增 WebDAV 模块文档 (37): 文件共享服务器配置 - 新增 Zone Sync 模块文档 (38): 多节点状态同步 - 新增 HTTP Tunnel 模块文档 (39): HTTP CONNECT代理隧道 - 更新 README.md 目录索引 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
42 KiB
NGINX OpenID Connect 模块指南
1. OIDC 模块概述
什么是 OpenID Connect
OpenID Connect (OIDC) 是基于 OAuth 2.0 协议的身份认证层,允许客户端应用程序验证用户身份并获取用户基本信息。NGINX Plus 提供原生 OIDC 支持,可作为 Relying Party (RP) 与 Identity Provider (IdP) 集成,实现单点登录 (SSO) 和集中式身份认证。
模块用途
| 场景 | 说明 |
|---|---|
| 单点登录 (SSO) | 用户一次登录,访问多个受保护应用 |
| 集中式认证 | 统一认证入口,后端服务无需处理认证逻辑 |
| API 保护 | 验证 JWT Token,保护 API 端点 |
| 身份代理 | 将身份信息传递给后端应用 |
| 会话管理 | 集中管理用户会话生命周期 |
架构图
┌─────────┐ ┌─────────────┐ ┌─────────────────┐
│ Client │────────▶│ NGINX Plus │────────▶│ IdP Provider │
│ (浏览器) │ │ (OIDC RP) │ │ (Keycloak/Okta) │
└─────────┘ └──────┬──────┘ └─────────────────┘
│
▼
┌──────────────┐
│ Backend │
│ Applications │
└──────────────┘
工作流程:
- 用户访问受保护资源,NGINX 检查会话状态
- 无有效会话时,重定向到 IdP 登录页面
- 用户在 IdP 完成认证,IdP 返回授权码
- NGINX 使用授权码交换 ID Token 和 Access Token
- 用户携带会话 Cookie 访问资源,NGINX 验证 JWT
- 后端应用接收带有用户信息的请求
2. OIDC 认证流程
2.1 授权码流程 (Authorization Code Flow)
NGINX Plus 使用标准的 OAuth 2.0 授权码流程,支持 PKCE (Proof Key for Code Exchange) 增强安全性。
完整流程图:
用户 NGINX Plus IdP Provider
| │ │
|── 1. 访问 /app ───────────────▶│ │
| │── 2. 检查会话 ─────────────▶│
| │ │
|◀─ 3. 重定向到 /authorize ─────│ │
| │ │
|── 4. 登录认证 ───────────────────────────────▶│ │
| │ │
|◀─ 5. 返回授权码 ───────────────│ │
| │ │
|── 6. 携带 code 回调 ──────────▶│ │
| │── 7. Token 请求 ───────────▶│
| │ │
| │◀─ 8. ID/Access/Refresh Token─│
| │ │
|◀─ 9. 设置 Session Cookie ─────│ │
| │ │
|── 10. 携带 Cookie 访问 ───────▶│ │
| │── 11. 验证 JWT ────────────▶│
| │ │
| │── 12. 代理到后端 ──────────▶│
2.2 支持的 IdP 提供商
| 提供商 | Discovery URL 格式 |
|---|---|
| Keycloak | https://keycloak.example.com/realms/{realm}/.well-known/openid-configuration |
| Okta | https://{your-domain}.okta.com/.well-known/openid-configuration |
| Auth0 | https://{your-domain}.auth0.com/.well-known/openid-configuration |
| Azure AD / Entra ID | https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration |
https://accounts.google.com/.well-known/openid-configuration |
|
| GitHub | https://token.actions.githubusercontent.com/.well-known/openid-configuration |
| 自建 IdP | https://auth.example.com/.well-known/openid-configuration |
3. 指令参考
3.1 核心指令
auth_jwt
启用 JWT 认证。
| 属性 | 说明 |
|---|---|
| 语法 | auth_jwt "realm" [token=$variable] | off; |
| 默认值 | off |
| 上下文 | http, server, location |
# 基本用法
location /protected/ {
auth_jwt "API Access";
auth_jwt_key_file /etc/nginx/jwks.json;
}
# 从 Cookie 读取 Token
location /api/ {
auth_jwt "API Access" token=$cookie_auth_token;
auth_jwt_key_request /_jwks_uri;
}
# 从 Header 读取 Token
location /api/ {
auth_jwt "API Access" token=$http_authorization;
auth_jwt_key_request /_jwks_uri;
}
auth_jwt_key_file
指定 JWKS (JSON Web Key Set) 文件路径,用于验证 JWT 签名。
| 属性 | 说明 |
|---|---|
| 语法 | auth_jwt_key_file file; |
| 默认值 | — |
| 上下文 | http, server, location |
# 本地 JWKS 文件
auth_jwt_key_file /etc/nginx/oidc/jwks.json;
# 使用变量(动态配置)
auth_jwt_key_file $oidc_jwt_keyfile;
auth_jwt_key_request
从指定 location 动态获取 JWKS。
| 属性 | 说明 |
|---|---|
| 语法 | auth_jwt_key_request uri; |
| 默认值 | — |
| 上下文 | http, server, location |
server {
location /protected/ {
auth_jwt "API Access" token=$cookie_auth_token;
auth_jwt_key_request /_jwks_uri;
}
# 内部 JWKS 端点
location = /_jwks_uri {
internal; # 仅限内部子请求访问
proxy_pass https://idp.example.com/.well-known/jwks.json;
proxy_cache jwks_cache;
proxy_cache_valid 200 1h;
}
}
auth_jwt_require
配置 JWT 验证的额外要求。
| 属性 | 说明 |
|---|---|
| 语法 | auth_jwt_require $claim [value] ...; |
| 默认值 | — |
| 上下文 | http, server, location |
# 要求特定的 issuer
location /protected/ {
auth_jwt "API Access" token=$cookie_auth_token;
auth_jwt_key_request /_jwks_uri;
auth_jwt_require $jwt_claim_iss "https://idp.example.com";
}
# 要求特定的 audience
location /api/admin/ {
auth_jwt "Admin Access" token=$cookie_auth_token;
auth_jwt_key_request /_jwks_uri;
auth_jwt_require $jwt_claim_aud "my-api-client";
}
# 组合条件
location /api/sensitive/ {
auth_jwt "Sensitive Access" token=$cookie_auth_token;
auth_jwt_key_request /_jwks_uri;
auth_jwt_require $jwt_claim_iss "https://idp.example.com" $jwt_claim_aud "sensitive-api";
}
3.2 会话管理指令
keyval_zone
定义用于存储 OIDC 会话数据的共享内存区域。
| 属性 | 说明 |
|---|---|
| 语法 | keyval_zone zone=name:size [state=file] [timeout=time]; |
| 默认值 | — |
| 上下文 | http |
# 基本配置
keyval_zone zone=oidc_id_tokens:1M timeout=1h;
keyval_zone zone=oidc_access_tokens:1M timeout=1h;
keyval_zone zone=oidc_refresh_tokens:1M timeout=8h;
# 带状态持久化
keyval_zone zone=oidc_id_tokens:1M state=/var/lib/nginx/state/oidc_id_tokens.json timeout=1h;
keyval_zone zone=oidc_access_tokens:1M state=/var/lib/nginx/state/oidc_access_tokens.json timeout=1h;
参数说明:
zone=name:size- 共享内存区域名称和大小state=file- 状态持久化文件路径timeout=time- 数据过期时间
keyval
定义键值对存储。
| 属性 | 说明 |
|---|---|
| 语法 | keyval $variable $variable zone=name; |
| 默认值 | — |
| 上下文 | http |
# 定义存储变量
keyval $cookie_auth_token $session_jwt zone=oidc_id_tokens;
keyval $cookie_auth_token $access_token zone=oidc_access_tokens;
keyval $cookie_auth_token $refresh_token zone=oidc_refresh_tokens;
3.3 JavaScript 模块指令
js_import
导入 NGINX JavaScript (njs) 模块。
| 属性 | 说明 |
|---|---|
| 语法 | js_import module [as namespace]; |
| 默认值 | — |
| 上下文 | http |
# 导入 OIDC 处理脚本
js_import /etc/nginx/conf.d/openid_connect.js;
# 使用命名空间
js_import /etc/nginx/conf.d/openid_connect.js as oidc;
js_set
设置 JavaScript 变量。
| 属性 | 说明 |
|---|---|
| 语法 | js_set $variable function; |
| 默认值 | — |
| 上下文 | http |
# 设置 OIDC 认证头
js_set $oidc_auth_header oidc.authHeader;
# 设置 ID Token
js_set $id_token oidc.idToken;
js_content
使用 JavaScript 生成响应内容。
| 属性 | 说明 |
|---|---|
| 语法 | js_content function; |
| 默认值 | — |
| 上下文 | location |
location /login {
js_content oidc.login;
}
location /logout {
js_content oidc.logout;
}
location = /redirect_uri {
js_content oidc.redirect;
}
3.4 代理配置指令
auth_jwt_set
将 JWT Claims 提取到变量。
| 属性 | 说明 |
|---|---|
| 语法 | auth_jwt_set $variable claim; |
| 默认值 | — |
| 上下文 | http, server, location |
server {
location /api/ {
auth_jwt "API Access" token=$cookie_auth_token;
auth_jwt_key_request /_jwks_uri;
# 提取常用 Claims
auth_jwt_set $jwt_sub sub;
auth_jwt_set $jwt_email email;
auth_jwt_set $jwt_name name;
proxy_pass http://backend;
proxy_set_header X-User-ID $jwt_sub;
proxy_set_header X-User-Email $jwt_email;
proxy_set_header X-User-Name $jwt_name;
}
}
4. 变量参考
4.1 JWT Claims 变量
| 变量 | 说明 |
|---|---|
$jwt_claim_sub |
用户唯一标识 (Subject) |
$jwt_claim_iss |
Token 签发者 (Issuer) |
$jwt_claim_aud |
Token 受众 (Audience) |
$jwt_claim_exp |
Token 过期时间 (Expiration) |
$jwt_claim_iat |
Token 签发时间 (Issued At) |
$jwt_claim_nbf |
Token 生效时间 (Not Before) |
$jwt_claim_jti |
Token 唯一标识 (JWT ID) |
$jwt_claim_email |
用户邮箱 |
$jwt_claim_name |
用户名称 |
$jwt_claim_preferred_username |
首选用户名 |
$jwt_claim_groups |
用户组/角色 |
$jwt_claim_scope |
授权范围 |
4.2 会话变量
| 变量 | 说明 |
|---|---|
$cookie_auth_token |
会话 Cookie 值 |
$session_jwt |
存储的 ID Token |
$access_token |
存储的 Access Token |
$refresh_token |
存储的 Refresh Token |
5. IdP 集成配置示例
5.1 Keycloak 集成
Keycloak 配置要点:
- 创建 Client,启用
Standard Flow(Authorization Code) - 配置 Valid Redirect URIs:
https://app.example.com/redirect_uri - 启用
Client authentication,记录 Client Secret - 配置 Web Origins:
https://app.example.com
NGINX 配置:
http {
# 加载 JavaScript 模块
load_module modules/ngx_http_js_module.so;
# 会话存储
keyval_zone zone=oidc_id_tokens:1M state=/var/lib/nginx/state/oidc_id_tokens.json timeout=1h;
keyval_zone zone=oidc_access_tokens:1M state=/var/lib/nginx/state/oidc_access_tokens.json timeout=1h;
keyval_zone zone=oidc_refresh_tokens:1M state=/var/lib/nginx/state/oidc_refresh_tokens.json timeout=8h;
keyval $cookie_auth_token $session_jwt zone=oidc_id_tokens;
keyval $cookie_auth_token $access_token zone=oidc_access_tokens;
keyval $cookie_auth_token $refresh_token zone=oidc_refresh_tokens;
# IdP 端点配置
map $host $oidc_authz_endpoint {
default "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/auth";
}
map $host $oidc_token_endpoint {
default "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token";
}
map $host $oidc_jwks_uri {
default "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/certs";
}
map $host $oidc_userinfo_endpoint {
default "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/userinfo";
}
map $host $oidc_end_session_endpoint {
default "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/logout";
}
# Client 配置
map $host $oidc_client {
default "my-nginx-client";
}
map $host $oidc_client_secret {
default "your-client-secret-here";
}
map $host $oidc_scopes {
default "openid+profile+email";
}
map $host $oidc_pkce_enable {
default "1";
}
# 导入 OIDC 脚本
js_import /etc/nginx/conf.d/openid_connect.js;
# 缓存配置
proxy_cache_path /var/cache/nginx/jwks levels=1:2 keys_zone=jwks_cache:1m max_size=10m inactive=60m use_temp_path=off;
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /etc/nginx/ssl/app.example.com.crt;
ssl_certificate_key /etc/nginx/ssl/app.example.com.key;
# 会话 Cookie 配置
set $session_cookie "auth_token";
set $session_cookie_flags "Path=/; Secure; HttpOnly; SameSite=Strict";
location / {
# JWT 认证
auth_jwt "Keycloak SSO" token=$cookie_auth_token;
auth_jwt_key_request /_jwks_uri;
# 提取用户信息
auth_jwt_set $jwt_sub sub;
auth_jwt_set $jwt_email email;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-User-ID $jwt_sub;
proxy_set_header X-User-Email $jwt_email;
proxy_set_header Authorization "Bearer $access_token";
}
# OIDC 端点
location /login {
js_content openid_connect.login;
}
location /logout {
js_content openid_connect.logout;
}
location = /redirect_uri {
js_content openid_connect.redirect;
}
# 内部 JWKS 端点
location = /_jwks_uri {
internal;
proxy_pass $oidc_jwks_uri;
proxy_cache jwks_cache;
proxy_cache_valid 200 1h;
proxy_cache_use_stale error timeout updating;
proxy_ssl_server_name on;
}
}
}
5.2 Okta 集成
Okta 配置要点:
- 创建 App Integration,选择
OIDC - OpenID Connect - Application type:
Web Application - Sign-in redirect URIs:
https://app.example.com/redirect_uri - Sign-out redirect URIs:
https://app.example.com/ - Grant type:
Authorization Code+Refresh Token
NGINX 配置:
http {
load_module modules/ngx_http_js_module.so;
# 会话存储
keyval_zone zone=oidc_id_tokens:1M state=/var/lib/nginx/state/oidc_id_tokens.json timeout=1h;
keyval_zone zone=oidc_access_tokens:1M state=/var/lib/nginx/state/oidc_access_tokens.json timeout=1h;
keyval_zone zone=oidc_refresh_tokens:1M state=/var/lib/nginx/state/oidc_refresh_tokens.json timeout=8h;
keyval $cookie_auth_token $session_jwt zone=oidc_id_tokens;
keyval $cookie_auth_token $access_token zone=oidc_access_tokens;
keyval $cookie_auth_token $refresh_token zone=oidc_refresh_tokens;
# Okta 端点配置
map $host $oidc_authz_endpoint {
default "https://myorg.okta.com/oauth2/default/v1/authorize";
# 或使用自定义授权服务器
# default "https://myorg.okta.com/oauth2/ausxxxxxx/v1/authorize";
}
map $host $oidc_token_endpoint {
default "https://myorg.okta.com/oauth2/default/v1/token";
}
map $host $oidc_jwks_uri {
default "https://myorg.okta.com/oauth2/default/v1/keys";
}
map $host $oidc_userinfo_endpoint {
default "https://myorg.okta.com/oauth2/default/v1/userinfo";
}
map $host $oidc_end_session_endpoint {
default "https://myorg.okta.com/oauth2/default/v1/logout";
}
map $host $oidc_client {
default "0oaxxxxxxxxxxx";
}
map $host $oidc_client_secret {
default "your-client-secret";
}
map $host $oidc_scopes {
default "openid+profile+email+groups";
}
map $host $oidc_pkce_enable {
default "1";
}
js_import /etc/nginx/conf.d/openid_connect.js;
proxy_cache_path /var/cache/nginx/jwks levels=1:2 keys_zone=jwks_cache:1m max_size=10m inactive=60m;
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /etc/nginx/ssl/app.example.com.crt;
ssl_certificate_key /etc/nginx/ssl/app.example.com.key;
location / {
auth_jwt "Okta SSO" token=$cookie_auth_token;
auth_jwt_key_request /_jwks_uri;
auth_jwt_set $jwt_sub sub;
auth_jwt_set $jwt_email email;
auth_jwt_set $jwt_groups groups;
proxy_pass http://backend;
proxy_set_header X-User-ID $jwt_sub;
proxy_set_header X-User-Email $jwt_email;
proxy_set_header X-User-Groups $jwt_groups;
proxy_set_header Authorization "Bearer $access_token";
}
location /login {
js_content openid_connect.login;
}
location /logout {
js_content openid_connect.logout;
}
location = /redirect_uri {
js_content openid_connect.redirect;
}
location = /_jwks_uri {
internal;
proxy_pass $oidc_jwks_uri;
proxy_cache jwks_cache;
proxy_cache_valid 200 1h;
proxy_ssl_server_name on;
}
}
}
5.3 Auth0 集成
Auth0 配置要点:
- 创建 Application,类型选择
Regular Web Application - Allowed Callback URLs:
https://app.example.com/redirect_uri - Allowed Logout URLs:
https://app.example.com/ - Allowed Web Origins:
https://app.example.com - 启用
Refresh Token轮换(可选)
NGINX 配置:
http {
load_module modules/ngx_http_js_module.so;
# 会话存储
keyval_zone zone=oidc_id_tokens:1M state=/var/lib/nginx/state/oidc_id_tokens.json timeout=1h;
keyval_zone zone=oidc_access_tokens:1M state=/var/lib/nginx/state/oidc_access_tokens.json timeout=1h;
keyval_zone zone=oidc_refresh_tokens:1M state=/var/lib/nginx/state/oidc_refresh_tokens.json timeout=8h;
keyval $cookie_auth_token $session_jwt zone=oidc_id_tokens;
keyval $cookie_auth_token $access_token zone=oidc_access_tokens;
keyval $cookie_auth_token $refresh_token zone=oidc_refresh_tokens;
# Auth0 端点配置
map $host $oidc_authz_endpoint {
default "https://myapp.auth0.com/authorize";
}
map $host $oidc_token_endpoint {
default "https://myapp.auth0.com/oauth/token";
}
map $host $oidc_jwks_uri {
default "https://myapp.auth0.com/.well-known/jwks.json";
}
map $host $oidc_userinfo_endpoint {
default "https://myapp.auth0.com/userinfo";
}
map $host $oidc_end_session_endpoint {
default "https://myapp.auth0.com/v2/logout";
}
map $host $oidc_client {
default "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
}
map $host $oidc_client_secret {
default "your-client-secret";
}
map $host $oidc_scopes {
default "openid+profile+email";
}
map $host $oidc_pkce_enable {
default "1";
}
# Auth0 特定:返回首页参数
map $host $oidc_logout_redirect {
default "https://app.example.com/";
}
js_import /etc/nginx/conf.d/openid_connect.js;
proxy_cache_path /var/cache/nginx/jwks levels=1:2 keys_zone=jwks_cache:1m max_size=10m inactive=60m;
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /etc/nginx/ssl/app.example.com.crt;
ssl_certificate_key /etc/nginx/ssl/app.example.com.key;
location / {
auth_jwt "Auth0 SSO" token=$cookie_auth_token;
auth_jwt_key_request /_jwks_uri;
auth_jwt_set $jwt_sub sub;
auth_jwt_set $jwt_email email;
auth_jwt_set $jwt_name name;
auth_jwt_set $jwt_nickname nickname;
proxy_pass http://backend;
proxy_set_header X-User-ID $jwt_sub;
proxy_set_header X-User-Email $jwt_email;
proxy_set_header X-User-Name $jwt_name;
proxy_set_header Authorization "Bearer $access_token";
}
location /login {
js_content openid_connect.login;
}
location /logout {
js_content openid_connect.logout;
}
location = /redirect_uri {
js_content openid_connect.redirect;
}
location = /_jwks_uri {
internal;
proxy_pass $oidc_jwks_uri;
proxy_cache jwks_cache;
proxy_cache_valid 200 1h;
proxy_ssl_server_name on;
}
}
}
5.4 Azure AD / Entra ID 集成
Azure AD 配置要点:
- 在 Azure Portal 注册应用
- 添加平台配置:Web
- 重定向 URI:
https://app.example.com/redirect_uri - 启用
ID tokens(用于隐式和混合流) - 创建 Client Secret
NGINX 配置:
http {
load_module modules/ngx_http_js_module.so;
# 会话存储
keyval_zone zone=oidc_id_tokens:1M state=/var/lib/nginx/state/oidc_id_tokens.json timeout=1h;
keyval_zone zone=oidc_access_tokens:1M state=/var/lib/nginx/state/oidc_access_tokens.json timeout=1h;
keyval_zone zone=oidc_refresh_tokens:1M state=/var/lib/nginx/state/oidc_refresh_tokens.json timeout=8h;
keyval $cookie_auth_token $session_jwt zone=oidc_id_tokens;
keyval $cookie_auth_token $access_token zone=oidc_access_tokens;
keyval $cookie_auth_token $refresh_token zone=oidc_refresh_tokens;
# Azure AD 端点配置
map $host $oidc_authz_endpoint {
default "https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize";
}
map $host $oidc_token_endpoint {
default "https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token";
}
map $host $oidc_jwks_uri {
default "https://login.microsoftonline.com/{tenant-id}/discovery/v2.0/keys";
}
map $host $oidc_userinfo_endpoint {
default "https://graph.microsoft.com/oidc/userinfo";
}
map $host $oidc_end_session_endpoint {
default "https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/logout";
}
map $host $oidc_client {
default "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
}
map $host $oidc_client_secret {
default "your-client-secret";
}
# Azure AD 使用空格分隔 scopes
map $host $oidc_scopes {
default "openid+profile+email";
}
map $host $oidc_pkce_enable {
default "1";
}
js_import /etc/nginx/conf.d/openid_connect.js;
proxy_cache_path /var/cache/nginx/jwks levels=1:2 keys_zone=jwks_cache:1m max_size=10m inactive=60m;
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /etc/nginx/ssl/app.example.com.crt;
ssl_certificate_key /etc/nginx/ssl/app.example.com.key;
location / {
auth_jwt "Azure AD SSO" token=$cookie_auth_token;
auth_jwt_key_request /_jwks_uri;
auth_jwt_set $jwt_sub sub;
auth_jwt_set $jwt_email email;
auth_jwt_set $jwt_name name;
auth_jwt_set $jwt_oid oid; # Azure AD Object ID
proxy_pass http://backend;
proxy_set_header X-User-ID $jwt_sub;
proxy_set_header X-User-Email $jwt_email;
proxy_set_header X-User-Name $jwt_name;
proxy_set_header X-Azure-Object-ID $jwt_oid;
proxy_set_header Authorization "Bearer $access_token";
}
location /login {
js_content openid_connect.login;
}
location /logout {
js_content openid_connect.logout;
}
location = /redirect_uri {
js_content openid_connect.redirect;
}
location = /_jwks_uri {
internal;
proxy_pass $oidc_jwks_uri;
proxy_cache jwks_cache;
proxy_cache_valid 200 1h;
proxy_ssl_server_name on;
}
}
}
6. JWT 验证与 Token 刷新
6.1 JWT 验证机制
验证流程:
1. 提取 Token ──▶ 2. 解析 Header ──▶ 3. 获取公钥 ──▶ 4. 验证签名
│
▼
5. 验证 Claims ◀── 6. 检查过期 ◀── 4. 验证签名
关键验证点:
- 签名验证:使用 IdP 的公钥验证 JWT 签名
- Issuer 验证:确认 Token 来自正确的 IdP
- Audience 验证:确认 Token 为此应用签发
- 过期验证:检查
expClaim - 生效时间:检查
nbf(Not Before) Claim
6.2 JWKS 缓存配置
# JWKS 缓存
proxy_cache_path /var/cache/nginx/jwks levels=1:2 keys_zone=jwks_cache:1m
max_size=10m inactive=60m use_temp_path=off;
server {
location = /_jwks_uri {
internal;
proxy_pass $oidc_jwks_uri;
proxy_cache jwks_cache;
proxy_cache_valid 200 1h;
proxy_cache_use_stale error timeout updating;
proxy_ssl_server_name on;
# 连接超时设置
proxy_connect_timeout 5s;
proxy_send_timeout 5s;
proxy_read_timeout 5s;
}
}
6.3 Token 刷新机制
自动刷新流程:
// openid_connect.js 中的刷新逻辑
function refreshToken(r) {
// 1. 检查 Token 是否即将过期
var token = r.variables.session_jwt;
var payload = JSON.parse(jwtPayload(token));
var exp = payload.exp;
var now = Math.floor(Date.now() / 1000);
// 2. 提前 60 秒刷新
if (exp - now < 60) {
// 3. 使用 Refresh Token 获取新 Token
var refreshToken = r.variables.refresh_token;
return exchangeRefreshToken(r, refreshToken);
}
return token;
}
配置示例:
server {
location / {
# 使用 JavaScript 处理 Token 刷新
js_set $validated_token oidc.validateAndRefresh;
auth_jwt "API" token=$validated_token;
auth_jwt_key_request /_jwks_uri;
proxy_pass http://backend;
}
}
6.4 多 IdP 支持
http {
# 根据域名选择 IdP
map $host $oidc_config {
default "keycloak";
"app1.example.com" "keycloak";
"app2.example.com" "okta";
"app3.example.com" "auth0";
}
# Keycloak 配置
map $oidc_config $keycloak_authz_endpoint {
"keycloak" "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/auth";
default "";
}
map $oidc_config $keycloak_token_endpoint {
"keycloak" "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token";
default "";
}
# Okta 配置
map $oidc_config $okta_authz_endpoint {
"okta" "https://myorg.okta.com/oauth2/default/v1/authorize";
default "";
}
map $oidc_config $okta_token_endpoint {
"okta" "https://myorg.okta.com/oauth2/default/v1/token";
default "";
}
# 统一端点变量
map $oidc_config $oidc_authz_endpoint {
"keycloak" $keycloak_authz_endpoint;
"okta" $okta_authz_endpoint;
}
map $oidc_config $oidc_token_endpoint {
"keycloak" $keycloak_token_endpoint;
"okta" $okta_token_endpoint;
}
}
7. JavaScript 模块 (njs) 实现
7.1 基础 openid_connect.js
// openid_connect.js
var qs = require('querystring');
var jwt = require('jwt');
// 生成随机状态码
function generateState() {
return Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
}
// 生成 PKCE code_verifier
function generateCodeVerifier() {
var bytes = [];
for (var i = 0; i < 32; i++) {
bytes.push(Math.floor(Math.random() * 256));
}
return bytesToBase64Url(bytes);
}
// Base64 URL 编码
function bytesToBase64Url(bytes) {
var base64 = '';
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
for (var i = 0; i < bytes.length; i += 3) {
var b1 = bytes[i];
var b2 = bytes[i + 1] || 0;
var b3 = bytes[i + 2] || 0;
var bitmap = (b1 << 16) | (b2 << 8) | b3;
base64 += chars[(bitmap >> 18) & 63];
base64 += chars[(bitmap >> 12) & 63];
base64 += (i + 1 < bytes.length) ? chars[(bitmap >> 6) & 63] : '=';
base64 += (i + 2 < bytes.length) ? chars[bitmap & 63] : '=';
}
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
// 登录处理
function login(r) {
var state = generateState();
var redirectUri = 'https://' + r.headersIn['Host'] + '/redirect_uri';
var params = {
response_type: 'code',
client_id: r.variables.oidc_client,
redirect_uri: redirectUri,
scope: r.variables.oidc_scopes.replace(/\+/g, ' '),
state: state
};
// 启用 PKCE
if (r.variables.oidc_pkce_enable === '1') {
var codeVerifier = generateCodeVerifier();
params.code_challenge = codeVerifier;
params.code_challenge_method = 'S256';
// 存储 code_verifier 用于后续 token 交换
r.variables.code_verifier = codeVerifier;
}
var authUrl = r.variables.oidc_authz_endpoint + '?' + qs.stringify(params);
r.return(302, authUrl);
}
// Token 交换处理
function redirect(r) {
var args = r.args;
// 检查错误
if (args.error) {
r.return(500, 'Authentication error: ' + args.error_description);
return;
}
var code = args.code;
var redirectUri = 'https://' + r.headersIn['Host'] + '/redirect_uri';
// Token 请求
var tokenReq = {
method: 'POST',
body: qs.stringify({
grant_type: 'authorization_code',
code: code,
redirect_uri: redirectUri,
client_id: r.variables.oidc_client,
client_secret: r.variables.oidc_client_secret
}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
};
// 发送 Token 请求
r.subrequest('/_token', tokenReq, function(res) {
if (res.status !== 200) {
r.return(500, 'Token exchange failed');
return;
}
var tokenData = JSON.parse(res.responseBody);
var sessionId = generateState();
// 存储 Token
r.variables.session_jwt = tokenData.id_token;
r.variables.access_token = tokenData.access_token;
r.variables.refresh_token = tokenData.refresh_token || '';
// 设置 Cookie 并重定向
r.headersOut['Set-Cookie'] = 'auth_token=' + sessionId + '; ' +
r.variables.session_cookie_flags;
r.return(302, '/');
});
}
// 登出处理
function logout(r) {
var sessionId = r.variables.cookie_auth_token;
// 清除存储的 Token
if (sessionId) {
delete r.variables.session_jwt;
delete r.variables.access_token;
delete r.variables.refresh_token;
}
// 清除 Cookie
r.headersOut['Set-Cookie'] = 'auth_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT';
// 如果配置了 end_session_endpoint,重定向到 IdP 登出
if (r.variables.oidc_end_session_endpoint) {
var logoutUrl = r.variables.oidc_end_session_endpoint +
'?post_logout_redirect_uri=' +
encodeURIComponent('https://' + r.headersIn['Host'] + '/');
r.return(302, logoutUrl);
} else {
r.return(302, '/');
}
}
// Token 验证和刷新
function validateAndRefresh(r) {
var token = r.variables.session_jwt;
if (!token) {
return '';
}
try {
var payload = jwt.decode(token).payload;
var exp = payload.exp;
var now = Math.floor(Date.now() / 1000);
// Token 即将过期,尝试刷新
if (exp - now < 60 && r.variables.refresh_token) {
// 异步刷新 Token
refreshAccessToken(r);
}
return token;
} catch (e) {
return '';
}
}
// 导出函数
export default { login, logout, redirect, validateAndRefresh };
7.2 高级功能扩展
// 前端频道登出处理
function frontChannelLogout(r) {
var sid = r.args.sid;
if (sid) {
// 根据 sid 查找并清除会话
clearSessionBySid(r, sid);
}
r.return(200, 'OK');
}
// 用户信息服务
function userInfo(r) {
var token = r.variables.access_token;
if (!token) {
r.return(401, 'Unauthorized');
return;
}
r.subrequest('/_userinfo', {
method: 'GET',
headers: {
'Authorization': 'Bearer ' + token
}
}, function(res) {
r.headersOut['Content-Type'] = 'application/json';
r.return(res.status, res.responseBody);
});
}
// 会话检查
function checkSession(r) {
var token = r.variables.session_jwt;
if (!token) {
r.return(401, JSON.stringify({ active: false }));
return;
}
try {
var payload = jwt.decode(token).payload;
r.return(200, JSON.stringify({
active: true,
sub: payload.sub,
exp: payload.exp
}));
} catch (e) {
r.return(401, JSON.stringify({ active: false }));
}
}
8. 与 Lolly 项目的关系和建议
8.1 Lolly 项目概述
Lolly 是使用 Go 语言编写的高性能 HTTP 服务器与反向代理,与 NGINX 有相似的功能定位:
| 特性 | NGINX Plus | Lolly |
|---|---|---|
| 语言 | C | Go |
| OIDC 支持 | 内置 (auth_jwt, njs) | 待实现 |
| 配置 | nginx.conf | YAML |
| 扩展 | njs / C 模块 | Go 中间件 |
| 性能 | 极高 (C + 事件驱动) | 高 (Go + fasthttp) |
| HTTP/3 | 实验性 | 原生支持 |
| 热重载 | 支持 | 支持 |
8.2 Lolly OIDC 实现建议
基于 NGINX OIDC 模块的设计,建议 Lolly 实现以下功能:
配置结构示例
# Lolly OIDC 配置
oidc:
enabled: true
providers:
- name: keycloak
issuer: "https://keycloak.example.com/realms/myrealm"
client_id: "lolly-client"
client_secret: "${KEYCLOAK_SECRET}"
scopes: ["openid", "profile", "email"]
pkce_enabled: true
redirect_uri: "https://app.example.com/auth/callback"
# JWKS 配置
jwks:
url: "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/certs"
cache_ttl: "1h"
# 会话配置
session:
cookie_name: "auth_token"
cookie_secure: true
cookie_http_only: true
cookie_same_site: "Strict"
ttl: "1h"
# Token 刷新
refresh:
enabled: true
before_expiry: "5m"
# 用户信息转发
claims_forward:
- claim: "sub"
header: "X-User-ID"
- claim: "email"
header: "X-User-Email"
- claim: "name"
header: "X-User-Name"
server:
listen: ":443"
# 全局 OIDC 保护
oidc:
provider: keycloak
routes:
# 公开路径(无需认证)
- path: "/health"
public: true
static:
response: '{"status":"ok"}'
# 受保护 API
- path: "/api/*"
oidc:
provider: keycloak
require_claims:
- claim: "groups"
values: ["api-users"]
proxy:
target: "http://backend:8080"
# 管理后台(更严格)
- path: "/admin/*"
oidc:
provider: keycloak
require_claims:
- claim: "groups"
values: ["admin"]
proxy:
target: "http://admin:8080"
建议的实现架构
┌─────────────────────────────────────────────────────────────┐
│ Lolly Server │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ OIDC Middleware │ │ Session Store │ │ Token Validator │ │
│ │ │ │ - In-Memory │ │ - JWKS Fetch │ │
│ │ - Login Handler │ │ - Redis │ │ - JWT Verify │ │
│ │ - Callback │ │ - Cookie │ │ - Claims Extract│ │
│ │ - Logout │ │ │ │ │ │
│ │ - Refresh │ │ │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
推荐依赖库
| 功能 | 推荐库 | 说明 |
|---|---|---|
| OIDC Client | coreos/go-oidc |
官方推荐,功能完整 |
| JWT 解析 | golang-jwt/jwt |
最流行的 Go JWT 库 |
| OAuth2 | golang.org/x/oauth2 |
标准 OAuth2 实现 |
| Session | gorilla/sessions |
成熟的 Session 管理 |
实现优先级建议
-
Phase 1 - 基础功能
- JWT 验证中间件
- JWKS 获取和缓存
- 基本 Claims 提取
-
Phase 2 - 完整认证
- 授权码流程
- Session Cookie 管理
- 登录/登出端点
-
Phase 3 - 高级功能
- Token 自动刷新
- PKCE 支持
- 多 IdP 支持
-
Phase 4 - 企业特性
- 前端频道登出
- 用户信息服务
- 细粒度权限控制
8.3 迁移建议
从 NGINX Plus OIDC 迁移到 Lolly:
- 配置转换:使用工具将 nginx.conf 的 map 转换为 YAML
- IdP 配置复用:Issuer、Client ID、Secret 保持不变
- 后端适配:验证 Header 名称一致性
- Session 迁移:逐步迁移,支持双写
- 灰度切换:基于域名或路径逐步切换
9. 故障排查
9.1 常见问题
Token 验证失败
# 启用详细日志
error_log /var/log/nginx/error.log debug;
# 检查 JWKS 是否获取成功
location = /_jwks_uri {
internal;
proxy_pass $oidc_jwks_uri;
# 记录响应
add_header X-JWKS-Status $upstream_status always;
add_header X-JWKS-Cache $upstream_cache_status always;
}
Cookie 问题
# 确保 Cookie 配置正确
set $session_cookie "auth_token";
set $session_cookie_flags "Path=/; Secure; HttpOnly; SameSite=Lax";
# 检查 Cookie 是否设置
add_header X-Debug-Cookie $cookie_auth_token always;
9.2 调试端点
server {
# 健康检查端点
location /auth/health {
auth_jwt off;
default_type application/json;
return 200 '{"status":"ok","provider":"$oidc_config"}';
}
# Token 信息端点(仅调试)
location /auth/debug {
auth_jwt "Debug" token=$cookie_auth_token;
auth_jwt_key_request /_jwks_uri;
auth_jwt_set $jwt_sub sub;
auth_jwt_set $jwt_exp exp;
default_type application/json;
return 200 '{
"sub": "$jwt_sub",
"exp": "$jwt_exp",
"provider": "$oidc_config"
}';
}
}
9.3 日志分析
# 查看认证相关日志
grep "auth_jwt\|oidc\|openid" /var/log/nginx/error.log | tail -100
# 监控 Token 刷新
awk '/token.*refresh/ {print $0}' /var/log/nginx/access.log
10. 最佳实践
10.1 安全建议
-
始终启用 HTTPS
# 拒绝 HTTP 访问 server { listen 80; return 301 https://$host$request_uri; } -
使用 Secure Cookie
set $session_cookie_flags "Path=/; Secure; HttpOnly; SameSite=Strict"; -
启用 PKCE
map $host $oidc_pkce_enable { default "1"; } -
定期轮换 Client Secret
10.2 性能优化
-
JWKS 缓存
proxy_cache_valid 200 1h; proxy_cache_use_stale error timeout updating; -
会话存储优化
# 共享内存区域大小根据并发用户数调整 keyval_zone zone=oidc_id_tokens:10M timeout=1h; -
连接池
upstream idp_backend { server keycloak.example.com:443; keepalive 32; }
10.3 高可用配置
# JWKS 多源备份
upstream jwks_upstream {
server keycloak-primary.example.com:443;
server keycloak-backup.example.com:443 backup;
}
location = /_jwks_uri {
internal;
proxy_pass https://jwks_upstream/realms/myrealm/protocol/openid-connect/certs;
proxy_cache jwks_cache;
proxy_cache_valid 200 1h;
}