ajaygodbole7/oidc-reference

GitHub: ajaygodbole7/oidc-reference

一个可运行的 BFF 参考实现,确保在 OAuth 2.1 / OIDC 流程中浏览器端永远不接触任何 token,从架构层面消除了前端 token 泄露风险。

Stars: 0 | Forks: 0

# oidc-reference 这是一个关于 Backend-for-Frontend (BFF) session 模式的完整且**可运行**的参考实现:浏览器应用使用 OAuth 2.1 和 OpenID Connect Core 1.0,**浏览器 JS 或 storage 中不包含任何 token**,并且包含一个实时的测试,一旦发生泄露就会失败。 [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE) ![Java](https://img.shields.io/badge/Java-25-orange) ![Spring Boot](https://img.shields.io/badge/Spring%20Boot-4-green) ![Status](https://img.shields.io/badge/status-reference%20implementation-informational) ## 30 秒概览 1. **浏览器不存储任何 token,也不运行任何 OIDC 库**:只有一个不透明的 `__Host-sid` cookie 和一个 CSRF token。 2. **一个机密的服务器端 BFF 拥有 OAuth client 角色**,拆分为一个专用的 **Auth Service**(OIDC client)和一个 **API Gateway**(路由 + bearer 注入)。 3. **每个 `/api/**` 调用都是 phantom-token 模式:** 网关将不透明 cookie 替换为真实的 access token(由 Auth Service 解析),并将其作为 `Bearer` 注入并代理到 Resource Server。 ``` flowchart LR B["Browser SPA
opaque __Host-sid cookie only"] -->|"GET /api/…"| G[API Gateway] G -->|"resolve sid"| A["Auth Service (BFF)
holds the tokens"] A --> V[(Session Store)] G -->|"Bearer access_token"| R[Resource Server] A -.->|"OAuth round-trip"| K[IdP] G -.->|"Client Credentials service token"| K ``` 如果你不想看其他内容,请运行 `just up`,然后运行 `just e2e-auth`:登录 → API 调用 → token 刷新 → 登出,完成端到端流程。 ## 为什么采用这种模式 大多数 OIDC 演练都会向 SPA 提供一个在浏览器中运行 PKCE 的公共 client。这会将 access token 暴露在任何 XSS payload 都可以读取到的位置。本参考实现展示了安全的默认设置,并证明了其有效性: | 决策 | 本参考实现 | 常见替代方案 | | --- | --- | --- | | token 存储位置 | 服务器端 BFF;access 和 refresh token 永远不会到达浏览器,ID token 仅作为服务器在 RP-logout 重定向上发出的 `id_token_hint`(永远不会出现在 JS/storage/cookie 中) | 在浏览器中运行 PKCE 的公共 client SPA(token 可被 XSS 访问),或者仍然将 access token 交给 JavaScript 的后端 | | 组件架构 | 分离的 Auth Service(OAuth/OIDC client)+ API Gateway(路由、bearer 注入) | 一个合并的服务;虽然有效,但混淆了 OAuth-client 和 API-gateway 的角色 | | Session 状态 | 两个服务器端 keyspace,`tx:{state}`(授权前,以 OAuth `state` 为键)和 `sess:{sid}`(授权后);没有授权前的 session cookie,因此不存在 session fixation 类问题 | 框架的 HTTP-session blob | | Provider 耦合 | 根据 `.well-known/openid-configuration` 中的 `iss` / `aud` / scope / claim 路径进行分支处理;差异存在于配置中 | 将特定于 Provider 的 API 写死在 Java 或网关中 | 它实现了 [RFC 9700](https://datatracker.ietf.org/doc/rfc9700/)(OAuth 2.0 Security BCP,也是 OAuth 2.1 的基线)以及用于 ID-token 验证的 OIDC Core §3.1.3.7,涵盖两种流程:**浏览器登录**(带 saved-request replay 的 Authorization Code + PKCE)和**服务间通信**(Client Credentials)。 完整的理论基础和重新评估的触发条件位于 [`docs/architecture/architecture-decisions.md`](docs/architecture/architecture-decisions.md) 中。 ## 包含内容 - **一个实时测试断言没有 token 到达浏览器可见的表面。** `id_token` 永远不会到达浏览器 JS、storage、SPA 可读的 JSON 或 SPA 可见的 cookie;只有服务器的 `/auth/logout/continue` → IdP 重定向会携带 `id_token_hint`,测试证实了该浏览器可观察的路径。 - **每个控制项都链接到其规范、代码和测试。** [Security controls](#security-controls) 表格将每个控制项映射到其 RFC/OIDC 章节、实现它的代码以及证明它的关卡。 - **通过标准 OIDC 配置可以替换 Identity Providers。** 代码避免了特定于 Provider 的分支;issuer、audience、scope、claim 路径和 client identity 都是配置项。唯一固定的加密选择是 JWS 签名算法——RS256,在两个服务中都硬编码——因此使用不同算法(例如 ES256/PS256)签名的 IdP 需要修改一行代码,而不仅仅是修改配置。特定于 Provider 的设置说明位于 provider-adapter 文档中。`just e2e-portability` 针对第二个 realm 运行相同的代码,该 realm 的 token 结构完全不同。 ## 架构 | 组件 | 角色 | | --- | --- | | `frontend/` | React + TypeScript SPA。通过 cookie 进行身份验证。**浏览器中没有 OIDC client 库。** | | `auth-service/` | 机密 OIDC client(Nimbus `oauth2-oidc-sdk`)。拥有 `/auth/*`、OAuth 往返、session storage 和 `/internal/resolve`。 | | `api-gateway/` | APISIX standalone + 自定义 Lua 插件(`bff-session`)。拥有 `/api/**` 白名单、通过 `/internal/resolve` 进行 sid 解析(**不**持有 session-store 句柄)、bearer 注入和 signed-CSRF 验证。 | | `backend-resource-server/` | 仅进行 JWT 验证;永远看不到 session cookie。 | | `authorization-server/` | Keycloak realm + Compose 服务。 | 供应商的选择(Keycloak、APISIX、Valkey)是可互换的。[SPEC-0001](docs/specs/SPEC-0001-core-oidc-flows.md) 的附录 A 是供应商替换矩阵。
流程 1:登录(Authorization Code + PKCE) 当浏览器在没有 session 的情况下访问受保护的 `/api/**` URL,或者当用户点击“登录”时,登录流程开始。在无 session 的 `/api/**` 情况下: - 顶层导航 → `302` 到 `/auth/login?return_to=…`; - XHR → `401`,SPA 会自行导航。 然后 Auth Service 运行 OAuth 往返流程,并在设置了 session 和 CSRF cookie 后将浏览器返回到最初请求的 URL。 ``` sequenceDiagram autonumber actor U as User participant B as Browser (SPA) participant G as API Gateway participant A as Auth Service (BFF) participant V as Session Store participant K as IdP Note over B: Browser holds only an opaque __Host-sid cookie + a CSRF token —
never an access, refresh, or ID token. U->>B: Open a protected URL B->>G: GET /api/… (no session cookie) G-->>B: 302 → /auth/login (navigation) · 401 (XHR) B->>G: GET /auth/login?return_to=… G->>A: Forward /auth/login A->>A: Generate state, nonce, PKCE, browser-binding A->>V: Store tx:{state} (verifier, nonce, saved request, binding hash) A-->>G: 302 → IdP authorization endpoint (response_type=code, PKCE S256) G-->>B: Forward redirect + transaction cookie B->>K: Authenticate K-->>B: 302 → /auth/callback/idp?code&state (+ optional iss) B->>G: GET /auth/callback/idp (+ transaction cookie) G->>A: Forward callback A->>V: Atomically consume tx:{state} A->>K: Exchange code (+ PKCE verifier, client secret) K-->>A: access + refresh + ID tokens Note over A,K: Tokens exist only server-side, from here on. A->>A: Validate id_token A->>V: Create sess:{sid} + logout indexes A-->>G: 302 → original URL + __Host-sid + CSRF cookie G-->>B: Forward redirect + cookies ```
流程 2:身份检查(/auth/me SPA 本身不持有任何 session 状态。它调用 `/auth/me` 来了解 session 是否存在以及用户是谁。`/auth/me` 是一个纯粹的读取操作;它从不延长 session,也从不返回 token。 ``` sequenceDiagram autonumber participant B as Browser (SPA) participant G as API Gateway participant A as Auth Service (BFF) participant V as Session Store Note over B: On mount, the SPA checks who is signed in — its only window into session state. B->>G: GET /auth/me (sends the __Host-sid cookie) G->>A: Forward /auth/me A->>V: Read the session record (pure read, no idle-window slide) alt session valid A-->>G: 200 allowlisted identity claims G-->>B: 200 identity claims (+ optional auth_time, acr) Note over B: Authenticated — render identity and roles (display only, never a token) else no, expired, or server-deleted session A-->>G: 401 (Cache-Control: no-store) G-->>B: 401 Note over B: Anonymous — render the Sign-in prompt end ```
流程 3:已验证的请求(phantom token + 透明刷新) 每个 `/api/**` 调用仅携带不透明的 session cookie,即 phantom-token 模式,其中只有 Auth Service 会接触 session store(参见 [`docs/architecture/phantom-token-session-resolution.md`](docs/architecture/phantom-token-session-resolution.md)): - 网关通过 `/internal/resolve`(通过内部 RPC 进行 Client Credentials)解析 sid。 - 如果接近过期,Auth Service 会滑动空闲窗口并刷新 access token。 - 网关将返回的 token 作为 bearer 注入到 Resource Server。 ``` sequenceDiagram autonumber participant B as Browser (SPA) participant G as API Gateway participant A as Auth Service (BFF) participant V as Session Store participant K as IdP participant R as Resource Server B->>G: GET /api/… (Cookie __Host-sid) Note over B,G: State-changing methods also send the signed CSRF header. Note over G: The gateway holds no store handle — it resolves the sid via the Auth Service. G->>A: POST /internal/resolve (gateway service token + sid) A->>V: Look up session alt access token fresh A->>V: Slide idle window else access token near expiry A->>A: Acquire per-session lock A->>V: Re-read session under lock alt another caller already refreshed A->>V: Slide idle window else still near expiry A->>K: Refresh-token grant K-->>A: rotated access + refresh tokens A->>V: Atomic move sess:{sid}→sess:{sid'} + rotated:{sid} breadcrumb A->>V: CAS/repoint logout and subject indexes to sid' end end A-->>G: 200 access_token (+ rotated_sid, rotated_csrf when the sid rotated) opt resolve rotated the sid Note over B,G: Gateway re-issues __Host-sid and XSRF-TOKEN (bound to sid') on this response. end G->>R: GET /api/… + Authorization: Bearer access_token Note over G,R: Gateway strips the inbound cookie and injects the bearer.
The browser never sends or sees a token. R->>R: Validate JWT (iss, sig, aud, exp, scope/roles) R-->>G: 200 G-->>B: 200 ```
流程 4:登出(由 RP 发起,id_token_hint 永远不会到达 SPA 代码或 storage) IdP end-session URL 携带 `id_token_hint`(PII),因此它永远不会到达 SPA JavaScript。Auth Service 返回一个 same-origin、一次性的句柄,并从 `/auth/logout/continue` 自行发出 IdP 重定向。 ``` sequenceDiagram autonumber participant B as Browser (SPA) participant G as API Gateway participant A as Auth Service (BFF) participant V as Session Store participant K as IdP B->>G: POST /auth/logout (Cookie: __Host-sid, header: CSRF) G->>A: Forward /auth/logout A->>A: Validate signed CSRF A->>V: Delete session + indexes · store single-use logout handle A-->>G: 200 logoutUrl=/auth/logout/continue?lc=… + evict cookies G-->>B: Forward same-origin handle + cookie eviction Note over B,A: The SPA receives only a same-origin handle —
never the IdP URL or id_token_hint. B->>G: GET /auth/logout/continue?lc=… (top-level navigation) G->>A: Forward continuation A->>V: Atomically consume handle → IdP end-session URL A-->>G: 302 → end_session_endpoint?id_token_hint (Referrer-Policy: no-referrer) G-->>B: Forward server-emitted redirect B->>K: GET end_session_endpoint K-->>B: 302 → / ```
流程 5:服务间通信 机器调用方直接从 Authorization Server 获取 token,并使用 bearer 调用 Resource Server。Auth Service 和 API Gateway 都不在该路径中。 ``` sequenceDiagram autonumber participant SC as Service Client (machine) participant K as IdP participant R as Resource Server Note over SC,R: Machine-to-machine — neither the Browser, Gateway, nor BFF is in the path. SC->>K: Client Credentials grant (confidential-client authentication) K-->>SC: access_token (aud, scope) SC->>R: POST /api/jobs + Authorization: Bearer access_token R->>R: Validate JWT (iss, sig, aud, exp, scope) R-->>SC: 200 ```
协议层面的细节(确切的 cookie 属性、TTL、验证规则以及 `/internal/resolve`、`sess:{sid}` 和 signed-CSRF 契约)位于 [SPEC-0001](docs/specs/SPEC-0001-core-oidc-flows.md) 中。 ## Cookies 本参考实现使用了三种 cookie 类型,每种都有其自身的 scope 和 `SameSite` 值: | Cookie | JS 可读? | `SameSite` | 原因 | | --- | --- | --- | --- | | `__Host-sid` | 否(`HttpOnly`) | `Lax` | 唯一的凭证。在初始回调之前不存在 session cookie。`Lax` 支持直接回调到 saved-request 的导航,以及稍后的顶层跨站点返回,同时由 signed CSRF 保护状态更改请求。 | | `XSRF-TOKEN` | 是 | `Strict` | 携带经 HMAC-SHA256 签名的值(`.`,绑定到 `sid`)。SPA 将其作为 `X-XSRF-TOKEN` 回传。使用 **Strict** 是因为,与 session cookie 不同,它在跨站点回调中永远不需要。 | | `oauth_tx` | 否(`HttpOnly`) | `Lax` | 在 `/auth/login` 处发出的浏览器绑定 cookie,scope 限定为 `Path=/auth/callback/idp`。其 HMAC 存储在 `tx:{state}` 中;如果回调时发现不匹配将拒绝访问,从而挫败攻击者从不同的 user-agent 窃取 `(code, state)` 的行为。 | 两个细节问题: - **为什么使用 signed double-submit。** 拥有兄弟子域 `document.cookie` 写入权限的攻击者可以伪造一对匹配的未签名对。HMAC(绑定到 `sid`)会使伪造的对验证失败,因此未签名的 double-submit 会被直接拒绝。 - **刷新时轮换 Sid(控制项 A6)。** token 刷新会轮换 `sid`:Auth Service 以原子方式将 `sess:{sid}` 移动到 `sess:{sid'}`,并留下一个短期的 `rotated:{sid}` 面包屑,这样在旧 sid 上正在进行的请求会跟随它,而不是丢失 session。`/internal/resolve` 返回 `rotated_sid`、`rotated_sid_max_age` 和 `rotated_csrf`,网关会重新签发 `__Host-sid` 和绑定 HMAC 的 `XSRF-TOKEN`。这会将一次观察到的 sid 限定在单个刷新周期,而不是整个 session 生命周期([SECURITY](SECURITY.md) S-5)。面包屑和登出竞态机制详见 [SPEC-0001](docs/specs/SPEC-0001-core-oidc-flows.md)。 ## 安全控制 每个控制项都映射到其参考规范和实现代码。 | 控制项 | 参考 | 位置 | | --- | --- | --- | | Authorization Code + PKCE S256 | OIDC Core §3.1.2 | `auth-service` | | `state`、`nonce`、ID-token 签名/iss/aud/exp | OIDC Core §3.1.3 | `JwtOidcIdTokenValidator` | | 存在时的 `at_hash` | OIDC Core §3.1.3.7 步骤 7 | `JwtOidcIdTokenValidator` | | Access-token 签名/iss/aud/exp 以及 JOSE `typ=JWT|at+JWT` | RFC 7519, RFC 9068 | RS `SecurityConfig`、`JwtDecoderNegativeTest` | | `iss` 查询参数混淆防御 | [RFC 9207](https://datatracker.ietf.org/doc/rfc9207/) | `AuthController#callback` | | 刷新被 AS 拒绝(`invalid_grant`) → 409 + session 失效;realm 启用轮换 + 重用检测 | [RFC 9700 §4.14](https://datatracker.ietf.org/doc/rfc9700/) | `AuthorizationCodeTokenRefreshClient` + realm | | Signed double-submit CSRF(HMAC-SHA256, base64url) | — | `SignedCsrfSupport`, `bff-session.lua` | | `oauth_tx` 浏览器绑定 cookie | — | `OAuthTxBinding` | | 带有 `id_token_hint` 的 RP 发起的登出 | OIDC RP-Initiated Logout 1.0 | `AuthController#logout` | | Step-up:敏感路由上的 `auth_time` 近期性**和** `acr` 保证关卡 | OIDC Core §3.1.2.1, [RFC 9470](https://datatracker.ietf.org/doc/rfc9470/) | RS `ApiController#admin`, `AuthController#stepUp`, realm `auth_time` + `acr` 映射器 | | 通过 `app.base-url` 固定 `redirect_uri`(防止 Host-header 注入) | — | `AuthController#baseUrl` | | 仅在安全请求上接受 `__Host-sid` 作为 session cookie(防御 cookie-tossing / 强制登录) | — | `AuthController#sessionId` | | 每 session 刷新锁(默认为进程内,可选分布式) | — | `RefreshLock`, `InProcessRefresh`, `DistributedRefreshKeyLock`, `RefreshLockConfig`, `bff-session.lua` | | 刷新时轮换 Sid:原子性的 `sess:{sid}`→`sess:{sid'}` 移动 + `rotated:{sid}` 面包屑,以便正在进行的请求跟随它 | — | `InternalResolveController` (A6);由 `reference-flow.spec.ts` story 17 和 `e2e-distributed-lock.sh` 证明 | | `/auth/login` + `/auth/callback/idp` 上的速率限制 | — | `apisix.yaml.template` | | 拒绝默认开发密钥的哨兵守卫(在启动/渲染时 fail-closed) | — | `SecretSentinelValidator`, `render-apisix-config.sh`, `bff-session.lua` | **`acr` scope(本地 realm)。** 全新的交互式登录映射到 `acr=1`;记住的 SSO 映射到 `acr=0`。关卡拒绝任何低于 `app.step-up.required-acr`(默认为 `1`)的 `acr`。请注意,**`acr=1` 是一个 Level-of-Assurance 值;它不能证明 MFA。** 将 `acr` 映射到真实的 MFA 级别是每个 IdP 的配置,在此未做处理。详见 [`RFC9470-compliance.md`](RFC9470-compliance.md)。 ## 特意*不*包含的内容 完整理由详见 [`docs/architecture/architecture-decisions.md`](docs/architecture/architecture-decisions.md) §F。 - **Sender-constrained tokens(DPoP / mTLS)。** RS bearer token 未绑定发送方,因此在添加之前,Resource Server 的网络隔离是承重环节([SECURITY](SECURITY.md) G-8)。当 RS 面临不受信任的调用方时,请重新考虑此项。 - **非对称 client 认证(`private_key_jwt`、mTLS 到 AS)。** 共享密钥认证足以满足基线要求。对于 FAPI / PSD2 需重新考虑。 - **JAR、PAR、RAR。** 确切的 redirect-URI + PKCE + state + nonce 涵盖了该流程;scope 涵盖了授权。对于多个 AS 或按 resource 授权的情况需重新考虑。 - **OIDC Front-Channel Logout。** 由 RP 发起的登出 + OIDC Back-Channel Logout(已实现,`POST /backchannel-logout`)已经涵盖;iframe 变体未包含在内。 - **OIDC Session Management。** 没有浏览器↔AS session 可供监控;状态通过 `/auth/me` 或下一个返回 401 的 `/api/**` 显现。 - **Valkey 中的静态加密 session。** 本地 Valkey 运行时没有 AUTH/TLS/加密。在任何非本地部署之前添加此项。 ## 技术栈 - React 19 + TypeScript, Vite - Java 25 + Spring Boot 4 (Auth Service, Resource Server) - Nimbus `oauth2-oidc-sdk` 用于 OIDC discovery, JWKS, ID-token 验证, PKCE - Spring Security 7 (JWT decoder, validator composition) - Apache APISIX 3 standalone + 自定义 Lua 插件(`lua-resty-http`, `lua-resty-lock`) - Keycloak 26(通过 `KC_DB=dev-file` 内嵌 H2;无需单独的数据库) - Valkey 9(兼容 Redis 的状态存储) - Docker Compose ## 本地运行 适用于 macOS、Linux 和 Windows。 **前置条件** - **Docker Desktop** (macOS/Windows) 或任何兼容 Docker 的引擎,如 Podman。 - **Node 20+** 用于 SPA 开发服务器。 - **一个 POSIX shell** 用于 `scripts/*.sh`:macOS/Linux 内置;在 Windows 上使用 WSL2(推荐)或 Git Bash。 - **Java 25**:仅在你在 Docker *外部*运行 Spring 模块或其单元测试时才需要在主机上安装(Docker 会为你构建 Java 镜像)。 - **`just` 是可选的**:它是一个命令运行器;每个配方都包装了一个脚本(`just up` 运行 `sh scripts/up.sh`)。通过 `brew install just`、`winget install Casey.Just` 或 `scoop install just` 安装。 ``` # 1. 启动 reference stack (Keycloak, Valkey, APISIX, Auth Service, Resource Server)。 just up # or, without just: sh scripts/up.sh # 2. 启动 SPA dev server。 cd frontend && npm install && npm run dev ``` - **SPA:** ,以 `alice` / `alice` 登录。 - **Keycloak 管理界面:** ,`admin` / `admin` 检查种子 realm。 **验证** ``` just e2e-auth # authenticated proof: login → API → refresh → logout just e2e-portability # same code against a second realm (IdP portability) sh scripts/verify-all.sh # per-component checks + secret scan RUN_FULL_STACK_AUTH=1 sh scripts/verify-all.sh # the above, plus full stack + gateway suite ``` ## 术语表 OAuth/OIDC 词汇,映射到本仓库的组件。 | 术语 | 含义 | | --- | --- | | OIDC | OpenID Connect,建立在 OAuth 2.0 之上的身份层。 | | Relying Party (RP) | 将登录委托给 identity provider 的应用。在这里是 Auth Service。 | | Authorization Server (AS) | 认证用户并签发 token 的服务。在这里是 Keycloak。 | | Identity Provider (IdP) | 处于身份角色的 Authorization Server;此处可互换使用。 | | Resource Server (RS) | 验证 access token 并提供数据服务的 API。在这里是 `backend-resource-server`。 | | BFF | Backend-for-Frontend;持有 token 的服务器端组件,这样浏览器就不必持有它们。 | | `sid` / session cookie | `sid` 是不透明的 session 标识符;服务器以此作为记录的键(`sess:{sid}`)。浏览器在 `__Host-sid` 中携带 `sid`,这是它唯一的凭证。Cookie 是信封;`sid` 是里面的值。 | | PKCE | Proof Key for Code Exchange;将 authorization code 绑定到发起流程的 client。 | | JWT / JWKS | JSON Web Token / JSON Web Key Set(用于验证 JWT 签名的公钥)。 | | CSRF / XSS | Cross-Site Request Forgery / Cross-Site Scripting(跨站请求伪造 / 跨站脚本攻击)。 | | SPA | Single-page application;浏览器应用(此处为 React)。 | | acr / LoA | Authentication Context Class Reference / Level of Assurance;用户认证的强度。 | | SSO | Single sign-on(单点登录)。 | ## 文档 - [`docs/specs/SPEC-0001-core-oidc-flows.md`](docs/specs/SPEC-0001-core-oidc-flows.md): 构建契约。`sess:{sid}`、`tx:{state}`、`/internal/resolve`、signed CSRF 的协议格式;威胁模型;信任边界。附录 A 是供应商替换矩阵。 - [`docs/architecture/architecture-decisions.md`](docs/architecture/architecture-decisions.md): 理论基础 + 被否决的替代方案。 - [`SECURITY.md`](SECURITY.md): 威胁模型、加密原语、密钥处理、审计日志范围、生产环境强化清单、漏洞报告。 - [`OIDC-compliance.md`](OIDC-compliance.md): 针对 OpenID Connect Core 1.0 + Discovery + RP-Initiated Logout 的合规性矩阵。 - [`RFC9700-compliance.md`](RFC9700-compliance.md): 针对 RFC 9700(OAuth 2.0 Security BCP / OAuth 2.1 基线)的逐项控制状态。 - [`RFC9470-compliance.md`](RFC9470-compliance.md): 针对 RFC 9470(Step-Up Authentication Challenge)的逐项控制状态。 - [`docs/reference/refresh-rotation.md`](docs/reference/refresh-rotation.md): refresh-token 轮换策略和 `app.refresh-require-rotation` 旋钮。 - [`docs/operations/provider-adapters.md`](docs/operations/provider-adapters.md): IdP 替换演练(Keycloak / Auth0 / Okta / Entra)。 - [`docs/operations/production-hardening.md`](docs/operations/production-hardening.md): 本地参考实现与真实部署之间的差距清单。 - [`AGENTS.md`](AGENTS.md): 贡献者操作契约。 ## License [Apache-2.0](LICENSE).
标签:API网关, BFF架构, OAuth 2.1, OpenID Connect, rizin, Spring Boot, 参考实现, 域名枚举